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
13 changes: 12 additions & 1 deletion src/app/api/profile/ai/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions src/lib/agents/drivers/coder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 " +
"/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;
Expand All @@ -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} ` +
Expand Down Expand Up @@ -464,6 +501,7 @@ export const coderDriver: Driver = {
agentRunId: ctx.runId,
});
log(`[coder] posted clarifying comment #${posted.id}`);
await chargeRun();
return {
status: "ok",
summary: [
Expand Down Expand Up @@ -607,6 +645,7 @@ export const coderDriver: Driver = {
log(`[coder] posted PR-accompanying comment #${posted.id}`);
}

await chargeRun();
return {
status: "ok",
summary: [
Expand Down
87 changes: 87 additions & 0 deletions src/lib/ledger/client.ts
Original file line number Diff line number Diff line change
@@ -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<number | null> {
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<DebitResult> {
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 };
}
}
31 changes: 31 additions & 0 deletions src/lib/ledger/policy.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
37 changes: 37 additions & 0 deletions tests/ledger-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});