feat(costs): add billing, quota, and budget control plane
This commit is contained in:
28
packages/adapter-utils/src/billing.test.ts
Normal file
28
packages/adapter-utils/src/billing.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
20
packages/adapter-utils/src/billing.ts
Normal file
20
packages/adapter-utils/src/billing.ts
Normal 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;
|
||||
}
|
||||
@@ -30,3 +30,4 @@ export {
|
||||
redactHomePathUserSegmentsInValue,
|
||||
redactTranscriptEntryPaths,
|
||||
} from "./log-redaction.js";
|
||||
export { inferOpenAiCompatibleBiller } from "./billing.js";
|
||||
|
||||
@@ -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;
|
||||
@@ -185,12 +194,16 @@ export interface QuotaWindow {
|
||||
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 */
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
124
packages/adapters/claude-local/src/cli/quota-probe.ts
Normal file
124
packages/adapters/claude-local/src/cli/quota-probe.ts
Normal 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();
|
||||
@@ -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),
|
||||
|
||||
@@ -8,8 +8,12 @@ export {
|
||||
} from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
readClaudeAuthStatus,
|
||||
readClaudeToken,
|
||||
fetchClaudeQuota,
|
||||
fetchClaudeCliQuota,
|
||||
captureClaudeCliUsageText,
|
||||
parseClaudeCliUsageText,
|
||||
toPercent,
|
||||
fetchWithTimeout,
|
||||
claudeConfigDir,
|
||||
|
||||
@@ -1,16 +1,91 @@
|
||||
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");
|
||||
}
|
||||
|
||||
export async function readClaudeToken(): Promise<string | null> {
|
||||
const credPath = path.join(claudeConfigDir(), "credentials.json");
|
||||
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");
|
||||
@@ -31,22 +106,93 @@ export async function readClaudeToken(): Promise<string | null> {
|
||||
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;
|
||||
// utilization is 0-1 fraction; clamp to 100 in case of floating-point overshoot
|
||||
return Math.min(100, Math.round(utilization * 100));
|
||||
}
|
||||
|
||||
@@ -64,7 +210,7 @@ export async function fetchWithTimeout(url: string, init: RequestInit, ms = 8000
|
||||
export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||
const resp = await fetchWithTimeout("https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
Authorization: `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
});
|
||||
@@ -74,44 +220,312 @@ export async function fetchClaudeQuota(token: string): Promise<QuotaWindow[]> {
|
||||
|
||||
if (body.five_hour != null) {
|
||||
windows.push({
|
||||
label: "5h",
|
||||
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: "7d",
|
||||
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: "Sonnet 7d",
|
||||
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: "Opus 7d",
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const token = await readClaudeToken();
|
||||
if (!token) {
|
||||
return { provider: "anthropic", ok: false, error: "no local claude auth token", windows: [] };
|
||||
}
|
||||
const windows = await fetchClaudeQuota(token);
|
||||
return { provider: "anthropic", ok: true, 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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
112
packages/adapters/codex-local/src/cli/quota-probe.ts
Normal file
112
packages/adapters/codex-local/src/cli/quota-probe.ts
Normal 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();
|
||||
@@ -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,
|
||||
|
||||
@@ -3,8 +3,11 @@ export { testEnvironment } from "./test.js";
|
||||
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
export {
|
||||
getQuotaWindows,
|
||||
readCodexAuthInfo,
|
||||
readCodexToken,
|
||||
fetchCodexQuota,
|
||||
fetchCodexRpcQuota,
|
||||
mapCodexRpcQuota,
|
||||
secondsToWindowLabel,
|
||||
fetchWithTimeout,
|
||||
codexHomeDir,
|
||||
|
||||
@@ -1,20 +1,113 @@
|
||||
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 CodexAuthFile {
|
||||
interface CodexLegacyAuthFile {
|
||||
accessToken?: string | null;
|
||||
accountId?: string | null;
|
||||
}
|
||||
|
||||
export async function readCodexToken(): Promise<{ token: string; accountId: string | null } | 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 {
|
||||
@@ -29,18 +122,55 @@ export async function readCodexToken(): Promise<{ token: string; accountId: stri
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const obj = parsed as CodexAuthFile;
|
||||
const token = obj.accessToken;
|
||||
if (typeof token !== "string" || token.length === 0) 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 =
|
||||
typeof obj.accountId === "string" && obj.accountId.length > 0 ? obj.accountId : null;
|
||||
return { token, 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 | null;
|
||||
reset_at?: string | number | null;
|
||||
}
|
||||
|
||||
interface WhamCredits {
|
||||
@@ -49,6 +179,7 @@ interface WhamCredits {
|
||||
}
|
||||
|
||||
interface WhamUsageResponse {
|
||||
plan_type?: string | null;
|
||||
rate_limit?: {
|
||||
primary_window?: WhamWindow | null;
|
||||
secondary_window?: WhamWindow | null;
|
||||
@@ -69,7 +200,6 @@ export function secondsToWindowLabel(
|
||||
if (hours < 6) return "5h";
|
||||
if (hours <= 24) return "24h";
|
||||
if (hours <= 168) return "7d";
|
||||
// for windows larger than 7d, show the actual day count rather than silently mislabelling
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
@@ -88,6 +218,11 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -105,30 +240,28 @@ export async function fetchCodexQuota(
|
||||
const rateLimit = body.rate_limit;
|
||||
if (rateLimit?.primary_window != null) {
|
||||
const w = rateLimit.primary_window;
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case.
|
||||
// use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%.
|
||||
const rawPct = w.used_percent ?? null;
|
||||
const usedPercent =
|
||||
rawPct != null ? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct)) : null;
|
||||
windows.push({
|
||||
label: secondsToWindowLabel(w.limit_window_seconds, "Primary"),
|
||||
usedPercent,
|
||||
resetsAt: w.reset_at ?? null,
|
||||
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;
|
||||
// wham used_percent is 0-100 (confirmed empirically); guard against 0-1 format just in case.
|
||||
// use < 1 (not <= 1) so that 1% usage (rawPct=1) is not misclassified as 100%.
|
||||
const rawPct = w.used_percent ?? null;
|
||||
const usedPercent =
|
||||
rawPct != null ? Math.min(100, Math.round(rawPct < 1 ? rawPct * 100 : rawPct)) : null;
|
||||
windows.push({
|
||||
label: secondsToWindowLabel(w.limit_window_seconds, "Secondary"),
|
||||
usedPercent,
|
||||
resetsAt: w.reset_at ?? null,
|
||||
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) {
|
||||
@@ -139,16 +272,285 @@ export async function fetchCodexQuota(
|
||||
usedPercent: null,
|
||||
resetsAt: null,
|
||||
valueLabel,
|
||||
detail: null,
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
export async function getQuotaWindows(): Promise<ProviderQuotaResult> {
|
||||
const auth = await readCodexToken();
|
||||
if (!auth) {
|
||||
return { provider: "openai", ok: false, error: "no local codex auth token", windows: [] };
|
||||
}
|
||||
const windows = await fetchCodexQuota(auth.token, auth.accountId);
|
||||
return { provider: "openai", ok: true, 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: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"seed": "tsx src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"embedded-postgres": "^18.1.0-beta.16",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"postgres": "^3.4.5"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { createServer } from "node:net";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { ensurePostgresDatabase } from "./client.js";
|
||||
@@ -28,6 +29,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 {
|
||||
@@ -51,6 +64,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> {
|
||||
const require = createRequire(import.meta.url);
|
||||
const resolveCandidates = [
|
||||
@@ -76,6 +114,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 runningPid = readRunningPostmasterPid(postmasterPidFile);
|
||||
const runningPort = readPidFilePort(postmasterPidFile);
|
||||
@@ -95,7 +134,7 @@ async function ensureEmbeddedPostgresConnection(
|
||||
databaseDir: dataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port: preferredPort,
|
||||
port: selectedPort,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C"],
|
||||
onLog: () => {},
|
||||
@@ -103,19 +142,30 @@ 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 });
|
||||
}
|
||||
await instance.start();
|
||||
try {
|
||||
await instance.start();
|
||||
} catch (error) {
|
||||
throw toError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`);
|
||||
}
|
||||
|
||||
const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`;
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
51
packages/db/src/migrations/0031_zippy_magma.sql
Normal file
51
packages/db/src/migrations/0031_zippy_magma.sql
Normal 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");
|
||||
102
packages/db/src/migrations/0032_pretty_doctor_octopus.sql
Normal file
102
packages/db/src/migrations/0032_pretty_doctor_octopus.sql
Normal 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");
|
||||
7242
packages/db/src/migrations/meta/0031_snapshot.json
Normal file
7242
packages/db/src/migrations/meta/0031_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7733
packages/db/src/migrations/meta/0032_snapshot.json
Normal file
7733
packages/db/src/migrations/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,20 @@
|
||||
"when": 1773670925214,
|
||||
"tag": "0030_rich_magneto",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1773511922713,
|
||||
"tag": "0031_zippy_magma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1773542934499,
|
||||
"tag": "0032_pretty_doctor_octopus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>(),
|
||||
|
||||
41
packages/db/src/schema/budget_incidents.ts
Normal file
41
packages/db/src/schema/budget_incidents.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
43
packages/db/src/schema/budget_policies.ts
Normal file
43
packages/db/src/schema/budget_policies.ts
Normal 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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
67
packages/db/src/schema/finance_events.ts
Normal file
67
packages/db/src/schema/finance_events.ts
Normal 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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -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";
|
||||
@@ -31,6 +33,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";
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
@@ -129,13 +151,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,
|
||||
@@ -253,11 +286,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,
|
||||
@@ -273,6 +310,7 @@ export {
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
createCostEventSchema,
|
||||
createFinanceEventSchema,
|
||||
updateBudgetSchema,
|
||||
createAssetImageMetadataSchema,
|
||||
createCompanyInviteSchema,
|
||||
@@ -283,6 +321,7 @@ export {
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCostEvent,
|
||||
type CreateFinanceEvent,
|
||||
type UpdateBudget,
|
||||
type CreateAssetImageMetadata,
|
||||
type CreateCompanyInvite,
|
||||
|
||||
@@ -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;
|
||||
|
||||
99
packages/shared/src/types/budget.ts
Normal file
99
packages/shared/src/types/budget.ts
Normal 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;
|
||||
}
|
||||
@@ -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,45 +34,71 @@ 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;
|
||||
}
|
||||
|
||||
@@ -76,5 +108,6 @@ export interface CostByProject {
|
||||
projectName: string | null;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
@@ -18,4 +18,10 @@ export interface DashboardSummary {
|
||||
monthUtilizationPercent: number;
|
||||
};
|
||||
pendingApprovals: number;
|
||||
budgets: {
|
||||
activeIncidents: number;
|
||||
pendingApprovals: number;
|
||||
pausedAgents: number;
|
||||
pausedProjects: number;
|
||||
};
|
||||
}
|
||||
|
||||
60
packages/shared/src/types/finance.ts
Normal file
60
packages/shared/src/types/finance.ts
Normal 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;
|
||||
}
|
||||
@@ -36,6 +36,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,
|
||||
@@ -46,7 +54,8 @@ export type {
|
||||
CompanySecret,
|
||||
SecretProviderDescriptor,
|
||||
} from "./secrets.js";
|
||||
export type { CostEvent, CostSummary, CostByAgent, CostByProviderModel, CostByAgentModel, CostWindowSpendRow, CostByProject } 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,
|
||||
|
||||
@@ -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 interface ProjectGoalRef {
|
||||
@@ -35,6 +35,8 @@ export interface Project {
|
||||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
executionWorkspacePolicy: ProjectExecutionWorkspacePolicy | null;
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
|
||||
@@ -8,12 +8,16 @@ export interface QuotaWindow {
|
||||
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 */
|
||||
|
||||
37
packages/shared/src/validators/budget.ts
Normal file
37
packages/shared/src/validators/budget.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
|
||||
|
||||
34
packages/shared/src/validators/finance.ts
Normal file
34
packages/shared/src/validators/finance.ts
Normal 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>;
|
||||
@@ -1,3 +1,10 @@
|
||||
export {
|
||||
upsertBudgetPolicySchema,
|
||||
resolveBudgetIncidentSchema,
|
||||
type UpsertBudgetPolicy,
|
||||
type ResolveBudgetIncident,
|
||||
} from "./budget.js";
|
||||
|
||||
export {
|
||||
createCompanySchema,
|
||||
updateCompanySchema,
|
||||
@@ -121,6 +128,11 @@ export {
|
||||
type UpdateBudget,
|
||||
} from "./cost.js";
|
||||
|
||||
export {
|
||||
createFinanceEventSchema,
|
||||
type CreateFinanceEvent,
|
||||
} from "./finance.js";
|
||||
|
||||
export {
|
||||
createAssetImageMetadataSchema,
|
||||
type CreateAssetImageMetadata,
|
||||
|
||||
Reference in New Issue
Block a user