Merge remote-tracking branch 'public-gh/master' into paperclip-subissues

* public-gh/master:
  Fix budget incident resolution edge cases
  Fix agent budget tab routing
  Fix budget auth and monthly spend rollups
  Harden budget enforcement and migration startup
  Add budget tabs and sidebar budget indicators
  feat(costs): add billing, quota, and budget control plane
  refactor(quota): move provider quota logic into adapter layer, add unit tests
  fix(costs): replace non-null map assertions with nullish coalescing, clarify weekData guard
  fix(costs): guard byProject against duplicate null keys, memoize ProviderQuotaCard row aggregations
  fix(costs): align byAgent run filter to startedAt, tighten providerTabItems memo deps, stabilize byProject row keys
  feat(costs): add agent model breakdown, harden date validation, sync CostByProject type, fix quota threshold and tab-gated queries
  fix(costs): harden company auth check, fix frozen date memo, hide empty quota rows
  fix(costs): guard routes, fix DST ranges, sync provider state, wire live updates
  feat(costs): consolidate /usage into /costs with Spend + Providers tabs
  feat(usage): add subscription quota windows per provider on /usage page
  address greptile review: per-provider deficit notch, startedAt filter, weekRange refresh, deduplicate providerDisplayName
  feat(ui): add resource and usage dashboard (/usage route)

# Conflicts:
#	packages/db/src/migration-runtime.ts
#	packages/db/src/migrations/meta/0031_snapshot.json
#	packages/db/src/migrations/meta/_journal.json
This commit is contained in:
Dotta
2026-03-16 17:19:55 -05:00
112 changed files with 46441 additions and 2489 deletions

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { inferOpenAiCompatibleBiller } from "./billing.js";
describe("inferOpenAiCompatibleBiller", () => {
it("returns openrouter when OPENROUTER_API_KEY is present", () => {
expect(
inferOpenAiCompatibleBiller({ OPENROUTER_API_KEY: "sk-or-123" } as NodeJS.ProcessEnv, "openai"),
).toBe("openrouter");
});
it("returns openrouter when OPENAI_BASE_URL points at OpenRouter", () => {
expect(
inferOpenAiCompatibleBiller(
{ OPENAI_BASE_URL: "https://openrouter.ai/api/v1" } as NodeJS.ProcessEnv,
"openai",
),
).toBe("openrouter");
});
it("returns fallback when no OpenRouter markers are present", () => {
expect(
inferOpenAiCompatibleBiller(
{ OPENAI_BASE_URL: "https://api.openai.com/v1" } as NodeJS.ProcessEnv,
"openai",
),
).toBe("openai");
});
});

View File

