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
1 change: 1 addition & 0 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 @@ -73,6 +73,7 @@
"glob": "^13.0.1",
"ignore": "^7.0.0",
"ink": "^5.2.1",
"openai": "6.26.0",
"proper-lockfile": "^4.1.2",
"react": "^18.3.1",
"typebox": "^1.1.24",
Expand Down
1 change: 1 addition & 0 deletions src/agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Rewind and checkpoint behavior must keep conversation and filesystem state consistent.
- Tournaments must isolate contestant worktrees and avoid merging failed/unsafe outputs.
- Model selection should respect user config, live overrides, local/cloud capability, and BYOK settings.
- Proxy spending uses confirmed server receipts, never dollar estimates. Missing receipts are unconfirmed. Share the billing ledger across main/helper/subagent requests and preserve the safe proxy stream wrapper. Totals cover the current agent lifetime only.

## Work Guidance

Expand Down
13 changes: 10 additions & 3 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ import { buildTools } from "../tools/registry.js";
import { TaskStore } from "../tools/task-store.js";
import type { ToolContext } from "../tools/types.js";
import { UserQueryStore } from "../user-queries/store.js";
import { BillingLedger } from "./codebase-billing.js";
import { createCodebaseStream } from "./codebase-stream.js";
import { type ResolvedConfig, resolveConfig } from "./config.js";
import { type Effort, resolveEffort } from "./effort.js";
import { fetchAvailableModels, type ModelOption } from "./model-list.js";
import { buildProjectFilesAddendum } from "./project-files.js";
import { streamProxySafely } from "./safe-stream.js";
import { createSafeProxyStream } from "./safe-stream.js";
import { buildSystemPrompt } from "./system-prompt.js";

const WRITE_TOOL_NAMES: ReadonlySet<string> = new Set(["write_file", "edit_file", "multi_edit", "notebook_edit"]);
Expand Down Expand Up @@ -111,6 +113,7 @@ export interface CreateAgentOptions {
}

