@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.
@@ -0,0 +1,6 @@
1
+ import { type Recommendation, type SpendAnomaly, type SpendSummary, type UsageRecord, type WorkflowWatchEntry } from "./schema.js";
2
+ export declare function analyzeSpend(records: UsageRecord[]): SpendSummary;
3
+ export declare function detectSpendSpikes(records: UsageRecord[]): SpendAnomaly[];
4
+ export declare function generateWorkflowWatch(records: UsageRecord[]): WorkflowWatchEntry[];
5
+ export declare function generateRecommendations(records: UsageRecord[]): Recommendation[];
6
+ //# sourceMappingURL=analyze.d.ts.map
@@ -0,0 +1,260 @@
1
+ import { generateSpendInsights } from "./insights.js";
2
+ import { costConfidenceValues, spendSummarySchema } from "./schema.js";
3
+ const confidenceRank = {
4
+ verified: 0,
5
+ estimated: 1,
6
+ detected_unverified: 2,
7
+ missing: 3
8
+ };
9
+ export function analyzeSpend(records) {
10
+ const summary = {
11
+ totalUsd: roundMoney(sumRecords(records)),
12
+ recordCount: records.length,
13
+ confidence: combinedConfidence(records.map((record) => record.costConfidence)),
14
+ confidenceBreakdown: confidenceBreakdown(records),
15
+ bySource: breakdown(records, (record) => record.source.id),
16
+ byModel: breakdown(records, (record) => record.model),
17
+ byClient: breakdown(records, (record) => record.clientId),
18
+ byProject: breakdown(records, (record) => record.projectId),
19
+ byAgent: breakdown(records, (record) => record.agentId),
20
+ byUser: breakdown(records, (record) => record.userId),
21
+ byWorkspace: breakdown(records, (record) => record.workspaceId),
22
+ byApiKey: breakdown(records, (record) => record.apiKeyId),
23
+ workflowWatch: generateWorkflowWatch(records),
24
+ anomalies: detectSpendSpikes(records),
25
+ recommendations: generateRecommendations(records),
26
+ insights: []
27
+ };
28
+ summary.insights = generateSpendInsights(records, summary);
29
+ return spendSummarySchema.parse(summary);
30
+ }
31
+ export function detectSpendSpikes(records) {
32
+ const byDay = new Map();
33
+ for (const record of records) {
34
+ const day = record.timestamp.slice(0, 10);
35
+ byDay.set(day, [...(byDay.get(day) ?? []), record]);
36
+ }
37
+ const days = [...byDay.keys()].sort();
38
+ const anomalies = [];
39
+ for (let index = 1; index < days.length; index += 1) {
40
+ const previousDay = days[index - 1];
41
+ const currentDay = days[index];
42
+ const previousAmountUsd = roundMoney(sumRecords(byDay.get(previousDay) ?? []));
43
+ const currentRecords = byDay.get(currentDay) ?? [];
44
+ const currentAmountUsd = roundMoney(sumRecords(currentRecords));
45
+ if (previousAmountUsd === 0 || currentAmountUsd - previousAmountUsd < 10) {
46
+ continue;
47
+ }
48
+ const multiplier = currentAmountUsd / previousAmountUsd;
49
+ if (multiplier >= 1.75) {
50
+ anomalies.push({
51
+ kind: "day_over_day_spike",
52
+ key: currentDay,
53
+ previousAmountUsd,
54
+ currentAmountUsd,
55
+ multiplier: roundMoney(multiplier),
56
+ confidence: combinedConfidence(currentRecords.map((record) => record.costConfidence))
57
+ });
58
+ }
59
+ }
60
+ return anomalies;
61
+ }
62
+ export function generateWorkflowWatch(records) {
63
+ const totalUsd = sumRecords(records);
64
+ if (totalUsd === 0) {
65
+ return [];
66
+ }
67
+ const groups = new Map();
68
+ for (const record of records) {
69
+ const clientId = record.clientId ?? "unmapped-client";
70
+ const projectId = record.projectId ?? "unmapped-project";
71
+ const workflowKey = record.operation ?? "unmapped-workflow";
72
+ const agentId = record.agentId ?? "unmapped-agent";
73
+ const key = [clientId, projectId, workflowKey, agentId].join("::");
74
+ groups.set(key, [...(groups.get(key) ?? []), record]);
75
+ }
76
+ return [...groups.entries()]
77
+ .map(([key, groupRecords]) => {
78
+ const [clientId, projectId, workflowKey, agentId] = key.split("::");
79
+ const amountUsd = roundMoney(sumRecords(groupRecords));
80
+ const shareOfSpend = roundRatio(amountUsd / totalUsd);
81
+ const estimatedSavingsUsd = roundMoney(amountUsd * 0.236875);
82
+ const estimatedMarginRiskUsd = roundMoney(amountUsd * 0.625);
83
+ const confidence = combinedConfidence(groupRecords.map((record) => record.costConfidence));
84
+ const suggestedOptimization = workflowOptimizationFor(workflowKey, agentId);
85
+ return {
86
+ id: slugify(["workflow", clientId, projectId, workflowKey].join("-")),
87
+ clientId,
88
+ projectId,
89
+ workflowKey,
90
+ agentId,
91
+ amountUsd,
92
+ shareOfSpend,
93
+ recordCount: groupRecords.length,
94
+ confidence,
95
+ estimatedMarginRiskUsd,
96
+ estimatedSavingsUsd,
97
+ suggestedOptimization,
98
+ applyArtifact: `Copy this into your coding agent to cut cost: ${suggestedOptimization}`,
99
+ verificationPlan: `After applying, rerun the ${workflowKey} workflow on the same sample and compare spend, latency, and output acceptance before rolling it out.`
100
+ };
101
+ })
102
+ .filter((entry) => entry.amountUsd > 0)
103
+ .sort((left, right) => right.estimatedMarginRiskUsd - left.estimatedMarginRiskUsd || left.id.localeCompare(right.id))
104
+ .slice(0, 5);
105
+ }
106
+ export function generateRecommendations(records) {
107
+ const recommendations = [];
108
+ const modelSpend = breakdown(records, (record) => record.model);
109
+ const topModel = modelSpend[0];
110
+ if (topModel && topModel.amountUsd >= 20) {
111
+ recommendations.push({
112
+ id: "model-downgrade",
113
+ title: "Review expensive model workloads for downgrade candidates",
114
+ rationale: `${topModel.key} is the largest cost driver in the current local sample.`,
115
+ whyItMatters: "Premium model usage tends to become invisible once agents are running in the background. Board owners need a clear rule for which jobs deserve the expensive model.",
116
+ nextAction: `Audit the top ${topModel.key} operations and move low-risk summarization, extraction, and draft work to a cheaper model tier first.`,
117
+ priority: "high",
118
+ estimatedImpactUsd: roundMoney(topModel.amountUsd * 0.3237),
119
+ confidence: topModel.confidence,
120
+ relatedKeys: [topModel.key]
121
+ });
122
+ }
123
+ const highInputTokenRecords = records.filter((record) => record.inputTokens >= 100_000);
124
+ if (highInputTokenRecords.length > 0) {
125
+ recommendations.push({
126
+ id: "prompt-context-trimming",
127
+ title: "Trim large prompts and retrieved context",
128
+ rationale: "High input-token calls suggest prompt or retrieval context may be oversized.",
129
+ whyItMatters: "Context bloat compounds across every agent run and can make spend rise even when output quality does not improve.",
130
+ nextAction: "Sample the largest prompts, cap retrieval chunks, and require justification before agents include full documents or long histories.",
131
+ priority: "high",
132
+ estimatedImpactUsd: roundMoney(sumRecords(highInputTokenRecords) * 0.18),
133
+ confidence: combinedConfidence(highInputTokenRecords.map((record) => record.costConfidence)),
134
+ relatedKeys: unique(highInputTokenRecords.map((record) => record.model))
135
+ });
136
+ }
137
+ const repeatedOperations = repeatedValues(records.map((record) => record.operation).filter(isPresent));
138
+ if (repeatedOperations.length > 0) {
139
+ recommendations.push({
140
+ id: "caching",
141
+ title: "Cache repeated operations",
142
+ rationale: "Repeated operation labels are present in the sample and may be cacheable.",
143
+ whyItMatters: "Repeated AI calls are the easiest spend to defend cutting because they usually do not change the customer experience.",
144
+ nextAction: "Add a local cache or memoization policy for repeated operation labels before expanding this workflow to more clients.",
145
+ priority: "medium",
146
+ estimatedImpactUsd: roundMoney(sumRecords(records.filter((record) => repeatedOperations.includes(record.operation ?? ""))) * 0.25),
147
+ confidence: combinedConfidence(records.map((record) => record.costConfidence)),
148
+ relatedKeys: repeatedOperations
149
+ });
150
+ }
151
+ const agentSpend = breakdown(records, (record) => record.agentId);
152
+ const topAgent = agentSpend[0];
153
+ if (topAgent && topAgent.amountUsd >= 25) {
154
+ recommendations.push({
155
+ id: "agent-caps",
156
+ title: "Set local spend caps for the highest-cost agent",
157
+ rationale: `${topAgent.key} accounts for a material share of sampled usage.`,
158
+ whyItMatters: "An autonomous agent can quietly turn one bad loop or broad task into a budget issue before anyone reviews the invoice.",
159
+ nextAction: `Set a warning threshold and hard cap for ${topAgent.key}, then require approval when a run exceeds its expected range.`,
160
+ priority: "high",
161
+ estimatedImpactUsd: roundMoney(topAgent.amountUsd * 0.15),
162
+ confidence: topAgent.confidence,
163
+ relatedKeys: [topAgent.key]
164
+ });
165
+ }
166
+ if (records.length >= 8) {
167
+ recommendations.push({
168
+ id: "batching",
169
+ title: "Batch low-latency-tolerant work",
170
+ rationale: "The sample contains enough discrete calls to review for batching opportunities.",
171
+ whyItMatters: "Batching turns scattered background calls into an intentional queue, which makes spend easier to forecast and approve.",
172
+ nextAction: "Mark jobs that do not need immediate responses and run them in scheduled batches with a shared context budget.",
173
+ priority: "medium",
174
+ estimatedImpactUsd: roundMoney(sumRecords(records) * 0.08),
175
+ confidence: combinedConfidence(records.map((record) => record.costConfidence)),
176
+ relatedKeys: ["usage-records"]
177
+ });
178
+ }
179
+ const sources = unique(records.map((record) => record.source.id));
180
+ if (sources.length > 1) {
181
+ recommendations.push({
182
+ id: "routing",
183
+ title: "Route workloads by price and quality requirements",
184
+ rationale: "Multiple AI providers are represented, so routing policy can reduce avoidable spend.",
185
+ whyItMatters: "Without routing policy, teams pay premium prices for tasks where cheaper models or providers would be good enough.",
186
+ nextAction: "Define default provider/model tiers for extraction, drafting, research, and high-stakes reasoning, then measure quality deltas.",
187
+ priority: "medium",
188
+ estimatedImpactUsd: roundMoney(sumRecords(records) * 0.12),
189
+ confidence: combinedConfidence(records.map((record) => record.costConfidence)),
190
+ relatedKeys: sources
191
+ });
192
+ }
193
+ return recommendations;
194
+ }
195
+ function breakdown(records, select) {
196
+ const groups = new Map();
197
+ for (const record of records) {
198
+ const key = select(record) ?? "unmapped";
199
+ groups.set(key, [...(groups.get(key) ?? []), record]);
200
+ }
201
+ return [...groups.entries()]
202
+ .map(([key, groupRecords]) => ({
203
+ key,
204
+ amountUsd: roundMoney(sumRecords(groupRecords)),
205
+ recordCount: groupRecords.length,
206
+ confidence: combinedConfidence(groupRecords.map((record) => record.costConfidence))
207
+ }))
208
+ .sort((left, right) => right.amountUsd - left.amountUsd || left.key.localeCompare(right.key));
209
+ }
210
+ function sumRecords(records) {
211
+ return records.reduce((total, record) => total + (record.amountUsd ?? 0), 0);
212
+ }
213
+ function confidenceBreakdown(records) {
214
+ return Object.fromEntries(costConfidenceValues.map((confidence) => [
215
+ confidence,
216
+ roundMoney(sumRecords(records.filter((record) => record.costConfidence === confidence)))
217
+ ]));
218
+ }
219
+ function combinedConfidence(confidences) {
220
+ if (confidences.length === 0) {
221
+ return "missing";
222
+ }
223
+ return confidences.reduce((lowest, current) => confidenceRank[current] > confidenceRank[lowest] ? current : lowest);
224
+ }
225
+ function repeatedValues(values) {
226
+ const counts = new Map();
227
+ for (const value of values) {
228
+ counts.set(value, (counts.get(value) ?? 0) + 1);
229
+ }
230
+ return [...counts.entries()]
231
+ .filter(([, count]) => count > 1)
232
+ .map(([value]) => value)
233
+ .sort();
234
+ }
235
+ function unique(values) {
236
+ return [...new Set(values)].sort();
237
+ }
238
+ function isPresent(value) {
239
+ return value !== undefined;
240
+ }
241
+ function workflowOptimizationFor(workflowKey, agentId) {
242
+ const normalized = workflowKey.toLowerCase();
243
+ if (normalized.includes("research") || normalized.includes("summary")) {
244
+ return `Cap context for ${workflowKey}, cache repeated research inputs, and route first-pass summaries from ${agentId} to a cheaper model tier unless confidence drops.`;
245
+ }
246
+ if (normalized.includes("draft") || normalized.includes("copy")) {
247
+ return `Move first-draft generation for ${workflowKey} to a cheaper model tier, keep premium review only for final approval, and cache brand/context blocks.`;
248
+ }
249
+ return `Add a per-run budget cap for ${workflowKey}, route low-risk calls to a cheaper model tier, and cache stable inputs before expanding ${agentId}.`;
250
+ }
251
+ function slugify(value) {
252
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
253
+ }
254
+ function roundRatio(value) {
255
+ return Math.round(value * 10_000) / 10_000;
256
+ }
257
+ function roundMoney(value) {
258
+ return Math.round(value * 100) / 100;
259
+ }
260
+ //# sourceMappingURL=analyze.js.map
@@ -0,0 +1,4 @@
1
+ import type { AttributionMapping, UsageRecord } from "./schema.js";
2
+ export declare function classifyAttributionConfidence(confidence: number): AttributionMapping["status"];
3
+ export declare function attributeUsageRecords(records: UsageRecord[]): AttributionMapping[];
4
+ //# sourceMappingURL=attribution.d.ts.map
@@ -0,0 +1,117 @@
1
+ export function classifyAttributionConfidence(confidence) {
2
+ if (confidence >= 0.95) {
3
+ return "auto_mapped";
4
+ }
5
+ if (confidence >= 0.75) {
6
+ return "needs_confirmation";
7
+ }
8
+ if (confidence >= 0.5) {
9
+ return "needs_question";
10
+ }
11
+ return "unmapped";
12
+ }
13
+ export function attributeUsageRecords(records) {
14
+ return records.map((record) => {
15
+ const candidates = buildCandidates(record).sort((left, right) => right.confidence - left.confidence);
16
+ const selected = candidates[0];
17
+ const status = selected ? classifyAttributionConfidence(selected.confidence) : "unmapped";
18
+ return {
19
+ usageRecordId: record.id,
20
+ candidates,
21
+ selected: status === "unmapped" ? undefined : selected,
22
+ status,
23
+ evidence: candidates.flatMap((candidate) => candidate.evidence)
24
+ };
25
+ });
26
+ }
27
+ function buildCandidates(record) {
28
+ const candidates = [];
29
+ if (record.projectId) {
30
+ candidates.push({
31
+ entityType: "project",
32
+ entityId: record.projectId,
33
+ confidence: 0.98,
34
+ evidence: [`usage record includes projectId ${record.projectId}`]
35
+ });
36
+ }
37
+ if (record.clientId) {
38
+ candidates.push({
39
+ entityType: "client",
40
+ entityId: record.clientId,
41
+ confidence: 0.97,
42
+ evidence: [`usage record includes clientId ${record.clientId}`]
43
+ });
44
+ }
45
+ if (record.agentId) {
46
+ candidates.push({
47
+ entityType: "agent",
48
+ entityId: record.agentId,
49
+ confidence: 0.96,
50
+ evidence: [`usage record includes agentId ${record.agentId}`]
51
+ });
52
+ }
53
+ if (record.userId) {
54
+ candidates.push({
55
+ entityType: "user",
56
+ entityId: record.userId,
57
+ confidence: 0.94,
58
+ evidence: [`usage record includes userId ${record.userId}`]
59
+ });
60
+ }
61
+ if (record.workspaceId) {
62
+ candidates.push({
63
+ entityType: "workspace",
64
+ entityId: record.workspaceId,
65
+ confidence: 0.93,
66
+ evidence: [`usage record includes workspaceId ${record.workspaceId}`]
67
+ });
68
+ }
69
+ if (record.apiKeyId) {
70
+ candidates.push({
71
+ entityType: "api_key",
72
+ entityId: record.apiKeyId,
73
+ confidence: 0.9,
74
+ evidence: [`usage record includes apiKeyId ${record.apiKeyId}`]
75
+ });
76
+ }
77
+ const operation = record.operation?.toLowerCase() ?? "";
78
+ const clientMatch = operation.match(/client[_-]([a-z0-9-]+)/);
79
+ if (clientMatch?.[1]) {
80
+ candidates.push({
81
+ entityType: "client",
82
+ entityId: `client-${clientMatch[1]}`,
83
+ confidence: 0.82,
84
+ evidence: [`operation label references client_${clientMatch[1]}`]
85
+ });
86
+ }
87
+ const agentMatch = operation.match(/agent[_-]([a-z0-9-]+)/);
88
+ if (agentMatch?.[1]) {
89
+ candidates.push({
90
+ entityType: "agent",
91
+ entityId: `agent-${agentMatch[1]}`,
92
+ confidence: 0.8,
93
+ evidence: [`operation label references agent_${agentMatch[1]}`]
94
+ });
95
+ }
96
+ if (candidates.length === 0 && /claude|anthropic/i.test(`${record.source.provider} ${record.model}`)) {
97
+ candidates.push({
98
+ entityType: "agent",
99
+ entityId: "agent-claude-workflows",
100
+ confidence: 0.58,
101
+ evidence: [`model/source suggests Claude or Anthropic workflow: ${record.model}`]
102
+ });
103
+ }
104
+ return dedupeCandidates(candidates);
105
+ }
106
+ function dedupeCandidates(candidates) {
107
+ const byKey = new Map();
108
+ for (const candidate of candidates) {
109
+ const key = `${candidate.entityType}:${candidate.entityId}`;
110
+ const existing = byKey.get(key);
111
+ if (!existing || candidate.confidence > existing.confidence) {
112
+ byKey.set(key, candidate);
113
+ }
114
+ }
115
+ return Array.from(byKey.values());
116
+ }
117
+ //# sourceMappingURL=attribution.js.map
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Local credential auto-detection.
3
+ *
4
+ * CRITICAL SAFETY CONTRACT: this module NEVER returns, stores, or prints a raw
5
+ * secret value. It returns only:
6
+ * - the environment-variable NAME a key lives in (so callers can use the
7
+ * existing `env:NAME` reference pattern from sync-provider), and
8
+ * - a redacted last-4 hint for human recognition.
9
+ * Raw values are read transiently to classify them and are never surfaced.
10
+ */
11
+ export type DetectedCredential = {
12
+ provider: "openai" | "anthropic";
13
+ /** Reference usable with sync-provider, e.g. "env:OPENAI_API_KEY". */
14
+ reference: string;
15
+ /** The env var name the key was found in. */
16
+ envName: string;
17
+ /** Where we found it. */
18
+ origin: "process_env" | "dotenv" | "shell_rc";
19
+ /** Path of the file it was found in (for dotenv/shell_rc), redacted-safe. */
20
+ filePath?: string;
21
+ /** Redacted recognition hint, e.g. "sk-...­a1b2". Never the full key. */
22
+ hint: string;
23
+ /**
24
+ * Whether this looks like it CAN return cost data. Regular API keys cannot:
25
+ * all four providers gate cost/usage behind admin/owner credentials.
26
+ */
27
+ isLikelyAdminKey: boolean;
28
+ };
29
+ export type CredentialDetectionResult = {
30
+ credentials: DetectedCredential[];
31
+ /** Files scanned (paths only), for transparency / audit. */
32
+ scannedFiles: string[];
33
+ };
34
+ export type DetectCredentialsOptions = {
35
+ /** Working directory to look for .env files in. */
36
+ cwd?: string;
37
+ /** Override process.env (used in tests). */
38
+ env?: Record<string, string | undefined>;
39
+ /** Home directory override (used in tests). */
40
+ home?: string;
41
+ /** Skip reading shell rc files (faster / sandbox-safe). */
42
+ skipShellRc?: boolean;
43
+ };
44
+ export declare function detectLocalCredentials(options?: DetectCredentialsOptions): Promise<CredentialDetectionResult>;
45
+ //# sourceMappingURL=credentialDetection.d.ts.map
@@ -0,0 +1,133 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const providerRules = [
5
+ {
6
+ provider: "openai",
7
+ envNames: ["OPENAI_API_KEY", "OPENAI_ADMIN_KEY", "OPENAI_KEY"],
8
+ valuePattern: /^sk-[A-Za-z0-9_-]{16,}$/,
9
+ adminEnvHints: /ADMIN|ORG|OWNER/i
10
+ },
11
+ {
12
+ provider: "anthropic",
13
+ envNames: ["ANTHROPIC_API_KEY", "ANTHROPIC_ADMIN_KEY", "ANTHROPIC_KEY"],
14
+ valuePattern: /^sk-ant-[A-Za-z0-9_-]{16,}$/,
15
+ adminEnvHints: /ADMIN|ORG|OWNER/i
16
+ }
17
+ ];
18
+ const defaultShellRcFiles = [".zshrc", ".bashrc", ".bash_profile", ".profile"];
19
+ export async function detectLocalCredentials(options = {}) {
20
+ const cwd = options.cwd ?? process.cwd();
21
+ const env = options.env ?? process.env;
22
+ const home = options.home ?? homedir();
23
+ const scannedFiles = [];
24
+ const byReference = new Map();
25
+ // 1) process.env (highest signal; already loaded in the user's shell).
26
+ for (const rule of providerRules) {
27
+ for (const envName of rule.envNames) {
28
+ const value = env[envName];
29
+ if (value && rule.valuePattern.test(value.trim())) {
30
+ addCredential(byReference, {
31
+ provider: rule.provider,
32
+ reference: `env:${envName}`,
33
+ envName,
34
+ origin: "process_env",
35
+ hint: redactHint(value.trim()),
36
+ isLikelyAdminKey: rule.adminEnvHints.test(envName)
37
+ });
38
+ }
39
+ }
40
+ }
41
+ // 2) .env files in the working directory.
42
+ const dotenvCandidates = [".env", ".env.local"];
43
+ for (const fileName of dotenvCandidates) {
44
+ const filePath = join(cwd, fileName);
45
+ const parsed = await readEnvFile(filePath);
46
+ if (parsed) {
47
+ scannedFiles.push(filePath);
48
+ collectFromAssignments(byReference, parsed, "dotenv", filePath);
49
+ }
50
+ }
51
+ // 3) Shell rc files (export FOO=bar lines).
52
+ if (!options.skipShellRc) {
53
+ for (const fileName of defaultShellRcFiles) {
54
+ const filePath = join(home, fileName);
55
+ const parsed = await readEnvFile(filePath);
56
+ if (parsed) {
57
+ scannedFiles.push(filePath);
58
+ collectFromAssignments(byReference, parsed, "shell_rc", filePath);
59
+ }
60
+ }
61
+ }
62
+ return {
63
+ credentials: [...byReference.values()].sort((left, right) => left.provider.localeCompare(right.provider) || left.reference.localeCompare(right.reference)),
64
+ scannedFiles
65
+ };
66
+ }
67
+ function collectFromAssignments(byReference, assignments, origin, filePath) {
68
+ for (const rule of providerRules) {
69
+ for (const envName of rule.envNames) {
70
+ const value = assignments.get(envName);
71
+ if (value && rule.valuePattern.test(value)) {
72
+ addCredential(byReference, {
73
+ provider: rule.provider,
74
+ reference: `env:${envName}`,
75
+ envName,
76
+ origin,
77
+ filePath,
78
+ hint: redactHint(value),
79
+ isLikelyAdminKey: rule.adminEnvHints.test(envName)
80
+ });
81
+ }
82
+ }
83
+ }
84
+ }
85
+ /** Prefer the highest-signal origin (process_env) when the same ref appears twice. */
86
+ function addCredential(map, credential) {
87
+ const existing = map.get(credential.reference);
88
+ if (!existing || originRank(credential.origin) < originRank(existing.origin)) {
89
+ map.set(credential.reference, credential);
90
+ }
91
+ }
92
+ function originRank(origin) {
93
+ return origin === "process_env" ? 0 : origin === "dotenv" ? 1 : 2;
94
+ }
95
+ async function readEnvFile(path) {
96
+ let contents;
97
+ try {
98
+ contents = await readFile(path, "utf8");
99
+ }
100
+ catch {
101
+ return undefined;
102
+ }
103
+ const assignments = new Map();
104
+ for (const rawLine of contents.split(/\r?\n/)) {
105
+ const line = rawLine.trim();
106
+ if (!line || line.startsWith("#")) {
107
+ continue;
108
+ }
109
+ // Handles `FOO=bar`, `export FOO=bar`, and quoted values.
110
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
111
+ if (!match) {
112
+ continue;
113
+ }
114
+ const name = match[1];
115
+ let value = match[2].trim();
116
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
117
+ value = value.slice(1, -1);
118
+ }
119
+ // Strip inline comments on unquoted values.
120
+ value = value.replace(/\s+#.*$/, "").trim();
121
+ if (value) {
122
+ assignments.set(name, value);
123
+ }
124
+ }
125
+ return assignments;
126
+ }
127
+ /** Returns a recognition hint that never exposes the secret, e.g. "sk-...­f0a2". */
128
+ function redactHint(value) {
129
+ const prefix = value.slice(0, 3);
130
+ const suffix = value.length >= 4 ? value.slice(-4) : "";
131
+ return `${prefix}...${suffix}`;
132
+ }
133
+ //# sourceMappingURL=credentialDetection.js.map
@@ -0,0 +1,29 @@
1
+ import type { CostConfidence, UsageRecord } from "./schema.js";
2
+ /**
3
+ * Actionable, dollar-specific "cut" suggestions.
4
+ *
5
+ * The product wow is specificity: instead of "review expensive model
6
+ * workloads", we say "move these 4 gpt-4.1 ticket_triage calls to
7
+ * gpt-4.1-mini -> save ~$3.10/mo". Every entry is grounded in real records
8
+ * from the loaded sample/usage so the dollar amount is defensible.
9
+ */
10
+ export type CutAction = {
11
+ id: string;
12
+ /** Short imperative headline, e.g. "Move gpt-4.1 ticket_triage to gpt-4.1-mini". */
13
+ title: string;
14
+ /** One-line, copy-pasteable instruction with the exact target. */
15
+ action: string;
16
+ /** Estimated monthly savings in USD for this single action. */
17
+ estimatedMonthlySavingsUsd: number;
18
+ /** Spend (in the analyzed window) this action touches. */
19
+ affectedSpendUsd: number;
20
+ /** How many usage records this action is grounded in. */
21
+ recordCount: number;
22
+ /** Lowest confidence of the underlying records (drives how we caveat $). */
23
+ confidence: CostConfidence;
24
+ kind: "model_downgrade" | "context_trim" | "cache" | "batch";
25
+ };
26
+ export declare function generateCutList(records: UsageRecord[]): CutAction[];
27
+ /** Sum of all per-action estimated monthly savings. */
28
+ export declare function totalEstimatedMonthlySavingsUsd(actions: CutAction[]): number;
29
+ //# sourceMappingURL=cutList.d.ts.map