@@ -0,0 +1,20 @@
function readEnv(env: NodeJS.ProcessEnv, key: string): string | null {
const value = env[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function inferOpenAiCompatibleBiller(
env: NodeJS.ProcessEnv,
fallback: string | null = "openai",
): string | null {
const explicitOpenRouterKey = readEnv(env, "OPENROUTER_API_KEY");
if (explicitOpenRouterKey) return "openrouter";
const baseUrl =
readEnv(env, "OPENAI_BASE_URL") ??
readEnv(env, "OPENAI_API_BASE") ??
readEnv(env, "OPENAI_API_BASE_URL");
if (baseUrl && /openrouter\.ai/i.test(baseUrl)) return "openrouter";
return fallback;
}

View File

@@ -17,6 +17,8 @@ export type {
HireApprovedPayload,
HireApprovedHookResult,
ServerAdapterModule,
QuotaWindow,
ProviderQuotaResult,
TranscriptEntry,
StdoutLineParser,
CLIAdapterModule,
@@ -28,3 +30,4 @@ export {
redactHomePathUserSegmentsInValue,
redactTranscriptEntryPaths,
} from "./log-redaction.js";
export { inferOpenAiCompatibleBiller } from "./billing.js";

View File

@@ -30,7 +30,15 @@ export interface UsageSummary {
cachedInputTokens?: number;
}
export type AdapterBillingType = "api" | "subscription" | "unknown";
export type AdapterBillingType =
| "api"
| "subscription"
| "metered_api"
| "subscription_included"
| "subscription_overage"
| "credits"
| "fixed"
| "unknown";
export interface AdapterRuntimeServiceReport {
id?: string | null;
@@ -68,6 +76,7 @@ export interface AdapterExecutionResult {
sessionParams?: Record<string, unknown> | null;
sessionDisplayId?: string | null;
provider?: string | null;
biller?: string | null;
model?: string | null;
billingType?: AdapterBillingType | null;
costUsd?: number | null;
@@ -171,6 +180,37 @@ export interface HireApprovedHookResult {
detail?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Quota window types — used by adapters that can report provider quota/rate-limit state
// ---------------------------------------------------------------------------
/** a single rate-limit or usage window returned by a provider quota API */
export interface QuotaWindow {
/** human label, e.g. "5h", "7d", "Sonnet 7d", "Credits" */
label: string;
/** percent of the window already consumed (0-100), null when not reported */
usedPercent: number | null;
/** iso timestamp when this window resets, null when not reported */
resetsAt: string | null;
/** free-form value label for credit-style windows, e.g. "$4.20 remaining" */
valueLabel: string | null;
/** optional supporting text, e.g. reset details or provider-specific notes */
detail?: string | null;
}
/** result for one provider from getQuotaWindows() */
export interface ProviderQuotaResult {
/** provider slug, e.g. "anthropic", "openai" */
provider: string;
/** source label when the provider reports where the quota data came from */
source?: string | null;
/** true when the fetch succeeded and windows is populated */
ok: boolean;
/** error message when ok is false */
error?: string;
windows: QuotaWindow[];
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
@@ -188,6 +228,12 @@ export interface ServerAdapterModule {
payload: HireApprovedPayload,
adapterConfig: Record<string, unknown>,
) => Promise<HireApprovedHookResult>;
/**
* Optional: fetch live provider quota/rate-limit windows for this adapter.
* Returns a ProviderQuotaResult so the server can aggregate across adapters
* without knowing provider-specific credential paths or API shapes.
*/
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
}
// ---------------------------------------------------------------------------

View File

@@ -38,7 +38,9 @@
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json",
"probe:quota:raw": "pnpm exec tsx src/cli/quota-probe.ts --json --raw-cli"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
import {
captureClaudeCliUsageText,
fetchClaudeCliQuota,
fetchClaudeQuota,
getQuotaWindows,
parseClaudeCliUsageText,
readClaudeAuthStatus,
readClaudeToken,
} from "../server/quota.js";
interface ProbeArgs {
json: boolean;
includeRawCli: boolean;
oauthOnly: boolean;
cliOnly: boolean;
}
function parseArgs(argv: string[]): ProbeArgs {
return {
json: argv.includes("--json"),
includeRawCli: argv.includes("--raw-cli"),
oauthOnly: argv.includes("--oauth-only"),
cliOnly: argv.includes("--cli-only"),
};
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.oauthOnly && args.cliOnly) {
throw new Error("Choose either --oauth-only or --cli-only, not both.");
}
const authStatus = await readClaudeAuthStatus();
const token = await readClaudeToken();
const result: Record<string, unknown> = {
timestamp: new Date().toISOString(),
authStatus,
tokenAvailable: token != null,
};
if (!args.cliOnly) {
if (!token) {
result.oauth = {
ok: false,
error: "No Claude OAuth access token found in local credentials files.",
windows: [],
};
} else {
try {
result.oauth = {
ok: true,
windows: await fetchClaudeQuota(token),
};
} catch (error) {
result.oauth = {
ok: false,
error: stringifyError(error),
windows: [],
};
}
}
}
if (!args.oauthOnly) {
try {
const rawCliText = args.includeRawCli ? await captureClaudeCliUsageText() : null;
const windows = rawCliText ? parseClaudeCliUsageText(rawCliText) : await fetchClaudeCliQuota();
result.cli = rawCliText
? {
ok: true,
windows,
rawText: rawCliText,
}
: {
ok: true,
windows,
};
} catch (error) {
result.cli = {
ok: false,
error: stringifyError(error),
windows: [],
};
}
}
if (!args.oauthOnly && !args.cliOnly) {
try {
result.aggregated = await getQuotaWindows();
} catch (error) {
result.aggregated = {
ok: false,
error: stringifyError(error),
};
}
}
const oauthOk = (result.oauth as { ok?: boolean } | undefined)?.ok === true;
const cliOk = (result.cli as { ok?: boolean } | undefined)?.ok === true;
const aggregatedOk = (result.aggregated as { ok?: boolean } | undefined)?.ok === true;
const ok = oauthOk || cliOk || aggregatedOk;
if (args.json || process.stdout.isTTY === false) {
console.log(JSON.stringify({ ok, ...result }, null, 2));
} else {
console.log(`timestamp: ${result.timestamp}`);
console.log(`auth: ${JSON.stringify(authStatus)}`);
console.log(`tokenAvailable: ${token != null}`);
if (result.oauth) console.log(`oauth: ${JSON.stringify(result.oauth, null, 2)}`);
if (result.cli) console.log(`cli: ${JSON.stringify(result.cli, null, 2)}`);
if (result.aggregated) console.log(`aggregated: ${JSON.stringify(result.aggregated, null, 2)}`);
}
if (!ok) process.exitCode = 1;
}
await main();

View File

@@ -340,7 +340,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
graceSec,
extraArgs,
} = runtimeConfig;
const billingType = resolveClaudeBillingType(env);
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveClaudeBillingType(effectiveEnv);
const skillsDir = await buildSkillsDir();
// When instructionsFilePath is configured, create a combined temp file that
@@ -547,6 +552,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "anthropic",
biller: "anthropic",
model: parsedStream.model || asString(parsed.model, model),
billingType,
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),

View File

@@ -6,6 +6,18 @@ export {
isClaudeMaxTurnsResult,
isClaudeUnknownSessionError,
} from "./parse.js";
export {
getQuotaWindows,
readClaudeAuthStatus,
readClaudeToken,
fetchClaudeQuota,
fetchClaudeCliQuota,
captureClaudeCliUsageText,
parseClaudeCliUsageText,
toPercent,
fetchWithTimeout,
claudeConfigDir,
} from "./quota.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {

View File

@@ -0,0 +1,531 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
const execFileAsync = promisify(execFile);
const CLAUDE_USAGE_SOURCE_OAUTH = "anthropic-oauth";
const CLAUDE_USAGE_SOURCE_CLI = "claude-cli";
export function claudeConfigDir(): string {
const fromEnv = process.env.CLAUDE_CONFIG_DIR;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".claude");
}
function hasNonEmptyProcessEnv(key: string): boolean {
const value = process.env[key];
return typeof value === "string" && value.trim().length > 0;
}
function createClaudeQuotaEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(process.env)) {
if (typeof value !== "string") continue;
if (key.startsWith("ANTHROPIC_")) continue;
env[key] = value;
}
return env;
}
function stripBackspaces(text: string): string {
let out = "";
for (const char of text) {
if (char === "\b") {
out = out.slice(0, -1);
} else {
out += char;
}
}
return out;
}
function stripAnsi(text: string): string {
return text
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, "")
.replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
}
function cleanTerminalText(text: string): string {
return stripAnsi(stripBackspaces(text))
.replace(/\u0000/g, "")
.replace(/\r/g, "\n");
}
function normalizeForLabelSearch(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function trimToLatestUsagePanel(text: string): string | null {
const lower = text.toLowerCase();
const settingsIndex = lower.lastIndexOf("settings:");
if (settingsIndex < 0) return null;
let tail = text.slice(settingsIndex);
const tailLower = tail.toLowerCase();
if (!tailLower.includes("usage")) return null;
if (!tailLower.includes("current session") && !tailLower.includes("loading usage")) return null;
const stopMarkers = [
"status dialog dismissed",
"checking for updates",
"press ctrl-c again to exit",
];
let stopIndex = -1;
for (const marker of stopMarkers) {
const markerIndex = tailLower.indexOf(marker);
if (markerIndex >= 0 && (stopIndex === -1 || markerIndex < stopIndex)) {
stopIndex = markerIndex;
}
}
if (stopIndex >= 0) {
tail = tail.slice(0, stopIndex);
}
return tail;
}
async function readClaudeTokenFromFile(credPath: string): Promise<string | null> {
let raw: string;
try {
raw = await fs.readFile(credPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
const oauth = obj["claudeAiOauth"];
if (typeof oauth !== "object" || oauth === null) return null;
const token = (oauth as Record<string, unknown>)["accessToken"];
return typeof token === "string" && token.length > 0 ? token : null;
}
interface ClaudeAuthStatus {
loggedIn: boolean;
authMethod: string | null;
subscriptionType: string | null;
}
export async function readClaudeAuthStatus(): Promise<ClaudeAuthStatus | null> {
try {
const { stdout } = await execFileAsync("claude", ["auth", "status"], {
env: process.env,
timeout: 5_000,
maxBuffer: 1024 * 1024,
});
const parsed = JSON.parse(stdout) as Record<string, unknown>;
return {
loggedIn: parsed.loggedIn === true,
authMethod: typeof parsed.authMethod === "string" ? parsed.authMethod : null,
subscriptionType: typeof parsed.subscriptionType === "string" ? parsed.subscriptionType : null,
};
} catch {
return null;
}
}
function describeClaudeSubscriptionAuth(status: ClaudeAuthStatus | null): string | null {
if (!status?.loggedIn || status.authMethod !== "claude.ai") return null;
return status.subscriptionType
? `Claude is logged in via claude.ai (${status.subscriptionType})`
: "Claude is logged in via claude.ai";
}
export async function readClaudeToken(): Promise<string | null> {
const configDir = claudeConfigDir();
for (const filename of [".credentials.json", "credentials.json"]) {
const token = await readClaudeTokenFromFile(path.join(configDir, filename));
if (token) return token;
}
return null;
}
interface AnthropicUsageWindow {
utilization?: number | null;
resets_at?: string | null;
}
interface AnthropicExtraUsage {
is_enabled?: boolean | null;
monthly_limit?: number | null;
used_credits?: number | null;
utilization?: number | null;
currency?: string | null;
}
interface AnthropicUsageResponse {
five_hour?: AnthropicUsageWindow | null;
seven_day?: AnthropicUsageWindow | null;
seven_day_sonnet?: AnthropicUsageWindow | null;
seven_day_opus?: AnthropicUsageWindow | null;
extra_usage?: AnthropicExtraUsage | null;
}
function formatCurrencyAmount(value: number, currency: string | null | undefined): string {
const code = typeof currency === "string" && currency.trim().length > 0 ? currency.trim().toUpperCase() : "USD";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
maximumFractionDigits: 2,
}).format(value);
}
function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null {
const monthlyLimit = extraUsage.monthly_limit;
const usedCredits = extraUsage.used_credits;
if (
typeof monthlyLimit !== "number" ||
!Number.isFinite(monthlyLimit) ||
typeof usedCredits !== "number" ||
!Number.isFinite(usedCredits)
) {
return null;
}
return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`;
}
/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */
export function toPercent(utilization: number | null | undefined): number | null {
if (utilization == null) return null;
return Math.min(100, Math.round(utilization * 100));
}
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
export async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
headers: {
Authorization: `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
},
});
if (!resp.ok) throw new Error(`anthropic usage api returned ${resp.status}`);
const body = (await resp.json()) as AnthropicUsageResponse;
const windows: QuotaWindow[] = [];
if (body.five_hour != null) {
windows.push({
label: "Current session",
usedPercent: toPercent(body.five_hour.utilization),
resetsAt: body.five_hour.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.seven_day != null) {
windows.push({
label: "Current week (all models)",
usedPercent: toPercent(body.seven_day.utilization),
resetsAt: body.seven_day.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.seven_day_sonnet != null) {
windows.push({
label: "Current week (Sonnet only)",
usedPercent: toPercent(body.seven_day_sonnet.utilization),
resetsAt: body.seven_day_sonnet.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.seven_day_opus != null) {
windows.push({
label: "Current week (Opus only)",
usedPercent: toPercent(body.seven_day_opus.utilization),
resetsAt: body.seven_day_opus.resets_at ?? null,
valueLabel: null,
detail: null,
});
}
if (body.extra_usage != null) {
windows.push({
label: "Extra usage",
usedPercent: body.extra_usage.is_enabled === false ? null : toPercent(body.extra_usage.utilization),
resetsAt: null,
valueLabel:
body.extra_usage.is_enabled === false
? "Not enabled"
: formatExtraUsageLabel(body.extra_usage),
detail:
body.extra_usage.is_enabled === false
? "Extra usage not enabled"
: "Monthly extra usage pool",
});
}
return windows;
}
function usageOutputLooksRelevant(text: string): boolean {
const normalized = normalizeForLabelSearch(text);
return normalized.includes("currentsession")
|| normalized.includes("currentweek")
|| normalized.includes("loadingusage")
|| normalized.includes("failedtoloadusagedata")
|| normalized.includes("tokenexpired")
|| normalized.includes("authenticationerror")
|| normalized.includes("ratelimited");
}
function usageOutputLooksComplete(text: string): boolean {
const normalized = normalizeForLabelSearch(text);
if (
normalized.includes("failedtoloadusagedata")
|| normalized.includes("tokenexpired")
|| normalized.includes("authenticationerror")
|| normalized.includes("ratelimited")
) {
return true;
}
return normalized.includes("currentsession")
&& (normalized.includes("currentweek") || normalized.includes("extrausage"))
&& /[0-9]{1,3}(?:\.[0-9]+)?%/i.test(text);
}
function extractUsageError(text: string): string | null {
const lower = text.toLowerCase();
const compact = lower.replace(/\s+/g, "");
if (lower.includes("token_expired") || lower.includes("token has expired")) {
return "Claude CLI token expired. Run `claude login` to refresh.";
}
if (lower.includes("authentication_error")) {
return "Claude CLI authentication error. Run `claude login`.";
}
if (lower.includes("rate_limit_error") || lower.includes("rate limited") || compact.includes("ratelimited")) {
return "Claude CLI usage endpoint is rate limited right now. Please try again later.";
}
if (lower.includes("failed to load usage data") || compact.includes("failedtoloadusagedata")) {
return "Claude CLI could not load usage data. Open the CLI and retry `/usage`.";
}
return null;
}
function percentFromLine(line: string): number | null {
const match = line.match(/([0-9]{1,3}(?:\.[0-9]+)?)\s*%/i);
if (!match) return null;
const rawValue = Number(match[1]);
if (!Number.isFinite(rawValue)) return null;
const clamped = Math.min(100, Math.max(0, rawValue));
const lower = line.toLowerCase();
if (lower.includes("remaining") || lower.includes("left") || lower.includes("available")) {
return Math.max(0, Math.min(100, Math.round(100 - clamped)));
}
return Math.round(clamped);
}
function isQuotaLabel(line: string): boolean {
const normalized = normalizeForLabelSearch(line);
return normalized === "currentsession"
|| normalized === "currentweekallmodels"
|| normalized === "currentweeksonnetonly"
|| normalized === "currentweeksonnet"
|| normalized === "currentweekopusonly"
|| normalized === "currentweekopus"
|| normalized === "extrausage";
}
function canonicalQuotaLabel(line: string): string {
switch (normalizeForLabelSearch(line)) {
case "currentsession":
return "Current session";
case "currentweekallmodels":
return "Current week (all models)";
case "currentweeksonnetonly":
case "currentweeksonnet":
return "Current week (Sonnet only)";
case "currentweekopusonly":
case "currentweekopus":
return "Current week (Opus only)";
case "extrausage":
return "Extra usage";
default:
return line;
}
}
function formatClaudeCliDetail(label: string, lines: string[]): string | null {
const normalizedLabel = normalizeForLabelSearch(label);
if (normalizedLabel === "extrausage") {
const compact = lines.join(" ").replace(/\s+/g, "").toLowerCase();
if (compact.includes("extrausagenotenabled")) {
return "Extra usage not enabled • /extra-usage to enable";
}
const firstLine = lines.find((line) => line.trim().length > 0) ?? null;
return firstLine;
}
const resetLine = lines.find((line) => /^resets/i.test(line) || normalizeForLabelSearch(line).startsWith("resets"));
if (!resetLine) return null;
return resetLine
.replace(/^Resets/i, "Resets ")
.replace(/([A-Z][a-z]{2})(\d)/g, "$1 $2")
.replace(/(\d)at(\d)/g, "$1 at $2")
.replace(/(am|pm)\(/gi, "$1 (")
.replace(/([A-Za-z])\(/g, "$1 (")
.replace(/\s+/g, " ")
.trim();
}
export function parseClaudeCliUsageText(text: string): QuotaWindow[] {
const cleaned = trimToLatestUsagePanel(cleanTerminalText(text)) ?? cleanTerminalText(text);
const usageError = extractUsageError(cleaned);
if (usageError) throw new Error(usageError);
const lines = cleaned
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
const sections: Array<{ label: string; lines: string[] }> = [];
let current: { label: string; lines: string[] } | null = null;
for (const line of lines) {
if (isQuotaLabel(line)) {
if (current) sections.push(current);
current = { label: canonicalQuotaLabel(line), lines: [] };
continue;
}
if (current) current.lines.push(line);
}
if (current) sections.push(current);
const windows = sections.map<QuotaWindow>((section) => {
const usedPercent = section.lines.map(percentFromLine).find((value) => value != null) ?? null;
return {
label: section.label,
usedPercent,
resetsAt: null,
valueLabel: null,
detail: formatClaudeCliDetail(section.label, section.lines),
};
});
if (!windows.some((window) => normalizeForLabelSearch(window.label) === "currentsession")) {
throw new Error("Could not parse Claude CLI usage output.");
}
return windows;
}
function quoteForShell(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function buildClaudeCliShellProbeCommand(): string {
const feed = "(sleep 2; printf '/usage\\r'; sleep 6; printf '\\033'; sleep 1; printf '\\003')";
const claudeCommand = "claude --tools \"\"";
if (process.platform === "darwin") {
return `${feed} | script -q /dev/null ${claudeCommand}`;
}
return `${feed} | script -q -e -f -c ${quoteForShell(claudeCommand)} /dev/null`;
}
export async function captureClaudeCliUsageText(timeoutMs = 12_000): Promise<string> {
const command = buildClaudeCliShellProbeCommand();
try {
const { stdout, stderr } = await execFileAsync("sh", ["-c", command], {
env: createClaudeQuotaEnv(),
timeout: timeoutMs,
maxBuffer: 8 * 1024 * 1024,
});
const output = `${stdout}${stderr}`;
const cleaned = cleanTerminalText(output);
if (usageOutputLooksComplete(cleaned)) return output;
throw new Error("Claude CLI usage probe ended before rendering usage.");
} catch (error) {
const stdout =
typeof error === "object" && error !== null && "stdout" in error && typeof error.stdout === "string"
? error.stdout
: "";
const stderr =
typeof error === "object" && error !== null && "stderr" in error && typeof error.stderr === "string"
? error.stderr
: "";
const output = `${stdout}${stderr}`;
const cleaned = cleanTerminalText(output);
if (usageOutputLooksComplete(cleaned)) return output;
if (usageOutputLooksRelevant(cleaned)) {
throw new Error("Claude CLI usage probe ended before rendering usage.");
}
throw error instanceof Error ? error : new Error(String(error));
}
}
export async function fetchClaudeCliQuota(): Promise<QuotaWindow[]> {
const rawText = await captureClaudeCliUsageText();
return parseClaudeCliUsageText(rawText);
}
function formatProviderError(source: string, error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return `${source}: ${message}`;
}
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
const authStatus = await readClaudeAuthStatus();
const authDescription = describeClaudeSubscriptionAuth(authStatus);
const token = await readClaudeToken();
const errors: string[] = [];
if (token) {
try {
const windows = await fetchClaudeQuota(token);
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_OAUTH, ok: true, windows };
} catch (error) {
errors.push(formatProviderError("Anthropic OAuth usage", error));
}
}
try {
const windows = await fetchClaudeCliQuota();
return { provider: "anthropic", source: CLAUDE_USAGE_SOURCE_CLI, ok: true, windows };
} catch (error) {
errors.push(formatProviderError("Claude CLI /usage", error));
}
if (hasNonEmptyProcessEnv("ANTHROPIC_API_KEY") && !authDescription) {
return {
provider: "anthropic",
ok: false,
error:
errors[0]
?? "ANTHROPIC_API_KEY is set and no local Claude subscription session is available for quota polling",
windows: [],
};
}
if (authDescription) {
return {
provider: "anthropic",
ok: false,
error:
errors.length > 0
? `${authDescription}, but quota polling failed (${errors.join("; ")})`
: `${authDescription}, but Paperclip could not load subscription quota data`,
windows: [],
};
}
return {
provider: "anthropic",
ok: false,
error: errors[0] ?? "no local claude auth token",
windows: [],
};
}

View File

@@ -38,7 +38,8 @@
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
import {
fetchCodexQuota,
fetchCodexRpcQuota,
getQuotaWindows,
readCodexAuthInfo,
readCodexToken,
} from "../server/quota.js";
interface ProbeArgs {
json: boolean;
rpcOnly: boolean;
whamOnly: boolean;
}
function parseArgs(argv: string[]): ProbeArgs {
return {
json: argv.includes("--json"),
rpcOnly: argv.includes("--rpc-only"),
whamOnly: argv.includes("--wham-only"),
};
}
function stringifyError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.rpcOnly && args.whamOnly) {
throw new Error("Choose either --rpc-only or --wham-only, not both.");
}
const auth = await readCodexAuthInfo();
const token = await readCodexToken();
const result: Record<string, unknown> = {
timestamp: new Date().toISOString(),
auth,
tokenAvailable: token != null,
};
if (!args.whamOnly) {
try {
result.rpc = {
ok: true,
...(await fetchCodexRpcQuota()),
};
} catch (error) {
result.rpc = {
ok: false,
error: stringifyError(error),
windows: [],
};
}
}
if (!args.rpcOnly) {
if (!token) {
result.wham = {
ok: false,
error: "No local Codex auth token found in ~/.codex/auth.json.",
windows: [],
};
} else {
try {
result.wham = {
ok: true,
windows: await fetchCodexQuota(token.token, token.accountId),
};
} catch (error) {
result.wham = {
ok: false,
error: stringifyError(error),
windows: [],
};
}
}
}
if (!args.rpcOnly && !args.whamOnly) {
try {
result.aggregated = await getQuotaWindows();
} catch (error) {
result.aggregated = {
ok: false,
error: stringifyError(error),
};
}
}
const rpcOk = (result.rpc as { ok?: boolean } | undefined)?.ok === true;
const whamOk = (result.wham as { ok?: boolean } | undefined)?.ok === true;
const aggregatedOk = (result.aggregated as { ok?: boolean } | undefined)?.ok === true;
const ok = rpcOk || whamOk || aggregatedOk;
if (args.json || process.stdout.isTTY === false) {
console.log(JSON.stringify({ ok, ...result }, null, 2));
} else {
console.log(`timestamp: ${result.timestamp}`);
console.log(`auth: ${JSON.stringify(auth)}`);
console.log(`tokenAvailable: ${token != null}`);
if (result.rpc) console.log(`rpc: ${JSON.stringify(result.rpc, null, 2)}`);
if (result.wham) console.log(`wham: ${JSON.stringify(result.wham, null, 2)}`);
if (result.aggregated) console.log(`aggregated: ${JSON.stringify(result.aggregated, null, 2)}`);
}
if (!ok) process.exitCode = 1;
}
await main();

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
@@ -61,6 +61,12 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function resolveCodexBiller(env: Record<string, string>, billingType: "api" | "subscription"): string {
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, "openai");
if (openAiCompatibleBiller === "openrouter") return "openrouter";
return billingType === "subscription" ? "chatgpt" : openAiCompatibleBiller ?? "openai";
}
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
@@ -315,8 +321,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveCodexBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveCodexBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const timeoutSec = asNumber(config.timeoutSec, 0);
@@ -508,6 +519,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "openai",
biller: resolveCodexBiller(effectiveEnv, billingType),
model,
billingType,
costUsd: null,

View File

@@ -1,6 +1,17 @@
export { execute, ensureCodexSkillsInjected } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
export {
getQuotaWindows,
readCodexAuthInfo,
readCodexToken,
fetchCodexQuota,
fetchCodexRpcQuota,
mapCodexRpcQuota,
secondsToWindowLabel,
fetchWithTimeout,
codexHomeDir,
} from "./quota.js";
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {

View File

@@ -0,0 +1,556 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ProviderQuotaResult, QuotaWindow } from "@paperclipai/adapter-utils";
const CODEX_USAGE_SOURCE_RPC = "codex-rpc";
const CODEX_USAGE_SOURCE_WHAM = "codex-wham";
export function codexHomeDir(): string {
const fromEnv = process.env.CODEX_HOME;
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
return path.join(os.homedir(), ".codex");
}
interface CodexLegacyAuthFile {
accessToken?: string | null;
accountId?: string | null;
}
interface CodexTokenBlock {
id_token?: string | null;
access_token?: string | null;
refresh_token?: string | null;
account_id?: string | null;
}
interface CodexModernAuthFile {
OPENAI_API_KEY?: string | null;
tokens?: CodexTokenBlock | null;
last_refresh?: string | null;
}
export interface CodexAuthInfo {
accessToken: string;
accountId: string | null;
refreshToken: string | null;
idToken: string | null;
email: string | null;
planType: string | null;
lastRefresh: string | null;
}
function base64UrlDecode(input: string): string | null {
try {
let normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const remainder = normalized.length % 4;
if (remainder > 0) normalized += "=".repeat(4 - remainder);
return Buffer.from(normalized, "base64").toString("utf8");
} catch {
return null;
}
}
function decodeJwtPayload(token: string | null | undefined): Record<string, unknown> | null {
if (typeof token !== "string" || token.trim().length === 0) return null;
const parts = token.split(".");
if (parts.length < 2) return null;
const decoded = base64UrlDecode(parts[1] ?? "");
if (!decoded) return null;
try {
const parsed = JSON.parse(decoded) as unknown;
return typeof parsed === "object" && parsed !== null ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
function readNestedString(record: Record<string, unknown>, pathSegments: string[]): string | null {
let current: unknown = record;
for (const segment of pathSegments) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[segment];
}
return typeof current === "string" && current.trim().length > 0 ? current.trim() : null;
}
function parsePlanAndEmailFromToken(idToken: string | null, accessToken: string | null): {
email: string | null;
planType: string | null;
} {
const payloads = [decodeJwtPayload(idToken), decodeJwtPayload(accessToken)].filter(
(value): value is Record<string, unknown> => value != null,
);
for (const payload of payloads) {
const directEmail = typeof payload.email === "string" ? payload.email : null;
const authBlock =
typeof payload["https://api.openai.com/auth"] === "object" &&
payload["https://api.openai.com/auth"] !== null &&
!Array.isArray(payload["https://api.openai.com/auth"])
? payload["https://api.openai.com/auth"] as Record<string, unknown>
: null;
const profileBlock =
typeof payload["https://api.openai.com/profile"] === "object" &&
payload["https://api.openai.com/profile"] !== null &&
!Array.isArray(payload["https://api.openai.com/profile"])
? payload["https://api.openai.com/profile"] as Record<string, unknown>
: null;
const email =
directEmail
?? (typeof profileBlock?.email === "string" ? profileBlock.email : null)
?? (typeof authBlock?.chatgpt_user_email === "string" ? authBlock.chatgpt_user_email : null);
const planType =
typeof authBlock?.chatgpt_plan_type === "string" ? authBlock.chatgpt_plan_type : null;
if (email || planType) return { email: email ?? null, planType };
}
return { email: null, planType: null };
}
export async function readCodexAuthInfo(): Promise<CodexAuthInfo | null> {
const authPath = path.join(codexHomeDir(), "auth.json");
let raw: string;
try {
raw = await fs.readFile(authPath, "utf8");
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== "object" || parsed === null) return null;
const obj = parsed as Record<string, unknown>;
const modern = obj as CodexModernAuthFile;
const legacy = obj as CodexLegacyAuthFile;
const accessToken =
legacy.accessToken
?? modern.tokens?.access_token
?? readNestedString(obj, ["tokens", "access_token"]);
if (typeof accessToken !== "string" || accessToken.length === 0) return null;
const accountId =
legacy.accountId
?? modern.tokens?.account_id
?? readNestedString(obj, ["tokens", "account_id"]);
const refreshToken =
modern.tokens?.refresh_token
?? readNestedString(obj, ["tokens", "refresh_token"]);
const idToken =
modern.tokens?.id_token
?? readNestedString(obj, ["tokens", "id_token"]);
const { email, planType } = parsePlanAndEmailFromToken(idToken, accessToken);
return {
accessToken,
accountId:
typeof accountId === "string" && accountId.trim().length > 0 ? accountId.trim() : null,
refreshToken:
typeof refreshToken === "string" && refreshToken.trim().length > 0 ? refreshToken.trim() : null,
idToken:
typeof idToken === "string" && idToken.trim().length > 0 ? idToken.trim() : null,
email,
planType,
lastRefresh:
typeof modern.last_refresh === "string" && modern.last_refresh.trim().length > 0
? modern.last_refresh.trim()
: null,
};
}
export async function readCodexToken(): Promise<{ token: string; accountId: string | null } | null> {
const auth = await readCodexAuthInfo();
if (!auth) return null;
return { token: auth.accessToken, accountId: auth.accountId };
}
interface WhamWindow {
used_percent?: number | null;
limit_window_seconds?: number | null;
reset_at?: string | number | null;
}
interface WhamCredits {
balance?: number | null;
unlimited?: boolean | null;
}
interface WhamUsageResponse {
plan_type?: string | null;
rate_limit?: {
primary_window?: WhamWindow | null;
secondary_window?: WhamWindow | null;
} | null;
credits?: WhamCredits | null;
}
/**
* Map a window duration in seconds to a human-readable label.
* Falls back to the provided fallback string when seconds is null/undefined.
*/
export function secondsToWindowLabel(
seconds: number | null | undefined,
fallback: string,
): string {
if (seconds == null) return fallback;
const hours = seconds / 3600;
if (hours < 6) return "5h";
if (hours <= 24) return "24h";
if (hours <= 168) return "7d";
return `${Math.round(hours / 24)}d`;
}
/** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */
export async function fetchWithTimeout(
url: string,
init: RequestInit,
ms = 8000,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function normalizeCodexUsedPercent(rawPct: number | null | undefined): number | null {
if (rawPct == null) return null;
return Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct));
}
export async function fetchCodexQuota(
token: string,
accountId: string | null,
): Promise<QuotaWindow[]> {
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
};
if (accountId) headers["ChatGPT-Account-Id"] = accountId;
const resp = await fetchWithTimeout("https://chatgpt.com/backend-api/wham/usage", { headers });
if (!resp.ok) throw new Error(`chatgpt wham api returned ${resp.status}`);
const body = (await resp.json()) as WhamUsageResponse;
const windows: QuotaWindow[] = [];
const rateLimit = body.rate_limit;
if (rateLimit?.primary_window != null) {
const w = rateLimit.primary_window;
windows.push({
label: "5h limit",
usedPercent: normalizeCodexUsedPercent(w.used_percent),
resetsAt:
typeof w.reset_at === "number"
? unixSecondsToIso(w.reset_at)
: (w.reset_at ?? null),
valueLabel: null,
detail: null,
});
}
if (rateLimit?.secondary_window != null) {
const w = rateLimit.secondary_window;
windows.push({
label: "Weekly limit",
usedPercent: normalizeCodexUsedPercent(w.used_percent),
resetsAt:
typeof w.reset_at === "number"
? unixSecondsToIso(w.reset_at)
: (w.reset_at ?? null),
valueLabel: null,
detail: null,
});
}
if (body.credits != null && body.credits.unlimited !== true) {
const balance = body.credits.balance;
const valueLabel = balance != null ? `$${(balance / 100).toFixed(2)} remaining` : "N/A";
windows.push({
label: "Credits",
usedPercent: null,
resetsAt: null,
valueLabel,
detail: null,
});
}
return windows;
}
interface CodexRpcWindow {
usedPercent?: number | null;
windowDurationMins?: number | null;
resetsAt?: number | null;
}
interface CodexRpcCredits {
hasCredits?: boolean | null;
unlimited?: boolean | null;
balance?: string | number | null;
}
interface CodexRpcLimit {
limitId?: string | null;
limitName?: string | null;
primary?: CodexRpcWindow | null;
secondary?: CodexRpcWindow | null;
credits?: CodexRpcCredits | null;
planType?: string | null;
}
interface CodexRpcRateLimitsResult {
rateLimits?: CodexRpcLimit | null;
rateLimitsByLimitId?: Record<string, CodexRpcLimit> | null;
}
interface CodexRpcAccountResult {
account?: {
type?: string | null;
email?: string | null;
planType?: string | null;
} | null;
requiresOpenaiAuth?: boolean | null;
}
export interface CodexRpcQuotaSnapshot {
windows: QuotaWindow[];
email: string | null;
planType: string | null;
}
function unixSecondsToIso(value: number | null | undefined): string | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
return new Date(value * 1000).toISOString();
}
function buildCodexRpcWindow(label: string, window: CodexRpcWindow | null | undefined): QuotaWindow | null {
if (!window) return null;
return {
label,
usedPercent: normalizeCodexUsedPercent(window.usedPercent),
resetsAt: unixSecondsToIso(window.resetsAt),
valueLabel: null,
detail: null,
};
}
function parseCreditBalance(value: string | number | null | undefined): string | null {
if (typeof value === "number" && Number.isFinite(value)) {
return `$${value.toFixed(2)} remaining`;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return `$${parsed.toFixed(2)} remaining`;
}
return value.trim();
}
return null;
}
export function mapCodexRpcQuota(result: CodexRpcRateLimitsResult, account?: CodexRpcAccountResult | null): CodexRpcQuotaSnapshot {
const windows: QuotaWindow[] = [];
const limitOrder = ["codex"];
const limitsById = result.rateLimitsByLimitId ?? {};
for (const key of Object.keys(limitsById)) {
if (!limitOrder.includes(key)) limitOrder.push(key);
}
const rootLimit = result.rateLimits ?? null;
const allLimits = new Map<string, CodexRpcLimit>();
if (rootLimit?.limitId) allLimits.set(rootLimit.limitId, rootLimit);
for (const [key, value] of Object.entries(limitsById)) {
allLimits.set(key, value);
}
if (!allLimits.has("codex") && rootLimit) allLimits.set("codex", rootLimit);
for (const limitId of limitOrder) {
const limit = allLimits.get(limitId);
if (!limit) continue;
const prefix =
limitId === "codex"
? ""
: `${limit.limitName ?? limitId} · `;
const primary = buildCodexRpcWindow(`${prefix}5h limit`, limit.primary);
if (primary) windows.push(primary);
const secondary = buildCodexRpcWindow(`${prefix}Weekly limit`, limit.secondary);
if (secondary) windows.push(secondary);
if (limitId === "codex" && limit.credits && limit.credits.unlimited !== true) {
windows.push({
label: "Credits",
usedPercent: null,
resetsAt: null,
valueLabel: parseCreditBalance(limit.credits.balance) ?? "N/A",
detail: null,
});
}
}
return {
windows,
email:
typeof account?.account?.email === "string" && account.account.email.trim().length > 0
? account.account.email.trim()
: null,
planType:
typeof account?.account?.planType === "string" && account.account.planType.trim().length > 0
? account.account.planType.trim()
: (typeof rootLimit?.planType === "string" && rootLimit.planType.trim().length > 0 ? rootLimit.planType.trim() : null),
};
}
type PendingRequest = {
resolve: (value: Record<string, unknown>) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
};
class CodexRpcClient {
private proc = spawn(
"codex",
["-s", "read-only", "-a", "untrusted", "app-server"],
{ stdio: ["pipe", "pipe", "pipe"], env: process.env },
);
private nextId = 1;
private buffer = "";
private pending = new Map<number, PendingRequest>();
private stderr = "";
constructor() {
this.proc.stdout.setEncoding("utf8");
this.proc.stderr.setEncoding("utf8");
this.proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
this.proc.stderr.on("data", (chunk: string) => {
this.stderr += chunk;
});
this.proc.on("exit", () => {
for (const request of this.pending.values()) {
clearTimeout(request.timer);
request.reject(new Error(this.stderr.trim() || "codex app-server closed unexpectedly"));
}
this.pending.clear();
});
}
private onStdout(chunk: string) {
this.buffer += chunk;
while (true) {
const newlineIndex = this.buffer.indexOf("\n");
if (newlineIndex < 0) break;
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
if (!line) continue;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
const id = typeof parsed.id === "number" ? parsed.id : null;
if (id == null) continue;
const pending = this.pending.get(id);
if (!pending) continue;
this.pending.delete(id);
clearTimeout(pending.timer);
pending.resolve(parsed);
}
}
private request(method: string, params: Record<string, unknown> = {}, timeoutMs = 6_000): Promise<Record<string, unknown>> {
const id = this.nextId++;
const payload = JSON.stringify({ id, method, params }) + "\n";
return new Promise<Record<string, unknown>>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`codex app-server timed out on ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.proc.stdin.write(payload);
});
}
private notify(method: string, params: Record<string, unknown> = {}) {
this.proc.stdin.write(JSON.stringify({ method, params }) + "\n");
}
async initialize() {
await this.request("initialize", {
clientInfo: {
name: "paperclip",
version: "0.0.0",
},
});
this.notify("initialized", {});
}
async fetchRateLimits(): Promise<CodexRpcRateLimitsResult> {
const message = await this.request("account/rateLimits/read");
return (message.result as CodexRpcRateLimitsResult | undefined) ?? {};
}
async fetchAccount(): Promise<CodexRpcAccountResult | null> {
try {
const message = await this.request("account/read");
return (message.result as CodexRpcAccountResult | undefined) ?? null;
} catch {
return null;
}
}
async shutdown() {
this.proc.kill("SIGTERM");
}
}
export async function fetchCodexRpcQuota(): Promise<CodexRpcQuotaSnapshot> {
const client = new CodexRpcClient();
try {
await client.initialize();
const [limits, account] = await Promise.all([
client.fetchRateLimits(),
client.fetchAccount(),
]);
return mapCodexRpcQuota(limits, account);
} finally {
await client.shutdown();
}
}
function formatProviderError(source: string, error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return `${source}: ${message}`;
}
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
const errors: string[] = [];
try {
const rpc = await fetchCodexRpcQuota();
if (rpc.windows.length > 0) {
return { provider: "openai", source: CODEX_USAGE_SOURCE_RPC, ok: true, windows: rpc.windows };
}
} catch (error) {
errors.push(formatProviderError("Codex app-server", error));
}
const auth = await readCodexToken();
if (auth) {
try {
const windows = await fetchCodexQuota(auth.token, auth.accountId);
return { provider: "openai", source: CODEX_USAGE_SOURCE_WHAM, ok: true, windows };
} catch (error) {
errors.push(formatProviderError("ChatGPT WHAM usage", error));
}
} else {
errors.push("no local codex auth token");
}
return {
provider: "openai",
ok: false,
error: errors.join("; "),
windows: [],
};
}

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
@@ -47,6 +47,17 @@ function resolveCursorBillingType(env: Record<string, string>): "api" | "subscri
: "subscription";
}
function resolveCursorBiller(
env: Record<string, string>,
billingType: "api" | "subscription",
provider: string | null,
): string {
const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, null);
if (openAiCompatibleBiller === "openrouter") return "openrouter";
if (billingType === "subscription") return "cursor";
return provider ?? "cursor";
}
function resolveProviderFromModel(model: string): string | null {
const trimmed = model.trim().toLowerCase();
if (!trimmed) return null;
@@ -243,8 +254,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveCursorBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveCursorBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const timeoutSec = asNumber(config.timeoutSec, 0);
@@ -474,6 +490,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: providerFromModel,
biller: resolveCursorBiller(effectiveEnv, billingType, providerFromModel),
model,
billingType,
costUsd: attempt.parsed.costUsd,

View File

@@ -206,8 +206,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveGeminiBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveGeminiBillingType(effectiveEnv);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureCommandResolvable(command, cwd, runtimeEnv);
const timeoutSec = asNumber(config.timeoutSec, 0);
@@ -420,6 +425,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "google",
biller: "google",
model,
billingType,
costUsd: attempt.parsed.costUsd,

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
@@ -42,6 +42,10 @@ function parseModelProvider(model: string | null): string | null {
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
}
function resolveOpenCodeBiller(env: Record<string, string>, provider: string | null): string {
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
}
function claudeSkillsHome(): string {
return path.join(os.homedir(), ".claude", "skills");
}
@@ -361,6 +365,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: parseModelProvider(modelId),
biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.costUsd,

View File

@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
@@ -50,6 +50,10 @@ function parseModelId(model: string | null): string | null {
return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null;
}
function resolvePiBiller(env: Record<string, string>, provider: string | null): string {
return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown";
}
async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
const skillsEntries = await listPaperclipSkillEntries(__moduleDir);
if (skillsEntries.length === 0) return;
@@ -447,6 +451,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: provider,
biller: resolvePiBiller(runtimeEnv, provider),
model: model,
billingType: "unknown",
costUsd: attempt.parsed.usage.costUsd,

View File

@@ -1,4 +1,5 @@
import { existsSync, readFileSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import path from "node:path";
import { ensurePostgresDatabase } from "./client.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
@@ -26,6 +27,18 @@ export type MigrationConnection = {
stop: () => Promise<void>;
};
function toError(error: unknown, fallbackMessage: string): Error {
if (error instanceof Error) return error;
if (error === undefined) return new Error(fallbackMessage);
if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`);
try {
return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`);
} catch {
return new Error(`${fallbackMessage}: ${String(error)}`);
}
}
function readRunningPostmasterPid(postmasterPidFile: string): number | null {
if (!existsSync(postmasterPidFile)) return null;
try {
@@ -49,6 +62,31 @@ function readPidFilePort(postmasterPidFile: string): number | null {
}
}
async function isPortInUse(port: number): Promise<boolean> {
return await new Promise((resolve) => {
const server = createServer();
server.unref();
server.once("error", (error: NodeJS.ErrnoException) => {
resolve(error.code === "EADDRINUSE");
});
server.listen(port, "127.0.0.1", () => {
server.close();
resolve(false);
});
});
}
async function findAvailablePort(startPort: number): Promise<number> {
const maxLookahead = 20;
let port = startPort;
for (let i = 0; i < maxLookahead; i += 1, port += 1) {
if (!(await isPortInUse(port))) return port;
}
throw new Error(
`Embedded PostgreSQL could not find a free port from ${startPort} to ${startPort + maxLookahead - 1}`,
);
}
async function loadEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
try {
const mod = await import("embedded-postgres");
@@ -65,6 +103,7 @@ async function ensureEmbeddedPostgresConnection(
preferredPort: number,
): Promise<MigrationConnection> {
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
const selectedPort = await findAvailablePort(preferredPort);
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const pgVersionFile = path.resolve(dataDir, "PG_VERSION");
const runningPid = readRunningPostmasterPid(postmasterPidFile);
@@ -102,7 +141,7 @@ async function ensureEmbeddedPostgresConnection(
databaseDir: dataDir,
user: "paperclip",
password: "paperclip",
port: preferredPort,
port: selectedPort,
persistent: true,
initdbFlags: ["--encoding=UTF8", "--locale=C"],
onLog: () => {},
@@ -110,7 +149,14 @@ async function ensureEmbeddedPostgresConnection(
});
if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) {
await instance.initialise();
try {
await instance.initialise();
} catch (error) {
throw toError(
error,
`Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`,
);
}
}
if (existsSync(postmasterPidFile)) {
rmSync(postmasterPidFile, { force: true });
@@ -118,16 +164,15 @@ async function ensureEmbeddedPostgresConnection(
try {
await instance.start();
} catch (error) {
throw new Error(
`Failed to start embedded PostgreSQL at ${dataDir} on port ${preferredPort}: ${error instanceof Error ? error.message : String(error)}`,
);
throw toError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`);
}
await ensurePostgresDatabase(preferredAdminConnectionString, "paperclip");
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/postgres`;
await ensurePostgresDatabase(adminConnectionString, "paperclip");
return {
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/paperclip`,
source: `embedded-postgres@${preferredPort}`,
connectionString: `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/paperclip`,
source: `embedded-postgres@${selectedPort}`,
stop: async () => {
await instance.stop();
},

