From 82d2c31ff40a4cf2c7d67289aca03b9b1ea304f9 Mon Sep 17 00:00:00 2001 From: huhn511 Date: Sat, 20 Jun 2026 17:24:12 +0200 Subject: [PATCH] feat(coder): meter the bridge-default LLM key against free MIND allotment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coder's "env-fallback" path (shared bridge OPENROUTER_API_KEY) now spends a user's free MIND allotment from the mind-node ledger: it gates a run on the owner's balance and debits a flat price on success. A user on their own key (source "user-pref") is never metered; with the ledger off (no MIND_NODE_URL / MIND_OPERATOR_TOKEN env), env-fallback runs unmetered exactly as before. - src/lib/ledger/client.ts — server-only operator client (getBalance, debit) - src/lib/ledger/policy.ts — pure gateEnvFallback decision (unit-tested) - coder driver — gate before run (block with "add your key" at 0), debit once on a successful run (failed/late debit never fails the user after the fact) - GET /api/profile/ai — returns freeBalance so the builder can show it Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/profile/ai/route.ts | 13 ++++- src/lib/agents/drivers/coder.ts | 39 +++++++++++++++ src/lib/ledger/client.ts | 87 +++++++++++++++++++++++++++++++++ src/lib/ledger/policy.ts | 31 ++++++++++++ tests/ledger-gate.test.ts | 37 ++++++++++++++ 5 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/lib/ledger/client.ts create mode 100644 src/lib/ledger/policy.ts create mode 100644 tests/ledger-gate.test.ts diff --git a/src/app/api/profile/ai/route.ts b/src/app/api/profile/ai/route.ts index 4263c1e..adc3ad6 100644 --- a/src/app/api/profile/ai/route.ts +++ b/src/app/api/profile/ai/route.ts @@ -6,6 +6,7 @@ import { resolveCoderConfigSummary, } from "@/lib/ai-providers/store"; import { PROVIDERS } from "@/lib/ai-providers/providers"; +import { ledgerEnabled, getBalance } from "@/lib/ledger/client"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -25,10 +26,20 @@ export async function GET() { const auth = await requireSession(); if (!auth.ok) return auth.response; + const summary = resolveCoderConfigSummary(auth.webId); + // Free-allotment remaining: only meaningful while the coder would run on the + // bridge-default key. null when the ledger is off or the user is on their own + // key — the builder then renders the existing source-based copy unchanged. + const freeBalance = + summary.source === "env-fallback" && ledgerEnabled() + ? await getBalance(auth.webId) + : null; + return NextResponse.json({ providers: listConfiguredProviders(auth.webId), pref: getUserAiPref(auth.webId), - summary: resolveCoderConfigSummary(auth.webId), + summary, + freeBalance, catalog: PROVIDERS.map((p) => ({ name: p.name, label: p.label, diff --git a/src/lib/agents/drivers/coder.ts b/src/lib/agents/drivers/coder.ts index ce70fcb..a4ac75a 100644 --- a/src/lib/agents/drivers/coder.ts +++ b/src/lib/agents/drivers/coder.ts @@ -21,6 +21,8 @@ import { validateName } from "@/lib/registry/repos"; import { getOwnerFetch } from "@/lib/solid/fetch-for-owner"; import { ensureContainer, setPublicReadAcl } from "@/lib/solid/containers"; import { resolveCoderConfig } from "@/lib/ai-providers/store"; +import { ledgerEnabled, getBalance, debit, llmPrice } from "@/lib/ledger/client"; +import { gateEnvFallback } from "@/lib/ledger/policy"; import { AGENT_LOGS_DIR } from "@/lib/agents/dispatch"; import { PROVIDERS, @@ -181,6 +183,26 @@ export const coderDriver: Driver = { }; } + // Free-allotment metering — only the bridge-default ("env-fallback") key is + // metered. A user on their own key (source "user-pref") runs unmetered; with + // the ledger off, env-fallback also runs unmetered (today's behavior). When + // metered, gate on the owner's MIND balance and debit after a successful run. + let meterRun = false; + if (config.source === "env-fallback") { + const balance = ledgerEnabled() ? await getBalance(repo.ownerWebId) : null; + const gate = gateEnvFallback({ ledgerEnabled: ledgerEnabled(), balance }); + if (gate.kind === "blocked") { + return { + status: "error", + summary: + "You've used your free AI allotment. Connect your own AI key at " + + "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/profile/ai-providers to keep using the coder.", + error: "out of free usage", + }; + } + meterRun = gate.meter; + } + const image = process.env.MIND_CODER_IMAGE ?? DEFAULT_IMAGE; const timeoutS = Number(process.env.MIND_CODER_TIMEOUT ?? DEFAULT_TIMEOUT_S); const orModel = config.model; @@ -199,6 +221,21 @@ export const coderDriver: Driver = { if (logStream) logStream.write(`${line}\n`); }; const summaryLines: string[] = []; + // Debit the owner's free allotment once, on a successful metered run. A + // failed/late debit (e.g. a concurrent run drained the balance) never fails + // the user after the fact — the work is already done; we just log it. + let charged = false; + const chargeRun = async () => { + if (!meterRun || charged) return; + charged = true; + const res = await debit( + repo.ownerWebId, + llmPrice(), + `builder:coder#${issueNumber}`, + ); + if (res.ok) log(`[coder] metered ${llmPrice()} MIND (balance ${res.balance})`); + else log(`[coder] meter debit failed (status ${res.status}) — not charging`); + }; try { log( `[coder] start ${repoOwner}/${repoName}#${issueNumber} ` + @@ -464,6 +501,7 @@ export const coderDriver: Driver = { agentRunId: ctx.runId, }); log(`[coder] posted clarifying comment #${posted.id}`); + await chargeRun(); return { status: "ok", summary: [ @@ -607,6 +645,7 @@ export const coderDriver: Driver = { log(`[coder] posted PR-accompanying comment #${posted.id}`); } + await chargeRun(); return { status: "ok", summary: [ diff --git a/src/lib/ledger/client.ts b/src/lib/ledger/client.ts new file mode 100644 index 0000000..f68c438 --- /dev/null +++ b/src/lib/ledger/client.ts @@ -0,0 +1,87 @@ +/** + * Server-only client for the mind-node MIND ledger (operator surface), used by + * the coder driver to meter the bridge-default LLM key against a user's free + * allotment. The bridge holds the operator token and spends on the user's + * behalf: it reads the balance before an env-fallback run and debits a flat + * price after one succeeds. + * + * Configuration (server env): + * MIND_NODE_URL e.g. https://pods.mindpods.org (ledger base) + * MIND_OPERATOR_TOKEN the mind-node SOLIDRS_ADMIN_TOKEN bearer + * MIND_LLM_PRICE MIND debited per coder run (default 1) + * + * Unset URL/token ⇒ ledger off: no balance checks, no debits, and the coder + * behaves exactly as before this feature. + */ + +export interface LedgerConfig { + url: string; + token: string; + price: number; +} + +export function ledgerConfig(): LedgerConfig | null { + const url = process.env.MIND_NODE_URL?.trim().replace(/\/$/, ""); + const token = process.env.MIND_OPERATOR_TOKEN?.trim(); + if (!url || !token) return null; + const price = Math.max(1, Number(process.env.MIND_LLM_PRICE ?? "1") || 1); + return { url, token, price }; +} + +export function ledgerEnabled(): boolean { + return ledgerConfig() !== null; +} + +export function llmPrice(): number { + return ledgerConfig()?.price ?? 1; +} + +/** + * The caller's MIND balance, or null when it can't be determined (ledger off, + * disabled on the node, or unreachable). Null ⇒ fail open — don't block a + * coder run on a ledger outage. + */ +export async function getBalance(webId: string): Promise { + const cfg = ledgerConfig(); + if (!cfg) return null; + try { + const res = await fetch(`${cfg.url}/.admin/tokens?owner=${encodeURIComponent(webId)}`, { + headers: { authorization: `Bearer ${cfg.token}` }, + }); + if (!res.ok) return null; + const body = (await res.json()) as { balance?: number }; + return typeof body.balance === "number" ? body.balance : null; + } catch { + return null; + } +} + +export type DebitResult = + | { ok: true; balance: number } + | { ok: false; status: number; balance: number | null }; + +/** + * Debit `amount` MIND from `webId`. A 402 means the balance was already spent; + * the run has happened, so the caller logs and continues rather than failing + * the user after the fact. + */ +export async function debit( + webId: string, + amount: number, + memo: string, +): Promise { + const cfg = ledgerConfig(); + if (!cfg) return { ok: false, status: 0, balance: null }; + try { + const res = await fetch(`${cfg.url}/.admin/tokens/debit`, { + method: "POST", + headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" }, + body: JSON.stringify({ owner: webId, amount, memo }), + }); + const body = (await res.json().catch(() => ({}))) as { balance?: number }; + if (res.ok) return { ok: true, balance: body.balance ?? 0 }; + return { ok: false, status: res.status, balance: body.balance ?? null }; + } catch { + return { ok: false, status: 0, balance: null }; + } +} diff --git a/src/lib/ledger/policy.ts b/src/lib/ledger/policy.ts new file mode 100644 index 0000000..eee0861 --- /dev/null +++ b/src/lib/ledger/policy.ts @@ -0,0 +1,31 @@ +/** + * Free-allotment gate for the coder's bridge-default ("env-fallback") LLM key + * (pure, unit-tested). Only the company key is metered: a user running on their + * own BYOK key (source "user-pref") never touches the ledger, and when the + * ledger is switched off the bridge behaves exactly as before (company key, + * unmetered). + */ + +export type FallbackGate = + | { kind: "allow"; meter: boolean } + | { kind: "blocked"; balance: number }; + +/** + * Decide whether an env-fallback (company-key) coder run may proceed, and + * whether to debit it afterward. + * + * - ledger off → allow, unmetered (today's behavior). + * - ledger on, spent → blocked (tell the user to add their own key). + * - ledger on, has/unknown → allow, metered. (A null balance means the ledger + * was unreachable; we fail open and still let the run proceed.) + */ +export function gateEnvFallback(input: { + ledgerEnabled: boolean; + balance: number | null; +}): FallbackGate { + if (!input.ledgerEnabled) return { kind: "allow", meter: false }; + if (input.balance !== null && input.balance <= 0) { + return { kind: "blocked", balance: input.balance }; + } + return { kind: "allow", meter: true }; +} diff --git a/tests/ledger-gate.test.ts b/tests/ledger-gate.test.ts new file mode 100644 index 0000000..fe0faf0 --- /dev/null +++ b/tests/ledger-gate.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { gateEnvFallback } from "@/lib/ledger/policy"; + +describe("gateEnvFallback — free-allotment gate for the bridge-default key", () => { + it("ledger off → allow, unmetered (today's behavior)", () => { + expect(gateEnvFallback({ ledgerEnabled: false, balance: null })).toEqual({ + kind: "allow", + meter: false, + }); + // even a 0 balance is irrelevant when the ledger is off + expect(gateEnvFallback({ ledgerEnabled: false, balance: 0 })).toEqual({ + kind: "allow", + meter: false, + }); + }); + + it("ledger on, has balance → allow, metered", () => { + expect(gateEnvFallback({ ledgerEnabled: true, balance: 5 })).toEqual({ + kind: "allow", + meter: true, + }); + }); + + it("ledger on, spent → blocked", () => { + expect(gateEnvFallback({ ledgerEnabled: true, balance: 0 })).toEqual({ + kind: "blocked", + balance: 0, + }); + }); + + it("ledger on, unknown balance (unreachable) → allow, metered (fail open)", () => { + expect(gateEnvFallback({ ledgerEnabled: true, balance: null })).toEqual({ + kind: "allow", + meter: true, + }); + }); +});