feat(costs): add billing, quota, and budget control plane

This commit is contained in:
Dotta
2026-03-14 22:00:12 -05:00
parent 656b4659fc
commit 76e6cc08a6
91 changed files with 22406 additions and 769 deletions

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,12 @@ export {
} from "./parse.js";
export {
getQuotaWindows,
readClaudeAuthStatus,
readClaudeToken,
fetchClaudeQuota,
fetchClaudeCliQuota,
captureClaudeCliUsageText,
parseClaudeCliUsageText,
toPercent,
fetchWithTimeout,
claudeConfigDir,

View File

@@ -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: [],
};
}

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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: [],
};
}

View File

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

View File

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

View File

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

View File

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