View File

@@ -3,6 +3,18 @@ import { resolveMigrationConnection } from "./migration-runtime.js";
const jsonMode = process.argv.includes("--json");
function toError(error: unknown, context = "Migration status check failed"): Error {
if (error instanceof Error) return error;
if (error === undefined) return new Error(context);
if (typeof error === "string") return new Error(`${context}: ${error}`);
try {
return new Error(`${context}: ${JSON.stringify(error)}`);
} catch {
return new Error(`${context}: ${String(error)}`);
}
}
async function main(): Promise<void> {
const connection = await resolveMigrationConnection();
@@ -42,4 +54,8 @@ async function main(): Promise<void> {
}
}
await main();
main().catch((error) => {
const err = toError(error, "Migration status check failed");
process.stderr.write(`${err.stack ?? err.message}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,51 @@
CREATE TABLE "finance_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"agent_id" uuid,
"issue_id" uuid,
"project_id" uuid,
"goal_id" uuid,
"heartbeat_run_id" uuid,
"cost_event_id" uuid,
"billing_code" text,
"description" text,
"event_kind" text NOT NULL,
"direction" text DEFAULT 'debit' NOT NULL,
"biller" text NOT NULL,
"provider" text,
"execution_adapter_type" text,
"pricing_tier" text,
"region" text,
"model" text,
"quantity" integer,
"unit" text,
"amount_cents" integer NOT NULL,
"currency" text DEFAULT 'USD' NOT NULL,
"estimated" boolean DEFAULT false NOT NULL,
"external_invoice_id" text,
"metadata_json" jsonb,
"occurred_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN "heartbeat_run_id" uuid;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN "biller" text DEFAULT 'unknown' NOT NULL;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN "billing_type" text DEFAULT 'unknown' NOT NULL;--> statement-breakpoint
ALTER TABLE "cost_events" ADD COLUMN "cached_input_tokens" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_goal_id_goals_id_fk" FOREIGN KEY ("goal_id") REFERENCES "public"."goals"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "finance_events" ADD CONSTRAINT "finance_events_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "finance_events_company_occurred_idx" ON "finance_events" USING btree ("company_id","occurred_at");--> statement-breakpoint
CREATE INDEX "finance_events_company_biller_occurred_idx" ON "finance_events" USING btree ("company_id","biller","occurred_at");--> statement-breakpoint
CREATE INDEX "finance_events_company_kind_occurred_idx" ON "finance_events" USING btree ("company_id","event_kind","occurred_at");--> statement-breakpoint
CREATE INDEX "finance_events_company_direction_occurred_idx" ON "finance_events" USING btree ("company_id","direction","occurred_at");--> statement-breakpoint
CREATE INDEX "finance_events_company_heartbeat_run_idx" ON "finance_events" USING btree ("company_id","heartbeat_run_id");--> statement-breakpoint
CREATE INDEX "finance_events_company_cost_event_idx" ON "finance_events" USING btree ("company_id","cost_event_id");--> statement-breakpoint
ALTER TABLE "cost_events" ADD CONSTRAINT "cost_events_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "cost_events_company_provider_occurred_idx" ON "cost_events" USING btree ("company_id","provider","occurred_at");--> statement-breakpoint
CREATE INDEX "cost_events_company_biller_occurred_idx" ON "cost_events" USING btree ("company_id","biller","occurred_at");--> statement-breakpoint
CREATE INDEX "cost_events_company_heartbeat_run_idx" ON "cost_events" USING btree ("company_id","heartbeat_run_id");

View File

@@ -0,0 +1,102 @@
CREATE TABLE "budget_incidents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"policy_id" uuid NOT NULL,
"scope_type" text NOT NULL,
"scope_id" uuid NOT NULL,
"metric" text NOT NULL,
"window_kind" text NOT NULL,
"window_start" timestamp with time zone NOT NULL,
"window_end" timestamp with time zone NOT NULL,
"threshold_type" text NOT NULL,
"amount_limit" integer NOT NULL,
"amount_observed" integer NOT NULL,
"status" text DEFAULT 'open' NOT NULL,
"approval_id" uuid,
"resolved_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "budget_policies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"scope_type" text NOT NULL,
"scope_id" uuid NOT NULL,
"metric" text DEFAULT 'billed_cents' NOT NULL,
"window_kind" text NOT NULL,
"amount" integer DEFAULT 0 NOT NULL,
"warn_percent" integer DEFAULT 80 NOT NULL,
"hard_stop_enabled" boolean DEFAULT true NOT NULL,
"notify_enabled" boolean DEFAULT true NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_by_user_id" text,
"updated_by_user_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "pause_reason" text;--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "paused_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "pause_reason" text;--> statement-breakpoint
ALTER TABLE "projects" ADD COLUMN "paused_at" timestamp with time zone;--> statement-breakpoint
INSERT INTO "budget_policies" (
"company_id",
"scope_type",
"scope_id",
"metric",
"window_kind",
"amount",
"warn_percent",
"hard_stop_enabled",
"notify_enabled",
"is_active"
)
SELECT
"id",
'company',
"id",
'billed_cents',
'calendar_month_utc',
"budget_monthly_cents",
80,
true,
true,
true
FROM "companies"
WHERE "budget_monthly_cents" > 0;--> statement-breakpoint
INSERT INTO "budget_policies" (
"company_id",
"scope_type",
"scope_id",
"metric",
"window_kind",
"amount",
"warn_percent",
"hard_stop_enabled",
"notify_enabled",
"is_active"
)
SELECT
"company_id",
'agent',
"id",
'billed_cents',
'calendar_month_utc',
"budget_monthly_cents",
80,
true,
true,
true
FROM "agents"
WHERE "budget_monthly_cents" > 0;--> statement-breakpoint
ALTER TABLE "budget_incidents" ADD CONSTRAINT "budget_incidents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "budget_incidents" ADD CONSTRAINT "budget_incidents_policy_id_budget_policies_id_fk" FOREIGN KEY ("policy_id") REFERENCES "public"."budget_policies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "budget_incidents" ADD CONSTRAINT "budget_incidents_approval_id_approvals_id_fk" FOREIGN KEY ("approval_id") REFERENCES "public"."approvals"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "budget_policies" ADD CONSTRAINT "budget_policies_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "budget_incidents_company_status_idx" ON "budget_incidents" USING btree ("company_id","status");--> statement-breakpoint
CREATE INDEX "budget_incidents_company_scope_idx" ON "budget_incidents" USING btree ("company_id","scope_type","scope_id","status");--> statement-breakpoint
CREATE UNIQUE INDEX "budget_incidents_policy_window_threshold_idx" ON "budget_incidents" USING btree ("policy_id","window_start","threshold_type");--> statement-breakpoint
CREATE INDEX "budget_policies_company_scope_active_idx" ON "budget_policies" USING btree ("company_id","scope_type","scope_id","is_active");--> statement-breakpoint
CREATE INDEX "budget_policies_company_window_idx" ON "budget_policies" USING btree ("company_id","window_kind","metric");--> statement-breakpoint
CREATE UNIQUE INDEX "budget_policies_company_scope_metric_unique_idx" ON "budget_policies" USING btree ("company_id","scope_type","scope_id","metric","window_kind");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "companies" ADD COLUMN "pause_reason" text;--> statement-breakpoint
ALTER TABLE "companies" ADD COLUMN "paused_at" timestamp with time zone;

View File

@@ -0,0 +1,2 @@
DROP INDEX "budget_incidents_policy_window_threshold_idx";--> statement-breakpoint
CREATE UNIQUE INDEX "budget_incidents_policy_window_threshold_idx" ON "budget_incidents" USING btree ("policy_id","window_start","threshold_type") WHERE "budget_incidents"."status" <> 'dismissed';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -222,8 +222,36 @@
{
"idx": 31,
"version": "7",
"when": 1773694724077,
"tag": "0031_yielding_toad",
"when": 1773511922713,
"tag": "0031_zippy_magma",
"breakpoints": true
},
{
"idx": 32,
"version": "7",
"when": 1773542934499,
"tag": "0032_pretty_doctor_octopus",
"breakpoints": true
},
{
"idx": 33,
"version": "7",
"when": 1773664961967,
"tag": "0033_shiny_black_tarantula",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1773697572188,
"tag": "0034_fat_dormammu",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1773698696169,
"tag": "0035_marvelous_satana",
"breakpoints": true
}
]

View File

@@ -27,6 +27,8 @@ export const agents = pgTable(
runtimeConfig: jsonb("runtime_config").$type<Record<string, unknown>>().notNull().default({}),
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),
spentMonthlyCents: integer("spent_monthly_cents").notNull().default(0),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
permissions: jsonb("permissions").$type<Record<string, unknown>>().notNull().default({}),
lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),

View File

@@ -0,0 +1,42 @@
import { sql } from "drizzle-orm";
import { index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
import { approvals } from "./approvals.js";
import { budgetPolicies } from "./budget_policies.js";
import { companies } from "./companies.js";
export const budgetIncidents = pgTable(
"budget_incidents",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
policyId: uuid("policy_id").notNull().references(() => budgetPolicies.id),
scopeType: text("scope_type").notNull(),
scopeId: uuid("scope_id").notNull(),
metric: text("metric").notNull(),
windowKind: text("window_kind").notNull(),
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
windowEnd: timestamp("window_end", { withTimezone: true }).notNull(),
thresholdType: text("threshold_type").notNull(),
amountLimit: integer("amount_limit").notNull(),
amountObserved: integer("amount_observed").notNull(),
status: text("status").notNull().default("open"),
approvalId: uuid("approval_id").references(() => approvals.id),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyStatusIdx: index("budget_incidents_company_status_idx").on(table.companyId, table.status),
companyScopeIdx: index("budget_incidents_company_scope_idx").on(
table.companyId,
table.scopeType,
table.scopeId,
table.status,
),
policyWindowIdx: uniqueIndex("budget_incidents_policy_window_threshold_idx").on(
table.policyId,
table.windowStart,
table.thresholdType,
).where(sql`${table.status} <> 'dismissed'`),
}),
);

View File

@@ -0,0 +1,43 @@
import { boolean, index, integer, pgTable, text, timestamp, uuid, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const budgetPolicies = pgTable(
"budget_policies",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
scopeType: text("scope_type").notNull(),
scopeId: uuid("scope_id").notNull(),
metric: text("metric").notNull().default("billed_cents"),
windowKind: text("window_kind").notNull(),
amount: integer("amount").notNull().default(0),
warnPercent: integer("warn_percent").notNull().default(80),
hardStopEnabled: boolean("hard_stop_enabled").notNull().default(true),
notifyEnabled: boolean("notify_enabled").notNull().default(true),
isActive: boolean("is_active").notNull().default(true),
createdByUserId: text("created_by_user_id"),
updatedByUserId: text("updated_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyScopeActiveIdx: index("budget_policies_company_scope_active_idx").on(
table.companyId,
table.scopeType,
table.scopeId,
table.isActive,
),
companyWindowIdx: index("budget_policies_company_window_idx").on(
table.companyId,
table.windowKind,
table.metric,
),
companyScopeMetricUniqueIdx: uniqueIndex("budget_policies_company_scope_metric_unique_idx").on(
table.companyId,
table.scopeType,
table.scopeId,
table.metric,
table.windowKind,
),
}),
);

View File

@@ -7,6 +7,8 @@ export const companies = pgTable(
name: text("name").notNull(),
description: text("description"),
status: text("status").notNull().default("active"),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
issuePrefix: text("issue_prefix").notNull().default("PAP"),
issueCounter: integer("issue_counter").notNull().default(0),
budgetMonthlyCents: integer("budget_monthly_cents").notNull().default(0),

View File

@@ -4,6 +4,7 @@ import { agents } from "./agents.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";
import { goals } from "./goals.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
export const costEvents = pgTable(
"cost_events",
@@ -14,10 +15,14 @@ export const costEvents = pgTable(
issueId: uuid("issue_id").references(() => issues.id),
projectId: uuid("project_id").references(() => projects.id),
goalId: uuid("goal_id").references(() => goals.id),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
billingCode: text("billing_code"),
provider: text("provider").notNull(),
biller: text("biller").notNull().default("unknown"),
billingType: text("billing_type").notNull().default("unknown"),
model: text("model").notNull(),
inputTokens: integer("input_tokens").notNull().default(0),
cachedInputTokens: integer("cached_input_tokens").notNull().default(0),
outputTokens: integer("output_tokens").notNull().default(0),
costCents: integer("cost_cents").notNull(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
@@ -30,5 +35,19 @@ export const costEvents = pgTable(
table.agentId,
table.occurredAt,
),
companyProviderOccurredIdx: index("cost_events_company_provider_occurred_idx").on(
table.companyId,
table.provider,
table.occurredAt,
),
companyBillerOccurredIdx: index("cost_events_company_biller_occurred_idx").on(
table.companyId,
table.biller,
table.occurredAt,
),
companyHeartbeatRunIdx: index("cost_events_company_heartbeat_run_idx").on(
table.companyId,
table.heartbeatRunId,
),
}),
);

View File

@@ -0,0 +1,67 @@
import { pgTable, uuid, text, timestamp, integer, index, boolean, jsonb } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";
import { goals } from "./goals.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { costEvents } from "./cost_events.js";
export const financeEvents = pgTable(
"finance_events",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
agentId: uuid("agent_id").references(() => agents.id),
issueId: uuid("issue_id").references(() => issues.id),
projectId: uuid("project_id").references(() => projects.id),
goalId: uuid("goal_id").references(() => goals.id),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id),
costEventId: uuid("cost_event_id").references(() => costEvents.id),
billingCode: text("billing_code"),
description: text("description"),
eventKind: text("event_kind").notNull(),
direction: text("direction").notNull().default("debit"),
biller: text("biller").notNull(),
provider: text("provider"),
executionAdapterType: text("execution_adapter_type"),
pricingTier: text("pricing_tier"),
region: text("region"),
model: text("model"),
quantity: integer("quantity"),
unit: text("unit"),
amountCents: integer("amount_cents").notNull(),
currency: text("currency").notNull().default("USD"),
estimated: boolean("estimated").notNull().default(false),
externalInvoiceId: text("external_invoice_id"),
metadataJson: jsonb("metadata_json").$type<Record<string, unknown> | null>(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyOccurredIdx: index("finance_events_company_occurred_idx").on(table.companyId, table.occurredAt),
companyBillerOccurredIdx: index("finance_events_company_biller_occurred_idx").on(
table.companyId,
table.biller,
table.occurredAt,
),
companyKindOccurredIdx: index("finance_events_company_kind_occurred_idx").on(
table.companyId,
table.eventKind,
table.occurredAt,
),
companyDirectionOccurredIdx: index("finance_events_company_direction_occurred_idx").on(
table.companyId,
table.direction,
table.occurredAt,
),
companyHeartbeatRunIdx: index("finance_events_company_heartbeat_run_idx").on(
table.companyId,
table.heartbeatRunId,
),
companyCostEventIdx: index("finance_events_company_cost_event_idx").on(
table.companyId,
table.costEventId,
),
}),
);

View File

@@ -7,6 +7,8 @@ export { companyMemberships } from "./company_memberships.js";
export { principalPermissionGrants } from "./principal_permission_grants.js";
export { invites } from "./invites.js";
export { joinRequests } from "./join_requests.js";
export { budgetPolicies } from "./budget_policies.js";
export { budgetIncidents } from "./budget_incidents.js";
export { agentConfigRevisions } from "./agent_config_revisions.js";
export { agentApiKeys } from "./agent_api_keys.js";
export { agentRuntimeState } from "./agent_runtime_state.js";
@@ -33,6 +35,7 @@ export { issueDocuments } from "./issue_documents.js";
export { heartbeatRuns } from "./heartbeat_runs.js";
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
export { costEvents } from "./cost_events.js";
export { financeEvents } from "./finance_events.js";
export { approvals } from "./approvals.js";
export { approvalComments } from "./approval_comments.js";
export { activityLog } from "./activity_log.js";

View File

@@ -15,6 +15,8 @@ export const projects = pgTable(
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
targetDate: date("target_date"),
color: text("color"),
pauseReason: text("pause_reason"),
pausedAt: timestamp("paused_at", { withTimezone: true }),
executionWorkspacePolicy: jsonb("execution_workspace_policy").$type<Record<string, unknown>>(),
archivedAt: timestamp("archived_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),

View File

@@ -137,6 +137,9 @@ export const PROJECT_STATUSES = [
] as const;
export type ProjectStatus = (typeof PROJECT_STATUSES)[number];
export const PAUSE_REASONS = ["manual", "budget", "system"] as const;
export type PauseReason = (typeof PAUSE_REASONS)[number];
export const PROJECT_COLORS = [
"#6366f1", // indigo
"#8b5cf6", // violet
@@ -150,7 +153,7 @@ export const PROJECT_COLORS = [
"#3b82f6", // blue
] as const;
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy"] as const;
export const APPROVAL_TYPES = ["hire_agent", "approve_ceo_strategy", "budget_override_required"] as const;
export type ApprovalType = (typeof APPROVAL_TYPES)[number];
export const APPROVAL_STATUSES = [
@@ -173,6 +176,73 @@ export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
export const BILLING_TYPES = [
"metered_api",
"subscription_included",
"subscription_overage",
"credits",
"fixed",
"unknown",
] as const;
export type BillingType = (typeof BILLING_TYPES)[number];
export const FINANCE_EVENT_KINDS = [
"inference_charge",
"platform_fee",
"credit_purchase",
"credit_refund",
"credit_expiry",
"byok_fee",
"gateway_overhead",
"log_storage_charge",
"logpush_charge",
"provisioned_capacity_charge",
"training_charge",
"custom_model_import_charge",
"custom_model_storage_charge",
"manual_adjustment",
] as const;
export type FinanceEventKind = (typeof FINANCE_EVENT_KINDS)[number];
export const FINANCE_DIRECTIONS = ["debit", "credit"] as const;
export type FinanceDirection = (typeof FINANCE_DIRECTIONS)[number];
export const FINANCE_UNITS = [
"input_token",
"output_token",
"cached_input_token",
"request",
"credit_usd",
"credit_unit",
"model_unit_minute",
"model_unit_hour",
"gb_month",
"train_token",
"unknown",
] as const;
export type FinanceUnit = (typeof FINANCE_UNITS)[number];
export const BUDGET_SCOPE_TYPES = ["company", "agent", "project"] as const;
export type BudgetScopeType = (typeof BUDGET_SCOPE_TYPES)[number];
export const BUDGET_METRICS = ["billed_cents"] as const;
export type BudgetMetric = (typeof BUDGET_METRICS)[number];
export const BUDGET_WINDOW_KINDS = ["calendar_month_utc", "lifetime"] as const;
export type BudgetWindowKind = (typeof BUDGET_WINDOW_KINDS)[number];
export const BUDGET_THRESHOLD_TYPES = ["soft", "hard"] as const;
export type BudgetThresholdType = (typeof BUDGET_THRESHOLD_TYPES)[number];
export const BUDGET_INCIDENT_STATUSES = ["open", "resolved", "dismissed"] as const;
export type BudgetIncidentStatus = (typeof BUDGET_INCIDENT_STATUSES)[number];
export const BUDGET_INCIDENT_RESOLUTION_ACTIONS = [
"keep_paused",
"raise_budget_and_resume",
] as const;
export type BudgetIncidentResolutionAction = (typeof BUDGET_INCIDENT_RESOLUTION_ACTIONS)[number];
export const HEARTBEAT_INVOCATION_SOURCES = [
"timer",
"assignment",

View File

@@ -13,11 +13,22 @@ export {
GOAL_LEVELS,
GOAL_STATUSES,
PROJECT_STATUSES,
PAUSE_REASONS,
PROJECT_COLORS,
APPROVAL_TYPES,
APPROVAL_STATUSES,
SECRET_PROVIDERS,
STORAGE_PROVIDERS,
BILLING_TYPES,
FINANCE_EVENT_KINDS,
FINANCE_DIRECTIONS,
FINANCE_UNITS,
BUDGET_SCOPE_TYPES,
BUDGET_METRICS,
BUDGET_WINDOW_KINDS,
BUDGET_THRESHOLD_TYPES,
BUDGET_INCIDENT_STATUSES,
BUDGET_INCIDENT_RESOLUTION_ACTIONS,
HEARTBEAT_INVOCATION_SOURCES,
HEARTBEAT_RUN_STATUSES,
WAKEUP_TRIGGER_DETAILS,
@@ -61,10 +72,21 @@ export {
type GoalLevel,
type GoalStatus,
type ProjectStatus,
type PauseReason,
type ApprovalType,
type ApprovalStatus,
type SecretProvider,
type StorageProvider,
type BillingType,
type FinanceEventKind,
type FinanceDirection,
type FinanceUnit,
type BudgetScopeType,
type BudgetMetric,
type BudgetWindowKind,
type BudgetThresholdType,
type BudgetIncidentStatus,
type BudgetIncidentResolutionAction,
type HeartbeatInvocationSource,
type HeartbeatRunStatus,
type WakeupTriggerDetail,
@@ -140,9 +162,24 @@ export type {
Goal,
Approval,
ApprovalComment,
BudgetPolicy,
BudgetPolicySummary,
BudgetIncident,
BudgetOverview,
BudgetPolicyUpsertInput,
BudgetIncidentResolutionInput,
CostEvent,
CostSummary,
CostByAgent,
CostByProviderModel,
CostByBiller,
CostByAgentModel,
CostWindowSpendRow,
CostByProject,
FinanceEvent,
FinanceSummary,
FinanceByBiller,
FinanceByKind,
HeartbeatRun,
HeartbeatRunEvent,
AgentRuntimeState,
@@ -198,6 +235,8 @@ export type {
PluginJobRecord,
PluginJobRunRecord,
PluginWebhookDeliveryRecord,
QuotaWindow,
ProviderQuotaResult,
} from "./types/index.js";
export {
@@ -268,11 +307,15 @@ export {
type CreateGoal,
type UpdateGoal,
createApprovalSchema,
upsertBudgetPolicySchema,
resolveBudgetIncidentSchema,
resolveApprovalSchema,
requestApprovalRevisionSchema,
resubmitApprovalSchema,
addApprovalCommentSchema,
type CreateApproval,
type UpsertBudgetPolicy,
type ResolveBudgetIncident,
type ResolveApproval,
type RequestApprovalRevision,
type ResubmitApproval,
@@ -288,6 +331,7 @@ export {
type RotateSecret,
type UpdateSecret,
createCostEventSchema,
createFinanceEventSchema,
updateBudgetSchema,
createAssetImageMetadataSchema,
createCompanyInviteSchema,
@@ -298,6 +342,7 @@ export {
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCostEvent,
type CreateFinanceEvent,
type UpdateBudget,
type CreateAssetImageMetadata,
type CreateCompanyInvite,

View File

@@ -1,5 +1,6 @@
import type {
AgentAdapterType,
PauseReason,
AgentRole,
AgentStatus,
} from "../constants.js";
@@ -24,6 +25,8 @@ export interface Agent {
runtimeConfig: Record<string, unknown>;
budgetMonthlyCents: number;
spentMonthlyCents: number;
pauseReason: PauseReason | null;
pausedAt: Date | null;
permissions: AgentPermissions;
lastHeartbeatAt: Date | null;
metadata: Record<string, unknown> | null;

View File

@@ -0,0 +1,99 @@
import type {
BudgetIncidentResolutionAction,
BudgetIncidentStatus,
BudgetMetric,
BudgetScopeType,
BudgetThresholdType,
BudgetWindowKind,
PauseReason,
} from "../constants.js";
export interface BudgetPolicy {
id: string;
companyId: string;
scopeType: BudgetScopeType;
scopeId: string;
metric: BudgetMetric;
windowKind: BudgetWindowKind;
amount: number;
warnPercent: number;
hardStopEnabled: boolean;
notifyEnabled: boolean;
isActive: boolean;
createdByUserId: string | null;
updatedByUserId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface BudgetPolicySummary {
policyId: string;
companyId: string;
scopeType: BudgetScopeType;
scopeId: string;
scopeName: string;
metric: BudgetMetric;
windowKind: BudgetWindowKind;
amount: number;
observedAmount: number;
remainingAmount: number;
utilizationPercent: number;
warnPercent: number;
hardStopEnabled: boolean;
notifyEnabled: boolean;
isActive: boolean;
status: "ok" | "warning" | "hard_stop";
paused: boolean;
pauseReason: PauseReason | null;
windowStart: Date;
windowEnd: Date;
}
export interface BudgetIncident {
id: string;
companyId: string;
policyId: string;
scopeType: BudgetScopeType;
scopeId: string;
scopeName: string;
metric: BudgetMetric;
windowKind: BudgetWindowKind;
windowStart: Date;
windowEnd: Date;
thresholdType: BudgetThresholdType;
amountLimit: number;
amountObserved: number;
status: BudgetIncidentStatus;
approvalId: string | null;
approvalStatus: string | null;
resolvedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface BudgetOverview {
companyId: string;
policies: BudgetPolicySummary[];
activeIncidents: BudgetIncident[];
pausedAgentCount: number;
pausedProjectCount: number;
pendingApprovalCount: number;
}
export interface BudgetPolicyUpsertInput {
scopeType: BudgetScopeType;
scopeId: string;
metric?: BudgetMetric;
windowKind?: BudgetWindowKind;
amount: number;
warnPercent?: number;
hardStopEnabled?: boolean;
notifyEnabled?: boolean;
isActive?: boolean;
}
export interface BudgetIncidentResolutionInput {
action: BudgetIncidentResolutionAction;
amount?: number;
decisionNote?: string | null;
}

View File

@@ -1,10 +1,12 @@
import type { CompanyStatus } from "../constants.js";
import type { CompanyStatus, PauseReason } from "../constants.js";
export interface Company {
id: string;
name: string;
description: string | null;
status: CompanyStatus;
pauseReason: PauseReason | null;
pausedAt: Date | null;
issuePrefix: string;
issueCounter: number;
budgetMonthlyCents: number;

View File

@@ -1,3 +1,5 @@
import type { BillingType } from "../constants.js";
export interface CostEvent {
id: string;
companyId: string;
@@ -5,10 +7,14 @@ export interface CostEvent {
issueId: string | null;
projectId: string | null;
goalId: string | null;
heartbeatRunId: string | null;
billingCode: string | null;
provider: string;
biller: string;
billingType: BillingType;
model: string;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
costCents: number;
occurredAt: Date;
@@ -28,9 +34,80 @@ export interface CostByAgent {
agentStatus: string | null;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
apiRunCount: number;
subscriptionRunCount: number;
subscriptionCachedInputTokens: number;
subscriptionInputTokens: number;
subscriptionOutputTokens: number;
}
export interface CostByProviderModel {
provider: string;
biller: string;
billingType: BillingType;
model: string;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
apiRunCount: number;
subscriptionRunCount: number;
subscriptionCachedInputTokens: number;
subscriptionInputTokens: number;
subscriptionOutputTokens: number;
}
export interface CostByBiller {
biller: string;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
apiRunCount: number;
subscriptionRunCount: number;
subscriptionCachedInputTokens: number;
subscriptionInputTokens: number;
subscriptionOutputTokens: number;
providerCount: number;
modelCount: number;
}
/** per-agent breakdown by provider + model, for identifying token-hungry agents */
export interface CostByAgentModel {
agentId: string;
agentName: string | null;
provider: string;
biller: string;
billingType: BillingType;
model: string;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}
/** spend per provider for a fixed rolling time window */
export interface CostWindowSpendRow {
provider: string;
biller: string;
/** duration label, e.g. "5h", "24h", "7d" */
window: string;
/** rolling window duration in hours */
windowHours: number;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}
/** cost attributed to a project via heartbeat run → activity log → issue → project chain */
export interface CostByProject {
projectId: string | null;
projectName: string | null;
costCents: number;
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
}

View File

@@ -18,4 +18,10 @@ export interface DashboardSummary {
monthUtilizationPercent: number;
};
pendingApprovals: number;
budgets: {
activeIncidents: number;
pendingApprovals: number;
pausedAgents: number;
pausedProjects: number;
};
}

View File

@@ -0,0 +1,60 @@
import type { AgentAdapterType, FinanceDirection, FinanceEventKind, FinanceUnit } from "../constants.js";
export interface FinanceEvent {
id: string;
companyId: string;
agentId: string | null;
issueId: string | null;
projectId: string | null;
goalId: string | null;
heartbeatRunId: string | null;
costEventId: string | null;
billingCode: string | null;
description: string | null;
eventKind: FinanceEventKind;
direction: FinanceDirection;
biller: string;
provider: string | null;
executionAdapterType: AgentAdapterType | null;
pricingTier: string | null;
region: string | null;
model: string | null;
quantity: number | null;
unit: FinanceUnit | null;
amountCents: number;
currency: string;
estimated: boolean;
externalInvoiceId: string | null;
metadataJson: Record<string, unknown> | null;
occurredAt: Date;
createdAt: Date;
}
export interface FinanceSummary {
companyId: string;
debitCents: number;
creditCents: number;
netCents: number;
estimatedDebitCents: number;
eventCount: number;
}
export interface FinanceByBiller {
biller: string;
debitCents: number;
creditCents: number;
netCents: number;
estimatedDebitCents: number;
eventCount: number;
kindCount: number;
}
export interface FinanceByKind {
eventKind: FinanceEventKind;
debitCents: number;
creditCents: number;
netCents: number;
estimatedDebitCents: number;
eventCount: number;
billerCount: number;
}

View File

@@ -47,6 +47,14 @@ export type {
} from "./issue.js";
export type { Goal } from "./goal.js";
export type { Approval, ApprovalComment } from "./approval.js";
export type {
BudgetPolicy,
BudgetPolicySummary,
BudgetIncident,
BudgetOverview,
BudgetPolicyUpsertInput,
BudgetIncidentResolutionInput,
} from "./budget.js";
export type {
SecretProvider,
SecretVersionSelector,
@@ -57,7 +65,8 @@ export type {
CompanySecret,
SecretProviderDescriptor,
} from "./secrets.js";
export type { CostEvent, CostSummary, CostByAgent } from "./cost.js";
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByBiller, CostByAgentModel, CostWindowSpendRow, CostByProject } from "./cost.js";
export type { FinanceEvent, FinanceSummary, FinanceByBiller, FinanceByKind } from "./finance.js";
export type {
HeartbeatRun,
HeartbeatRunEvent,
@@ -77,6 +86,7 @@ export type {
JoinRequest,
InstanceUserRoleGrant,
} from "./access.js";
export type { QuotaWindow, ProviderQuotaResult } from "./quota.js";
export type {
CompanyPortabilityInclude,
CompanyPortabilitySecretRequirement,

View File

@@ -1,4 +1,4 @@
import type { ProjectStatus } from "../constants.js";
import type { PauseReason, ProjectStatus } from "../constants.js";
import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js";
export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path";
@@ -60,6 +60,8 @@ export interface Project {
leadAgentId: string | null;
targetDate: string | null;
color: string | null;
pauseReason: PauseReason | null;
pausedAt: Date | null;
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
codebase: ProjectCodebase;
workspaces: ProjectWorkspace[];

View File

@@ -0,0 +1,26 @@
/** a single rate-limit or usage window returned by a provider quota API */
export interface QuotaWindow {
/** human label, e.g. "5h", "7d", "Sonnet 7d", "Credits" */
label: string;
/** percent of the window already consumed (0-100), null when not reported */
usedPercent: number | null;
/** iso timestamp when this window resets, null when not reported */
resetsAt: string | null;
/** free-form value label for credit-style windows, e.g. "$4.20 remaining" */
valueLabel: string | null;
/** optional supporting text, e.g. reset details or provider-specific notes */
detail?: string | null;
}
/** result for one provider from the quota-windows endpoint */
export interface ProviderQuotaResult {
/** provider slug, e.g. "anthropic", "openai" */
provider: string;
/** source label when the provider reports where the quota data came from */
source?: string | null;
/** true when the fetch succeeded and windows is populated */
ok: boolean;
/** error message when ok is false */
error?: string;
windows: QuotaWindow[];
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import {
BUDGET_INCIDENT_RESOLUTION_ACTIONS,
BUDGET_METRICS,
BUDGET_SCOPE_TYPES,
BUDGET_WINDOW_KINDS,
} from "../constants.js";
export const upsertBudgetPolicySchema = z.object({
scopeType: z.enum(BUDGET_SCOPE_TYPES),
scopeId: z.string().uuid(),
metric: z.enum(BUDGET_METRICS).optional().default("billed_cents"),
windowKind: z.enum(BUDGET_WINDOW_KINDS).optional().default("calendar_month_utc"),
amount: z.number().int().nonnegative(),
warnPercent: z.number().int().min(1).max(99).optional().default(80),
hardStopEnabled: z.boolean().optional().default(true),
notifyEnabled: z.boolean().optional().default(true),
isActive: z.boolean().optional().default(true),
});
export type UpsertBudgetPolicy = z.infer<typeof upsertBudgetPolicySchema>;
export const resolveBudgetIncidentSchema = z.object({
action: z.enum(BUDGET_INCIDENT_RESOLUTION_ACTIONS),
amount: z.number().int().nonnegative().optional(),
decisionNote: z.string().optional().nullable(),
}).superRefine((value, ctx) => {
if (value.action === "raise_budget_and_resume" && typeof value.amount !== "number") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "amount is required when raising a budget",
path: ["amount"],
});
}
});
export type ResolveBudgetIncident = z.infer<typeof resolveBudgetIncidentSchema>;

View File

@@ -1,18 +1,26 @@
import { z } from "zod";
import { BILLING_TYPES } from "../constants.js";
export const createCostEventSchema = z.object({
agentId: z.string().uuid(),
issueId: z.string().uuid().optional().nullable(),
projectId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
heartbeatRunId: z.string().uuid().optional().nullable(),
billingCode: z.string().optional().nullable(),
provider: z.string().min(1),
biller: z.string().min(1).optional(),
billingType: z.enum(BILLING_TYPES).optional().default("unknown"),
model: z.string().min(1),
inputTokens: z.number().int().nonnegative().optional().default(0),
cachedInputTokens: z.number().int().nonnegative().optional().default(0),
outputTokens: z.number().int().nonnegative().optional().default(0),
costCents: z.number().int().nonnegative(),
occurredAt: z.string().datetime(),
});
}).transform((value) => ({
...value,
biller: value.biller ?? value.provider,
}));
export type CreateCostEvent = z.infer<typeof createCostEventSchema>;

View File

@@ -0,0 +1,34 @@
import { z } from "zod";
import { AGENT_ADAPTER_TYPES, FINANCE_DIRECTIONS, FINANCE_EVENT_KINDS, FINANCE_UNITS } from "../constants.js";
export const createFinanceEventSchema = z.object({
agentId: z.string().uuid().optional().nullable(),
issueId: z.string().uuid().optional().nullable(),
projectId: z.string().uuid().optional().nullable(),
goalId: z.string().uuid().optional().nullable(),
heartbeatRunId: z.string().uuid().optional().nullable(),
costEventId: z.string().uuid().optional().nullable(),
billingCode: z.string().optional().nullable(),
description: z.string().max(500).optional().nullable(),
eventKind: z.enum(FINANCE_EVENT_KINDS),
direction: z.enum(FINANCE_DIRECTIONS).optional().default("debit"),
biller: z.string().min(1),
provider: z.string().min(1).optional().nullable(),
executionAdapterType: z.enum(AGENT_ADAPTER_TYPES).optional().nullable(),
pricingTier: z.string().min(1).optional().nullable(),
region: z.string().min(1).optional().nullable(),
model: z.string().min(1).optional().nullable(),
quantity: z.number().int().nonnegative().optional().nullable(),
unit: z.enum(FINANCE_UNITS).optional().nullable(),
amountCents: z.number().int().nonnegative(),
currency: z.string().length(3).optional().default("USD"),
estimated: z.boolean().optional().default(false),
externalInvoiceId: z.string().optional().nullable(),
metadataJson: z.record(z.string(), z.unknown()).optional().nullable(),
occurredAt: z.string().datetime(),
}).transform((value) => ({
...value,
currency: value.currency.toUpperCase(),
}));
export type CreateFinanceEvent = z.infer<typeof createFinanceEventSchema>;

View File

@@ -1,3 +1,10 @@
export {
upsertBudgetPolicySchema,
resolveBudgetIncidentSchema,
type UpsertBudgetPolicy,
type ResolveBudgetIncident,
} from "./budget.js";
export {
createCompanySchema,
updateCompanySchema,
@@ -137,6 +144,11 @@ export {
type UpdateBudget,
} from "./cost.js";
export {
createFinanceEventSchema,
type CreateFinanceEvent,
} from "./finance.js";
export {
createAssetImageMetadataSchema,
type CreateAssetImageMetadata,