@agent-finops/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyze.d.ts +6 -0
- package/dist/analyze.js +260 -0
- package/dist/attribution.d.ts +4 -0
- package/dist/attribution.js +117 -0
- package/dist/credentialDetection.d.ts +45 -0
- package/dist/credentialDetection.js +133 -0
- package/dist/cutList.d.ts +29 -0
- package/dist/cutList.js +201 -0
- package/dist/discovery.d.ts +19 -0
- package/dist/discovery.js +176 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/insights.d.ts +3 -0
- package/dist/insights.js +183 -0
- package/dist/localAgentLogs.d.ts +53 -0
- package/dist/localAgentLogs.js +243 -0
- package/dist/modelPricing.d.ts +35 -0
- package/dist/modelPricing.js +45 -0
- package/dist/planMath.d.ts +42 -0
- package/dist/planMath.js +58 -0
- package/dist/providerConnectors.d.ts +88 -0
- package/dist/providerConnectors.js +683 -0
- package/dist/sampleData.d.ts +5 -0
- package/dist/sampleData.js +59 -0
- package/dist/schema.d.ts +431 -0
- package/dist/schema.js +158 -0
- package/dist/sourceRegistry.d.ts +109 -0
- package/dist/sourceRegistry.js +380 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +27 -0
- package/samples/anthropic-usage.csv +4 -0
- package/samples/openai-usage.csv +7 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { estimateTokenCostUsd } from "./modelPricing.js";
|
|
5
|
+
/** Parse one Claude Code transcript (JSONL). Exported for tests. */
|
|
6
|
+
export function parseClaudeCodeTranscript(content, filePath = "") {
|
|
7
|
+
const calls = [];
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
for (const line of content.split("\n")) {
|
|
10
|
+
if (!line.trim())
|
|
11
|
+
continue;
|
|
12
|
+
let entry;
|
|
13
|
+
try {
|
|
14
|
+
entry = JSON.parse(line);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (!isRecord(entry) || entry.type !== "assistant")
|
|
20
|
+
continue;
|
|
21
|
+
const message = isRecord(entry.message) ? entry.message : undefined;
|
|
22
|
+
const usage = message && isRecord(message.usage) ? message.usage : undefined;
|
|
23
|
+
if (!message || !usage)
|
|
24
|
+
continue;
|
|
25
|
+
// "<synthetic>" marks Claude Code internal placeholder messages, not API calls.
|
|
26
|
+
if (stringOf(message.model) === "<synthetic>")
|
|
27
|
+
continue;
|
|
28
|
+
// Streaming/retries can write the same API response on multiple lines.
|
|
29
|
+
const dedupeKey = `${stringOf(message.id) ?? ""}:${stringOf(entry.requestId) ?? ""}`;
|
|
30
|
+
if (dedupeKey !== ":" && seen.has(dedupeKey))
|
|
31
|
+
continue;
|
|
32
|
+
seen.add(dedupeKey);
|
|
33
|
+
const cacheCreation = isRecord(usage.cache_creation) ? usage.cache_creation : undefined;
|
|
34
|
+
const write5m = numberOf(cacheCreation?.ephemeral_5m_input_tokens);
|
|
35
|
+
const write1h = numberOf(cacheCreation?.ephemeral_1h_input_tokens);
|
|
36
|
+
const writeTotal = numberOf(usage.cache_creation_input_tokens) ?? 0;
|
|
37
|
+
calls.push({
|
|
38
|
+
agent: "claude-code",
|
|
39
|
+
model: stringOf(message.model) ?? "claude-code",
|
|
40
|
+
timestamp: toIso(stringOf(entry.timestamp)) ?? new Date(0).toISOString(),
|
|
41
|
+
project: projectFromCwd(stringOf(entry.cwd)) ?? projectFromTranscriptPath(filePath),
|
|
42
|
+
sessionId: stringOf(entry.sessionId),
|
|
43
|
+
usage: {
|
|
44
|
+
inputTokens: numberOf(usage.input_tokens) ?? 0,
|
|
45
|
+
outputTokens: numberOf(usage.output_tokens) ?? 0,
|
|
46
|
+
cacheReadTokens: numberOf(usage.cache_read_input_tokens) ?? 0,
|
|
47
|
+
// Prefer the 5m/1h breakdown; fall back to the total as 5m (cheaper bound).
|
|
48
|
+
cacheWrite5mTokens: write5m ?? writeTotal,
|
|
49
|
+
cacheWrite1hTokens: write1h ?? 0
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return calls;
|
|
54
|
+
}
|
|
55
|
+
/** Parse one Codex rollout file (JSONL event stream). Exported for tests. */
|
|
56
|
+
export function parseCodexRollout(content) {
|
|
57
|
+
let model;
|
|
58
|
+
let cwd;
|
|
59
|
+
let sessionId;
|
|
60
|
+
let timestamp;
|
|
61
|
+
let lastTotal;
|
|
62
|
+
for (const line of content.split("\n")) {
|
|
63
|
+
if (!line.trim())
|
|
64
|
+
continue;
|
|
65
|
+
let entry;
|
|
66
|
+
try {
|
|
67
|
+
entry = JSON.parse(line);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!isRecord(entry))
|
|
73
|
+
continue;
|
|
74
|
+
const payload = isRecord(entry.payload) ? entry.payload : undefined;
|
|
75
|
+
if (entry.type === "session_meta" && payload) {
|
|
76
|
+
sessionId = stringOf(payload.id) ?? sessionId;
|
|
77
|
+
cwd = stringOf(payload.cwd) ?? cwd;
|
|
78
|
+
timestamp = toIso(stringOf(payload.timestamp) ?? stringOf(entry.timestamp)) ?? timestamp;
|
|
79
|
+
}
|
|
80
|
+
if (entry.type === "turn_context" && payload) {
|
|
81
|
+
model = stringOf(payload.model) ?? model;
|
|
82
|
+
cwd = stringOf(payload.cwd) ?? cwd;
|
|
83
|
+
}
|
|
84
|
+
if (entry.type === "event_msg" && payload?.type === "token_count") {
|
|
85
|
+
const info = isRecord(payload.info) ? payload.info : undefined;
|
|
86
|
+
const total = info && isRecord(info.total_token_usage) ? info.total_token_usage : undefined;
|
|
87
|
+
if (total) {
|
|
88
|
+
lastTotal = total;
|
|
89
|
+
timestamp = timestamp ?? toIso(stringOf(entry.timestamp));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (!lastTotal)
|
|
94
|
+
return [];
|
|
95
|
+
const input = numberOf(lastTotal.input_tokens) ?? 0;
|
|
96
|
+
const cached = numberOf(lastTotal.cached_input_tokens) ?? 0;
|
|
97
|
+
return [{
|
|
98
|
+
agent: "codex",
|
|
99
|
+
model: model ?? "codex",
|
|
100
|
+
timestamp: timestamp ?? new Date(0).toISOString(),
|
|
101
|
+
project: projectFromCwd(cwd),
|
|
102
|
+
sessionId,
|
|
103
|
+
usage: {
|
|
104
|
+
// Codex input_tokens INCLUDES cached tokens; split them out.
|
|
105
|
+
inputTokens: Math.max(0, input - cached),
|
|
106
|
+
outputTokens: numberOf(lastTotal.output_tokens) ?? 0,
|
|
107
|
+
cacheReadTokens: cached
|
|
108
|
+
}
|
|
109
|
+
}];
|
|
110
|
+
}
|
|
111
|
+
/** Scan this machine's agent logs and return aggregated UsageRecords. */
|
|
112
|
+
export async function loadLocalAgentUsage(options = {}) {
|
|
113
|
+
const home = homedir();
|
|
114
|
+
const claudeDir = options.claudeProjectsDir ?? join(home, ".claude", "projects");
|
|
115
|
+
const codexDir = options.codexSessionsDir ?? join(home, ".codex", "sessions");
|
|
116
|
+
const calls = [];
|
|
117
|
+
let filesParsed = 0;
|
|
118
|
+
for (const file of await listJsonlFiles(claudeDir)) {
|
|
119
|
+
const content = await readFile(file, "utf8").catch(() => "");
|
|
120
|
+
if (!content)
|
|
121
|
+
continue;
|
|
122
|
+
filesParsed += 1;
|
|
123
|
+
calls.push(...parseClaudeCodeTranscript(content, file));
|
|
124
|
+
}
|
|
125
|
+
for (const file of await listJsonlFiles(codexDir)) {
|
|
126
|
+
if (!basename(file).startsWith("rollout-"))
|
|
127
|
+
continue;
|
|
128
|
+
const content = await readFile(file, "utf8").catch(() => "");
|
|
129
|
+
if (!content)
|
|
130
|
+
continue;
|
|
131
|
+
filesParsed += 1;
|
|
132
|
+
calls.push(...parseCodexRollout(content));
|
|
133
|
+
}
|
|
134
|
+
const since = options.sinceIso ? Date.parse(options.sinceIso) : undefined;
|
|
135
|
+
const filtered = typeof since === "number" && Number.isFinite(since)
|
|
136
|
+
? calls.filter((call) => Date.parse(call.timestamp) >= since)
|
|
137
|
+
: calls;
|
|
138
|
+
return {
|
|
139
|
+
records: aggregateCalls(filtered),
|
|
140
|
+
calls: filtered,
|
|
141
|
+
filesParsed,
|
|
142
|
+
agentsDetected: [...new Set(filtered.map((call) => call.agent))]
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/** Aggregate per-call usage into one UsageRecord per day+agent+model+project. */
|
|
146
|
+
export function aggregateCalls(calls) {
|
|
147
|
+
const groups = new Map();
|
|
148
|
+
for (const call of calls) {
|
|
149
|
+
const day = call.timestamp.slice(0, 10);
|
|
150
|
+
const key = [day, call.agent, call.model, call.project ?? "unattributed"].join("|");
|
|
151
|
+
groups.set(key, [...(groups.get(key) ?? []), call]);
|
|
152
|
+
}
|
|
153
|
+
const records = [];
|
|
154
|
+
for (const [key, groupCalls] of groups) {
|
|
155
|
+
const [day, agent, model, project] = key.split("|");
|
|
156
|
+
const usage = {
|
|
157
|
+
inputTokens: sum(groupCalls, (c) => c.usage.inputTokens),
|
|
158
|
+
outputTokens: sum(groupCalls, (c) => c.usage.outputTokens),
|
|
159
|
+
cacheReadTokens: sum(groupCalls, (c) => c.usage.cacheReadTokens ?? 0),
|
|
160
|
+
cacheWrite5mTokens: sum(groupCalls, (c) => c.usage.cacheWrite5mTokens ?? 0),
|
|
161
|
+
cacheWrite1hTokens: sum(groupCalls, (c) => c.usage.cacheWrite1hTokens ?? 0)
|
|
162
|
+
};
|
|
163
|
+
const amountUsd = estimateTokenCostUsd(model, usage);
|
|
164
|
+
const priced = typeof amountUsd === "number";
|
|
165
|
+
records.push({
|
|
166
|
+
id: slug(["local", agent, day, model, project].join("-")),
|
|
167
|
+
timestamp: new Date(`${day}T00:00:00Z`).toISOString(),
|
|
168
|
+
source: {
|
|
169
|
+
id: "local-agent-logs",
|
|
170
|
+
name: "Local agent session logs",
|
|
171
|
+
provider: agent === "claude-code" ? "anthropic" : "openai",
|
|
172
|
+
confidence: "estimated",
|
|
173
|
+
observedFrom: `${agent} transcript JSONL (this machine)`
|
|
174
|
+
},
|
|
175
|
+
model,
|
|
176
|
+
inputTokens: usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWrite5mTokens ?? 0) + (usage.cacheWrite1hTokens ?? 0),
|
|
177
|
+
outputTokens: usage.outputTokens,
|
|
178
|
+
amountUsd: priced ? amountUsd : null,
|
|
179
|
+
costConfidence: priced ? "estimated" : "missing",
|
|
180
|
+
projectId: project === "unattributed" ? undefined : project,
|
|
181
|
+
agentId: agent,
|
|
182
|
+
providerCostType: "local_agent_logs",
|
|
183
|
+
quantity: groupCalls.length,
|
|
184
|
+
operation: `${agent} sessions`
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return records.sort((left, right) => left.id.localeCompare(right.id));
|
|
188
|
+
}
|
|
189
|
+
async function listJsonlFiles(root) {
|
|
190
|
+
const exists = await stat(root).then((s) => s.isDirectory()).catch(() => false);
|
|
191
|
+
if (!exists)
|
|
192
|
+
return [];
|
|
193
|
+
const out = [];
|
|
194
|
+
const queue = [root];
|
|
195
|
+
while (queue.length > 0) {
|
|
196
|
+
const dir = queue.pop();
|
|
197
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
const path = join(dir, entry.name);
|
|
200
|
+
if (entry.isDirectory())
|
|
201
|
+
queue.push(path);
|
|
202
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
203
|
+
out.push(path);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
function projectFromCwd(cwd) {
|
|
209
|
+
if (!cwd)
|
|
210
|
+
return undefined;
|
|
211
|
+
const name = basename(cwd);
|
|
212
|
+
return name.length > 0 ? name : undefined;
|
|
213
|
+
}
|
|
214
|
+
/** Claude Code encodes the project path into the transcript's parent dir name. */
|
|
215
|
+
function projectFromTranscriptPath(filePath) {
|
|
216
|
+
if (!filePath)
|
|
217
|
+
return undefined;
|
|
218
|
+
const parent = basename(join(filePath, ".."));
|
|
219
|
+
const tail = parent.split("-").filter(Boolean).pop();
|
|
220
|
+
return tail && tail.length > 0 ? tail : undefined;
|
|
221
|
+
}
|
|
222
|
+
function toIso(value) {
|
|
223
|
+
if (!value)
|
|
224
|
+
return undefined;
|
|
225
|
+
const parsed = Date.parse(value);
|
|
226
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
|
|
227
|
+
}
|
|
228
|
+
function sum(calls, pick) {
|
|
229
|
+
return calls.reduce((total, call) => total + pick(call), 0);
|
|
230
|
+
}
|
|
231
|
+
function numberOf(value) {
|
|
232
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
233
|
+
}
|
|
234
|
+
function stringOf(value) {
|
|
235
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
236
|
+
}
|
|
237
|
+
function isRecord(value) {
|
|
238
|
+
return typeof value === "object" && value !== null;
|
|
239
|
+
}
|
|
240
|
+
function slug(value) {
|
|
241
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "x";
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=localAgentLogs.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published per-token API prices (mid-2026) used to estimate the
|
|
3
|
+
* API-equivalent dollar value of locally observed usage (e.g. Claude Code /
|
|
4
|
+
* Codex session logs, where the provider never reports a price).
|
|
5
|
+
*
|
|
6
|
+
* Estimates only — always surfaced with costConfidence "estimated". Rules are
|
|
7
|
+
* matched top-down; first match wins. Unknown models return undefined so
|
|
8
|
+
* callers can label the record "missing" instead of inventing a number.
|
|
9
|
+
*/
|
|
10
|
+
export type TokenUsage = {
|
|
11
|
+
/** Billable, uncached input tokens. */
|
|
12
|
+
inputTokens: number;
|
|
13
|
+
outputTokens: number;
|
|
14
|
+
cacheReadTokens?: number;
|
|
15
|
+
cacheWrite5mTokens?: number;
|
|
16
|
+
cacheWrite1hTokens?: number;
|
|
17
|
+
};
|
|
18
|
+
type PricingRule = {
|
|
19
|
+
match: RegExp;
|
|
20
|
+
/** USD per million tokens. */
|
|
21
|
+
inputPerM: number;
|
|
22
|
+
outputPerM: number;
|
|
23
|
+
/** Defaults: cache read 0.1x input; 5m cache write 1.25x; 1h write 2x. */
|
|
24
|
+
cacheReadPerM?: number;
|
|
25
|
+
cacheWrite5mPerM?: number;
|
|
26
|
+
cacheWrite1hPerM?: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function findPricingRule(model: string): PricingRule | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* API-equivalent USD for a usage slice, or undefined when the model has no
|
|
31
|
+
* published price we recognize.
|
|
32
|
+
*/
|
|
33
|
+
export declare function estimateTokenCostUsd(model: string, usage: TokenUsage): number | undefined;
|
|
34
|
+
export {};
|
|
35
|
+
//# sourceMappingURL=modelPricing.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const pricingRules = [
|
|
2
|
+
// Anthropic
|
|
3
|
+
{ match: /^claude-fable-5/i, inputPerM: 10, outputPerM: 50 },
|
|
4
|
+
{ match: /^claude-opus-4-[5-9]/i, inputPerM: 5, outputPerM: 25 },
|
|
5
|
+
{ match: /^claude-opus-4(-[01])?$/i, inputPerM: 15, outputPerM: 75 },
|
|
6
|
+
{ match: /^claude-sonnet-4/i, inputPerM: 3, outputPerM: 15 },
|
|
7
|
+
{ match: /^claude-haiku-4/i, inputPerM: 1, outputPerM: 5 },
|
|
8
|
+
{ match: /^claude-3-7-sonnet|^claude-3-5-sonnet/i, inputPerM: 3, outputPerM: 15 },
|
|
9
|
+
{ match: /^claude-3-5-haiku/i, inputPerM: 0.8, outputPerM: 4 },
|
|
10
|
+
// OpenAI (codex CLI models first — more specific)
|
|
11
|
+
{ match: /^gpt-5(\.\d+)?-codex/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
|
|
12
|
+
{ match: /^gpt-5/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 },
|
|
13
|
+
{ match: /^gpt-4\.1-nano/i, inputPerM: 0.1, outputPerM: 0.4 },
|
|
14
|
+
{ match: /^gpt-4\.1-mini/i, inputPerM: 0.4, outputPerM: 1.6 },
|
|
15
|
+
{ match: /^gpt-4\.1/i, inputPerM: 2, outputPerM: 8, cacheReadPerM: 0.5 },
|
|
16
|
+
{ match: /^gpt-4o-mini/i, inputPerM: 0.15, outputPerM: 0.6 },
|
|
17
|
+
{ match: /^gpt-4o/i, inputPerM: 2.5, outputPerM: 10 },
|
|
18
|
+
{ match: /^o3$/i, inputPerM: 2, outputPerM: 8 },
|
|
19
|
+
{ match: /^o4-mini/i, inputPerM: 1.1, outputPerM: 4.4 },
|
|
20
|
+
{ match: /codex/i, inputPerM: 1.25, outputPerM: 10, cacheReadPerM: 0.125 }
|
|
21
|
+
];
|
|
22
|
+
export function findPricingRule(model) {
|
|
23
|
+
return pricingRules.find((rule) => rule.match.test(model));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* API-equivalent USD for a usage slice, or undefined when the model has no
|
|
27
|
+
* published price we recognize.
|
|
28
|
+
*/
|
|
29
|
+
export function estimateTokenCostUsd(model, usage) {
|
|
30
|
+
const rule = findPricingRule(model);
|
|
31
|
+
if (!rule) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const cacheRead = rule.cacheReadPerM ?? rule.inputPerM * 0.1;
|
|
35
|
+
const write5m = rule.cacheWrite5mPerM ?? rule.inputPerM * 1.25;
|
|
36
|
+
const write1h = rule.cacheWrite1hPerM ?? rule.inputPerM * 2;
|
|
37
|
+
const usd = (usage.inputTokens * rule.inputPerM +
|
|
38
|
+
usage.outputTokens * rule.outputPerM +
|
|
39
|
+
(usage.cacheReadTokens ?? 0) * cacheRead +
|
|
40
|
+
(usage.cacheWrite5mTokens ?? 0) * write5m +
|
|
41
|
+
(usage.cacheWrite1hTokens ?? 0) * write1h) /
|
|
42
|
+
1_000_000;
|
|
43
|
+
return Math.round(usd * 10_000) / 10_000;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=modelPricing.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { UsageRecord } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Plan-price math: compares API-equivalent usage (from local agent logs)
|
|
4
|
+
* against published subscription plan prices — the arbitrage check no
|
|
5
|
+
* provider will ever show, because it's the math that tells you to pay
|
|
6
|
+
* them less.
|
|
7
|
+
*
|
|
8
|
+
* Prices are mid-2026 list prices. As of 2026-06-15, programmatic/Agent-SDK
|
|
9
|
+
* usage on Claude plans is metered against a separate monthly credit pool at
|
|
10
|
+
* API rates ($20 Pro / $100 Max 5x / $200 Max 20x) — which makes the
|
|
11
|
+
* API-equivalent dollar figure the number that matters on both paths.
|
|
12
|
+
*/
|
|
13
|
+
export type SubscriptionPlan = {
|
|
14
|
+
id: string;
|
|
15
|
+
provider: "anthropic" | "openai";
|
|
16
|
+
agent: "claude-code" | "codex";
|
|
17
|
+
name: string;
|
|
18
|
+
monthlyUsd: number;
|
|
19
|
+
/** Rough API-equivalent monthly usage this plan comfortably covers. */
|
|
20
|
+
coversUpToUsd: number;
|
|
21
|
+
};
|
|
22
|
+
export declare const subscriptionPlans: SubscriptionPlan[];
|
|
23
|
+
export type PlanCheck = {
|
|
24
|
+
agent: "claude-code" | "codex";
|
|
25
|
+
/** 30-day projection of API-equivalent spend observed in local logs. */
|
|
26
|
+
apiEquivalentMonthlyUsd: number;
|
|
27
|
+
/** Distinct days of observed usage the projection is based on. */
|
|
28
|
+
windowDays: number;
|
|
29
|
+
/** Cheapest plan that comfortably covers the projected usage (if any). */
|
|
30
|
+
suggestedPlan?: SubscriptionPlan;
|
|
31
|
+
/** apiEquivalentMonthlyUsd - plan price, when positive. */
|
|
32
|
+
monthlySavingsVsApiUsd?: number;
|
|
33
|
+
/** One-line, render-ready verdict. */
|
|
34
|
+
headline: string;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Compute per-agent plan checks from usage records. Only records that came
|
|
38
|
+
* from local agent logs participate (billing-API records already have real
|
|
39
|
+
* prices and a real plan behind them).
|
|
40
|
+
*/
|
|
41
|
+
export declare function computePlanChecks(records: UsageRecord[]): PlanCheck[];
|
|
42
|
+
//# sourceMappingURL=planMath.d.ts.map
|
package/dist/planMath.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const subscriptionPlans = [
|
|
2
|
+
{ id: "claude-pro", provider: "anthropic", agent: "claude-code", name: "Claude Pro", monthlyUsd: 20, coversUpToUsd: 50 },
|
|
3
|
+
{ id: "claude-max-5x", provider: "anthropic", agent: "claude-code", name: "Claude Max 5x", monthlyUsd: 100, coversUpToUsd: 250 },
|
|
4
|
+
{ id: "claude-max-20x", provider: "anthropic", agent: "claude-code", name: "Claude Max 20x", monthlyUsd: 200, coversUpToUsd: 1000 },
|
|
5
|
+
{ id: "chatgpt-plus", provider: "openai", agent: "codex", name: "ChatGPT Plus", monthlyUsd: 20, coversUpToUsd: 60 },
|
|
6
|
+
{ id: "chatgpt-pro", provider: "openai", agent: "codex", name: "ChatGPT Pro", monthlyUsd: 200, coversUpToUsd: 1000 }
|
|
7
|
+
];
|
|
8
|
+
const localLogCostType = "local_agent_logs";
|
|
9
|
+
/**
|
|
10
|
+
* Compute per-agent plan checks from usage records. Only records that came
|
|
11
|
+
* from local agent logs participate (billing-API records already have real
|
|
12
|
+
* prices and a real plan behind them).
|
|
13
|
+
*/
|
|
14
|
+
export function computePlanChecks(records) {
|
|
15
|
+
const localRecords = records.filter((record) => record.providerCostType === localLogCostType &&
|
|
16
|
+
(record.agentId === "claude-code" || record.agentId === "codex") &&
|
|
17
|
+
typeof record.amountUsd === "number");
|
|
18
|
+
if (localRecords.length === 0) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const byAgent = new Map();
|
|
22
|
+
for (const record of localRecords) {
|
|
23
|
+
const agent = record.agentId;
|
|
24
|
+
byAgent.set(agent, [...(byAgent.get(agent) ?? []), record]);
|
|
25
|
+
}
|
|
26
|
+
const checks = [];
|
|
27
|
+
for (const [agent, agentRecords] of byAgent) {
|
|
28
|
+
const windowDays = Math.max(1, new Set(agentRecords.map((record) => record.timestamp.slice(0, 10))).size);
|
|
29
|
+
const windowUsd = agentRecords.reduce((total, record) => total + (record.amountUsd ?? 0), 0);
|
|
30
|
+
const monthly = roundMoney((windowUsd / windowDays) * 30);
|
|
31
|
+
const candidates = subscriptionPlans.filter((plan) => plan.agent === agent);
|
|
32
|
+
const suggested = candidates.find((plan) => monthly <= plan.coversUpToUsd) ?? candidates[candidates.length - 1];
|
|
33
|
+
const savings = suggested ? roundMoney(monthly - suggested.monthlyUsd) : undefined;
|
|
34
|
+
let headline;
|
|
35
|
+
if (!suggested) {
|
|
36
|
+
headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates.`;
|
|
37
|
+
}
|
|
38
|
+
else if (typeof savings === "number" && savings > 0) {
|
|
39
|
+
headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates — ${suggested.name} ($${suggested.monthlyUsd}/mo) likely covers this, ~$${savings.toFixed(2)}/mo cheaper than paying per token.`;
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
headline = `${agent}: ~$${monthly.toFixed(2)}/mo at API rates — within ${suggested.name} ($${suggested.monthlyUsd}/mo); pay-as-you-go API could be cheaper if you drop the subscription.`;
|
|
43
|
+
}
|
|
44
|
+
checks.push({
|
|
45
|
+
agent,
|
|
46
|
+
apiEquivalentMonthlyUsd: monthly,
|
|
47
|
+
windowDays,
|
|
48
|
+
suggestedPlan: suggested,
|
|
49
|
+
monthlySavingsVsApiUsd: typeof savings === "number" && savings > 0 ? savings : undefined,
|
|
50
|
+
headline
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return checks.sort((left, right) => right.apiEquivalentMonthlyUsd - left.apiEquivalentMonthlyUsd);
|
|
54
|
+
}
|
|
55
|
+
function roundMoney(value) {
|
|
56
|
+
return Math.round(value * 100) / 100;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=planMath.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ApprovedSource } from "./sourceRegistry.js";
|
|
2
|
+
import type { UsageRecord } from "./schema.js";
|
|
3
|
+
type ProviderResponse = {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
status: number;
|
|
6
|
+
statusText?: string;
|
|
7
|
+
headers?: Record<string, string | undefined> | {
|
|
8
|
+
get: (name: string) => string | null;
|
|
9
|
+
};
|
|
10
|
+
json: () => Promise<unknown>;
|
|
11
|
+
};
|
|
12
|
+
export type ProviderQaPagination = {
|
|
13
|
+
label: string;
|
|
14
|
+
pagesFetched: number;
|
|
15
|
+
stoppedBecause: "complete" | "missing_cursor" | "max_pages";
|
|
16
|
+
maxPages: number;
|
|
17
|
+
limitPerPage?: number;
|
|
18
|
+
};
|
|
19
|
+
export type ProviderQaRateLimit = {
|
|
20
|
+
label: string;
|
|
21
|
+
remainingRequests?: number;
|
|
22
|
+
retryAfterSeconds?: number;
|
|
23
|
+
};
|
|
24
|
+
export type ProviderQaDriftIssue = {
|
|
25
|
+
label: string;
|
|
26
|
+
field: string;
|
|
27
|
+
issue: string;
|
|
28
|
+
};
|
|
29
|
+
export type ProviderQaSummary = {
|
|
30
|
+
provider: string;
|
|
31
|
+
requestedEndpoints: string[];
|
|
32
|
+
pagination: ProviderQaPagination[];
|
|
33
|
+
rateLimits: ProviderQaRateLimit[];
|
|
34
|
+
responseDrift: ProviderQaDriftIssue[];
|
|
35
|
+
instructions: string[];
|
|
36
|
+
};
|
|
37
|
+
export type ProviderId = "openai" | "anthropic" | "github-copilot" | "cursor" | string;
|
|
38
|
+
export type ProviderConnectorInput = {
|
|
39
|
+
provider: ProviderId;
|
|
40
|
+
sourceId?: string;
|
|
41
|
+
authReference: string;
|
|
42
|
+
startTime: number;
|
|
43
|
+
endTime?: number;
|
|
44
|
+
org?: string;
|
|
45
|
+
enterprise?: string;
|
|
46
|
+
accountId?: string;
|
|
47
|
+
fetcher?: Fetcher;
|
|
48
|
+
tokenResolver?: TokenResolver;
|
|
49
|
+
};
|
|
50
|
+
export type ProviderConnectorResult = {
|
|
51
|
+
provider: string;
|
|
52
|
+
source: ApprovedSource;
|
|
53
|
+
records: UsageRecord[];
|
|
54
|
+
fetchedAt: string;
|
|
55
|
+
completeness: "verified" | "estimated" | "detected_unverified" | "missing";
|
|
56
|
+
qa: ProviderQaSummary;
|
|
57
|
+
};
|
|
58
|
+
export type CreateProviderConnectionInput = {
|
|
59
|
+
provider: ProviderId;
|
|
60
|
+
sourceId?: string;
|
|
61
|
+
authReference: string;
|
|
62
|
+
verifiedRecordCount: number;
|
|
63
|
+
totalUsd: number;
|
|
64
|
+
fetchedAt?: Date;
|
|
65
|
+
};
|
|
66
|
+
export type TokenResolver = (reference: string) => string;
|
|
67
|
+
export type Fetcher = (url: string, init?: {
|
|
68
|
+
method?: string;
|
|
69
|
+
headers?: Record<string, string>;
|
|
70
|
+
body?: string;
|
|
71
|
+
}) => Promise<ProviderResponse>;
|
|
72
|
+
type NormalizerOptions = {
|
|
73
|
+
sourceId: string;
|
|
74
|
+
observedFrom: string;
|
|
75
|
+
accountId?: string;
|
|
76
|
+
};
|
|
77
|
+
export declare function normalizeOpenAiCostResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
78
|
+
export declare function normalizeOpenAiUsageResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
79
|
+
export declare function normalizeAnthropicClaudeCodeUsageResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
80
|
+
export declare function normalizeGitHubCopilotSeatResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
81
|
+
export declare function normalizeAnthropicCostResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
82
|
+
export declare function normalizeGitHubCopilotMetricsResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
83
|
+
export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
84
|
+
export declare function fetchProviderUsageRecords(input: ProviderConnectorInput): Promise<ProviderConnectorResult>;
|
|
85
|
+
export declare function createProviderConnection(input: CreateProviderConnectionInput): ApprovedSource;
|
|
86
|
+
export declare function resolveTokenReference(reference: string, env?: Record<string, string | undefined>): string;
|
|
87
|
+
export {};
|
|
88
|
+
//# sourceMappingURL=providerConnectors.d.ts.map
|