export interface AgentBundle {
billing?: BillingLedger;
agent: Agent;
model: Model<string>;
source: ResolvedConfig["source"];
Expand Down Expand Up @@ -203,6 +206,8 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
const tokenManager =
source === "proxy" ? new TokenManager({ store: credentials, oauthConfig: defaultOAuthConfig() }) : null;
const getApiKey = tokenManager ? () => tokenManager.getAccessToken() : () => apiKey;
const billing = source === "proxy" ? new BillingLedger() : undefined;
const streamFn = billing ? createSafeProxyStream(createCodebaseStream(billing, model.baseUrl!)) : undefined;

const config = persistedConfig;
const permissions = new PermissionStore({
Expand All @@ -225,6 +230,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
fastModel: glueModels.fast,
smartModel: glueModels.smart,
getApiKey,
streamFn,
});
// Pass model.contextWindow explicitly so proxy-synthesized models
// (Codebase Auto, custom in-house IDs) get the real window instead of
Expand Down Expand Up @@ -367,7 +373,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
...(thinkingLevel && { thinkingLevel: thinkingLevel as Effort }),
},
getApiKey,
...(source === "proxy" && { streamFn: streamProxySafely }),
streamFn,
beforeToolCall: (ctx, signal) => guardToolCall(ctx.toolCall.name, ctx.args, signal),
afterToolCall: async (ctx, signal) => {
await dispatchPostToolHooks(
Expand Down Expand Up @@ -416,7 +422,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
...(effort && { thinkingLevel: effort }),
},
getApiKey,
...(source === "proxy" && { streamFn: streamProxySafely }),
streamFn,
transformContext: async (messages, signal) => {
if (!compaction.needsCompaction(messages)) return withRelevantMemoryReminder(memory, messages);

Expand Down Expand Up @@ -626,6 +632,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
void agentRef;
return {
agent,
billing,
model,
source,
toolContext,
Expand Down
47 changes: 47 additions & 0 deletions src/agent/codebase-billing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export interface BillingReceipt {
creditsCharged: number | null;
usageEstimated: boolean;
model: string;
}

export interface BillingSummary {
creditsCharged: number;
requests: number;
unconfirmed: number;
estimated: number;
models: string[];
}

export function parseBillingReceipt(value: unknown, model: string): BillingReceipt | undefined {
if (!value || typeof value !== "object") return undefined;
const row = value as Record<string, unknown>;
if (typeof row.usage_estimated !== "boolean") return undefined;
const credits = row.credits_charged;
if (credits !== null && (typeof credits !== "number" || !Number.isSafeInteger(credits) || credits < 0)) {
return undefined;
}
return { creditsCharged: credits as number | null, usageEstimated: row.usage_estimated, model };
}

/** One entry per HTTP request, including sidecar and subagent calls. */
export class BillingLedger {
private readonly entries: BillingReceipt[] = [];
record(receipt: BillingReceipt): void {
this.entries.push(receipt);
}
snapshot(): BillingSummary {
return {
creditsCharged: this.entries.reduce((sum, row) => sum + (row.creditsCharged ?? 0), 0),
requests: this.entries.length,
unconfirmed: this.entries.filter((row) => row.creditsCharged === null).length,
estimated: this.entries.filter((row) => row.usageEstimated).length,
models: [...new Set(this.entries.map((row) => row.model))],
};
}
}

export function formatBillingSummary(summary: BillingSummary): string {
if (summary.requests === 0) return "No confirmed charges yet";
const unknown = summary.unconfirmed ? ` + ${summary.unconfirmed} unconfirmed` : "";
return `${summary.creditsCharged.toLocaleString()} credits charged${unknown}`;
}
165 changes: 165 additions & 0 deletions src/agent/codebase-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import type { Model } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it } from "vitest";
import { BillingLedger, formatBillingSummary, parseBillingReceipt } from "./codebase-billing.js";
import { createCodebaseStream } from "./codebase-stream.js";

let server: Server | undefined;
afterEach(async () => {
server?.closeAllConnections();
if (server) await new Promise<void>((resolve) => server!.close(() => resolve()));
});
async function serve(chunks: unknown[], status = 200) {
let request: Record<string, unknown> | undefined;
server = createServer(async (req, res) => {
let raw = "";
for await (const bytes of req) raw += bytes;
request = JSON.parse(raw);
res.writeHead(status, { "content-type": status === 200 ? "text/event-stream" : "application/json" });
if (status !== 200) return res.end(JSON.stringify(chunks[0]));
for (const chunk of chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
res.end("data: [DONE]\n\n");
});
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
const model: Model<string> = {
id: "d4f",
name: "Auto",
provider: "codebase",
api: "openai-completions",
baseUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
reasoning: false,
input: ["text"],
contextWindow: 131072,
maxTokens: 8192,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
const ledger = new BillingLedger();
const stream = createCodebaseStream(ledger, model.baseUrl!);
return {
ledger,
model,
stream,
request: () => request,
run: (signal?: AbortSignal) =>
stream(
model,
{ systemPrompt: "Be concise", messages: [{ role: "user", content: "hi", timestamp: 1 }] },
{ apiKey: "fake-key", signal },
),
};
}
const final = {
model: "deepseek-flash",
choices: [],
usage: {
prompt_tokens: 100,
completion_tokens: 10,
total_tokens: 110,
prompt_tokens_details: { cached_tokens: 80, cache_write_tokens: 5 },
},
codebase_billing: { credits_charged: 3, usage_estimated: false },
};

describe("Codebase receipt transport", () => {
it("uses the SDK stream with terminal usage-only receipts and the actual fallback model", async () => {
const h = await serve([
{ model: "deepseek-flash", choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }] },
final,
final,
]);
const result = await h.run().result();
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
expect(result.responseModel).toBe("deepseek-flash");
expect(result.usage).toMatchObject({ input: 15, output: 10, cacheRead: 80, cacheWrite: 5 });
expect(h.ledger.snapshot()).toEqual({
creditsCharged: 3,
requests: 1,
estimated: 0,
unconfirmed: 0,
models: ["deepseek-flash"],
});
expect(h.request()).toMatchObject({ stream: true, stream_options: { include_usage: true } });
});
it("never calls a missing or null receipt free", async () => {
const h = await serve([{ choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }] }]);
await h.run().result();
expect(formatBillingSummary(h.ledger.snapshot())).toBe("0 credits charged + 1 unconfirmed");
expect(parseBillingReceipt({ credits_charged: null, usage_estimated: true }, "d4f")?.creditsCharged).toBeNull();
});
it("keeps the receipt sent before an interrupted-stream SDK error", async () => {
const h = await serve([final, { error: { type: "provider_error", message: "upstream unavailable" } }]);
expect((await h.run().result()).stopReason).toBe("error");
expect(h.ledger.snapshot()).toMatchObject({ creditsCharged: 3, requests: 1, unconfirmed: 0 });
});
it("shows an admission denial without a retry or invented debit", async () => {
const h = await serve([{ error: "insufficient_credits", error_description: "Not enough credits" }], 402);
expect((await h.run().result()).stopReason).toBe("error");
expect(h.ledger.snapshot()).toMatchObject({ creditsCharged: 0, requests: 1, unconfirmed: 1 });
});
it("keeps cancellation unconfirmed rather than zero-cost", async () => {
const h = await serve([]);
const controller = new AbortController();
controller.abort();
expect((await h.run(controller.signal).result()).stopReason).toBe("aborted");
expect(h.ledger.snapshot().unconfirmed).toBe(1);
});
it("preserves SDK tool deltas", async () => {
const h = await serve([
{
choices: [
{
delta: {
tool_calls: [{ index: 0, id: "call1", function: { name: "write_file", arguments: '{"path":' } }],
},
},
],
},
{
choices: [
{
delta: { tool_calls: [{ index: 0, function: { arguments: '"a.txt"}' } }] },
finish_reason: "tool_calls",
},
],
},
final,
]);
const result = await h.run().result();
expect(result.stopReason).toBe("toolUse");
expect(result.content).toEqual([
{ type: "toolCall", id: "call1", name: "write_file", arguments: { path: "a.txt" } },
]);
});
it("rejects incomplete tool JSON without a completed tool event", async () => {
const h = await serve([
{
choices: [
{
delta: {
tool_calls: [{ index: 0, id: "call1", function: { name: "write_file", arguments: '{"path":' } }],
},
finish_reason: "length",
},
],
},
final,
]);
const events = [];
for await (const event of h.run()) events.push(event);
expect(events.some((event) => event.type === "toolcall_end")).toBe(false);
expect(events.at(-1)?.type).toBe("error");
expect(h.ledger.snapshot().requests).toBe(1);
});
it("never sends a Codebase key to a helper endpoint override", async () => {
const h = await serve([]);
const result = await h
.stream({ ...h.model, baseUrl: "https://example.com/v1" }, { messages: [] }, { apiKey: "fake-key" })
.result();
expect(result.errorMessage).toContain("different endpoint");
expect(h.request()).toBeUndefined();
});
it.each([-1, 0.5, Number.NaN, "3", undefined])("rejects invalid receipt amount %s", (value) => {
expect(parseBillingReceipt({ credits_charged: value, usage_estimated: false }, "d4f")).toBeUndefined();
});
});
Loading
Loading