@occasiolabs/occasio 0.8.3 → 0.8.4
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/README.md +1 -0
- package/docs/ARCHITECTURE.md +171 -0
- package/package.json +8 -2
- package/src/attest/check-summary.js +1 -1
- package/src/attest/index.js +14 -1
- package/src/audit/jsonl-auditor.js +180 -14
- package/src/audit/repair.js +118 -0
- package/src/audit/verifier.js +36 -2
- package/src/cli/clear.js +55 -0
- package/src/cli/help.js +81 -0
- package/src/cli/register.js +90 -0
- package/src/cli/status.js +94 -0
- package/src/cost/prices.js +106 -0
- package/src/index.js +15 -270
package/src/cli/clear.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `occasio clear` — reset session + today's log.
|
|
2
|
+
// `occasio clear --history` — wipe ALL historical logs + blocked-secret records.
|
|
3
|
+
//
|
|
4
|
+
// Destructive but bounded to ~/.occasio/. session.json is unconditionally
|
|
5
|
+
// removed; logs are removed file-by-file (no rmdir) so an exotic
|
|
6
|
+
// permission error on one file does not abort the rest.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
15
|
+
const SESSION_FILE = path.join(LOG_DIR, 'session.json');
|
|
16
|
+
|
|
17
|
+
const col = {
|
|
18
|
+
g: s => `\x1b[32m${s}\x1b[0m`,
|
|
19
|
+
d: s => `\x1b[2m${s}\x1b[0m`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function todayStr() {
|
|
23
|
+
const d = new Date();
|
|
24
|
+
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
25
|
+
}
|
|
26
|
+
function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
|
|
27
|
+
|
|
28
|
+
function ensureDirs() {
|
|
29
|
+
for (const sub of ['', 'logs', 'reports', 'blocked', 'distilled']) {
|
|
30
|
+
const d = path.join(LOG_DIR, sub);
|
|
31
|
+
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function run(args) {
|
|
36
|
+
ensureDirs();
|
|
37
|
+
const clearAll = (args || []).includes('--history');
|
|
38
|
+
if (clearAll) {
|
|
39
|
+
const logsDir = path.join(LOG_DIR, 'logs');
|
|
40
|
+
const blockedDir = path.join(LOG_DIR, 'blocked');
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (const dir of [logsDir, blockedDir]) {
|
|
43
|
+
try { for (const f of fs.readdirSync(dir)) { fs.unlinkSync(path.join(dir, f)); n++; } } catch {}
|
|
44
|
+
}
|
|
45
|
+
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
|
46
|
+
console.log(col.g(`✓ Cleared all history (${n} log files) and session data`));
|
|
47
|
+
} else {
|
|
48
|
+
try { fs.unlinkSync(getLogFile()); } catch {}
|
|
49
|
+
try { fs.unlinkSync(SESSION_FILE); } catch {}
|
|
50
|
+
console.log(col.g("✓ Cleared today's log and session data"));
|
|
51
|
+
console.log(col.d(' Use --history to wipe all historical logs'));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { run };
|
package/src/cli/help.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `occasio help` — top-level usage. Pure text; no side effects other
|
|
2
|
+
// than console.log. Each CLI command lives in its own file under
|
|
3
|
+
// src/cli/ as part of the index.js decomposition (see CHANGELOG).
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const VERSION = (() => {
|
|
8
|
+
try { return require('../../package.json').version; }
|
|
9
|
+
catch { return '0.0.0-unknown'; }
|
|
10
|
+
})();
|
|
11
|
+
|
|
12
|
+
const col = {
|
|
13
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
14
|
+
y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
|
|
15
|
+
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function run() {
|
|
19
|
+
console.log(`
|
|
20
|
+
${col.b(`⚡ Occasio v${VERSION}`)}
|
|
21
|
+
|
|
22
|
+
${col.b('Usage:')}
|
|
23
|
+
occasio claude [args...] Start Claude with local proxy (intercept + log)
|
|
24
|
+
occasio demo 10-second proof: see Occasio block real secrets
|
|
25
|
+
occasio demo attest End-to-end attestation pipeline against a synthetic audit chain
|
|
26
|
+
occasio demo anomalies End-to-end EDR test: synthetic adversarial chain → all 4 detectors
|
|
27
|
+
occasio dashboard Open live dashboard for the running session
|
|
28
|
+
occasio register Register shell alias (type 'claude' directly)
|
|
29
|
+
occasio status Show session stats and savings breakdown
|
|
30
|
+
occasio doctor Check setup: Node, claude CLI, port, Python, profile
|
|
31
|
+
occasio clear Reset today's log and session data
|
|
32
|
+
occasio clear --history Wipe all historical logs
|
|
33
|
+
occasio ledger Inspect token ledger (--last N, --summary, --scope session|today)
|
|
34
|
+
occasio replay Replay run audit (--last N, --detail, --run <id>, --attribute)
|
|
35
|
+
occasio distill Inspect distilled outputs (--last N, --entry <N> for raw)
|
|
36
|
+
occasio inspect Cloud-boundary manifest (--last N, --entry N, --run <id>)
|
|
37
|
+
occasio boundary Per-request three-column view: produced / re-entered / prevented
|
|
38
|
+
occasio baseline Behavior baseline: [learn|show|compare|reset] (per project cwd)
|
|
39
|
+
occasio harness Run a real Claude Code session against scratch fixtures and verify governance claims (needs ANTHROPIC_API_KEY)
|
|
40
|
+
occasio redteam Autonomous adversarial test — tester LLM probes a subject Claude Code session under Occasio (needs ANTHROPIC_API_KEY + @anthropic-ai/sdk)
|
|
41
|
+
occasio policy [show] Show active policy: flags, tool routing, overrides
|
|
42
|
+
occasio policy show --diff Only values that differ from defaults
|
|
43
|
+
occasio policy validate Validate policy.yml and report errors/warnings
|
|
44
|
+
occasio policy init Create a starter policy.yml (safe, non-destructive)
|
|
45
|
+
Use --template strict|finance for a non-default starter
|
|
46
|
+
occasio policy doctor Cross-reference session logs with policy; surface suggestions
|
|
47
|
+
occasio audit [verify] Verify tamper-evident hash chain in pipeline-events.jsonl
|
|
48
|
+
occasio audit repair Truncate a crash-partial trailing line (--file <path> [--dry-run])
|
|
49
|
+
occasio report Governance export: file access log, blocked paths, secret events
|
|
50
|
+
occasio anomalies Live anomaly detection over the audit chain (--window 15m, --json)
|
|
51
|
+
occasio computer-use Apply a Computer-Use policy to a JSONL of tool_use blocks (--dry-run --example)
|
|
52
|
+
occasio attest --run-id <uuid> AI-Agent Behavioral Attestation v1: hash-chain commitment + execution summary for one run
|
|
53
|
+
Add --sign in GitHub Actions (with permissions: id-token: write) for Sigstore keyless signing
|
|
54
|
+
occasio attest verify <file> Re-verify a signed attestation: Sigstore bundle + DSSE payload match + audit chain integrity
|
|
55
|
+
occasio selftest Run governance self-checks on a scratch chain (does not touch your audit log)
|
|
56
|
+
occasio report --format csv CSV export for auditors / SIEM import
|
|
57
|
+
occasio mcp-experiment MCP vs. built-in tool adoption stats (experiment)
|
|
58
|
+
|
|
59
|
+
${col.b('Presets:')}
|
|
60
|
+
--preset balanced (default) Intercept safe reads locally, log all requests
|
|
61
|
+
--preset strict Block requests that contain detected secrets
|
|
62
|
+
--preset off Log only — no interception, no blocking
|
|
63
|
+
|
|
64
|
+
${col.b('Flags:')}
|
|
65
|
+
--budget <N> Block requests once session cost exceeds $N (e.g. --budget 1.00)
|
|
66
|
+
--hardened Route Read/Glob/Grep through unified runtime (distill + secret scan)
|
|
67
|
+
--block-secrets Alias for --preset strict
|
|
68
|
+
--log-only Alias for --preset off
|
|
69
|
+
--dashboard Open live dashboard at http://localhost:3001
|
|
70
|
+
--port <N> Proxy port (default: 8081)
|
|
71
|
+
--verbose Print live per-request chatter (off by default — quiet for Claude Code's TUI)
|
|
72
|
+
|
|
73
|
+
${col.b('Multi-agent routing:')}
|
|
74
|
+
Default → Claude Code adapter
|
|
75
|
+
Header x-occasio-agent: cline → Cline adapter (synthetic; live validation pending)
|
|
76
|
+
|
|
77
|
+
${col.b('Logs:')} ~/.occasio/logs/YYYY-MM-DD.jsonl
|
|
78
|
+
`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { run };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// `occasio register` — installs a `claude()` shell function so the user
|
|
2
|
+
// can keep typing `claude` and silently get routed through the proxy.
|
|
3
|
+
//
|
|
4
|
+
// Idempotent: detects the canonical marker and exits; auto-upgrades the
|
|
5
|
+
// legacy `--intercept` snippet to `claude` if found. Best-effort on
|
|
6
|
+
// failure — prints a manual instruction rather than crashing.
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const col = {
|
|
15
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
16
|
+
y: s => `\x1b[33m${s}\x1b[0m`, d: s => `\x1b[2m${s}\x1b[0m`,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function registerWindows() {
|
|
20
|
+
const profileDir = path.join(os.homedir(), 'Documents', 'PowerShell');
|
|
21
|
+
const profileFile = path.join(profileDir, 'Microsoft.PowerShell_profile.ps1');
|
|
22
|
+
const snippet = `\n# Occasio — intercept Claude Code traffic\nfunction claude { occasio claude @args }\n`;
|
|
23
|
+
const alreadyMarker = 'occasio claude @args';
|
|
24
|
+
const legacyMarker = 'occasio --intercept @args';
|
|
25
|
+
try {
|
|
26
|
+
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
27
|
+
const existing = fs.existsSync(profileFile) ? fs.readFileSync(profileFile, 'utf8') : '';
|
|
28
|
+
if (existing.includes(alreadyMarker)) {
|
|
29
|
+
console.log(col.g('✓ Already registered (PowerShell)'));
|
|
30
|
+
console.log(col.d(' Type: claude'));
|
|
31
|
+
} else if (existing.includes(legacyMarker)) {
|
|
32
|
+
const updated = existing.replace(
|
|
33
|
+
/function claude \{ occasio --intercept @args \}/g,
|
|
34
|
+
'function claude { occasio claude @args }'
|
|
35
|
+
);
|
|
36
|
+
fs.writeFileSync(profileFile, updated);
|
|
37
|
+
console.log(col.g('✓ Updated to canonical form (occasio claude)'));
|
|
38
|
+
console.log('');
|
|
39
|
+
console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
|
|
40
|
+
console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
|
|
41
|
+
console.log('');
|
|
42
|
+
} else {
|
|
43
|
+
fs.appendFileSync(profileFile, snippet);
|
|
44
|
+
console.log(col.g(`✓ Registered in ${profileFile}`));
|
|
45
|
+
console.log('');
|
|
46
|
+
console.log(col.y(` ⚠ Restart PowerShell — the 'claude' alias is not active yet.`));
|
|
47
|
+
console.log(col.d(` Open a new terminal, or run: . $PROFILE`));
|
|
48
|
+
console.log('');
|
|
49
|
+
}
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.log(col.r(`✗ Could not write profile: ${e.message}`));
|
|
52
|
+
console.log(col.d(` Add manually to your PowerShell profile:\n function claude { occasio claude @args }`));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function registerPosix() {
|
|
57
|
+
const rcFile = (process.env.SHELL || '').includes('zsh')
|
|
58
|
+
? path.join(os.homedir(), '.zshrc')
|
|
59
|
+
: path.join(os.homedir(), '.bashrc');
|
|
60
|
+
const snippet = `\n# Occasio — intercept Claude Code traffic\nclaude() { occasio claude "$@"; }\n`;
|
|
61
|
+
const alreadyMarker = 'occasio claude "$@"';
|
|
62
|
+
const legacyMarker = 'occasio --intercept "$@"';
|
|
63
|
+
try {
|
|
64
|
+
const existing = fs.existsSync(rcFile) ? fs.readFileSync(rcFile, 'utf8') : '';
|
|
65
|
+
if (existing.includes(alreadyMarker)) {
|
|
66
|
+
console.log(col.g(`✓ Already registered (${rcFile})`));
|
|
67
|
+
} else if (existing.includes(legacyMarker)) {
|
|
68
|
+
const updated = existing.replace(
|
|
69
|
+
/claude\(\) \{ occasio --intercept "\$@"; \}/g,
|
|
70
|
+
'claude() { occasio claude "$@"; }'
|
|
71
|
+
);
|
|
72
|
+
fs.writeFileSync(rcFile, updated);
|
|
73
|
+
console.log(col.g(`✓ Updated to canonical form in ${rcFile}`));
|
|
74
|
+
} else {
|
|
75
|
+
fs.appendFileSync(rcFile, snippet);
|
|
76
|
+
console.log(col.g(`✓ Registered in ${rcFile}`));
|
|
77
|
+
}
|
|
78
|
+
console.log(col.d(' Run: source ' + rcFile + ' — then type: claude'));
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.log(col.r(`✗ Could not write ${rcFile}: ${e.message}`));
|
|
81
|
+
console.log(col.d(` Add manually:\n claude() { occasio claude "$@"; }`));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function run() {
|
|
86
|
+
if (process.platform === 'win32') registerWindows();
|
|
87
|
+
else registerPosix();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { run };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `occasio status` — session summary (cost, savings, coverage, budget).
|
|
2
|
+
// Read-only against ~/.occasio/session.json + today's JSONL log.
|
|
3
|
+
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const { calcCompoundingSavings } = require('../cost/prices');
|
|
11
|
+
const { fmtBudget } = require('../budget');
|
|
12
|
+
|
|
13
|
+
const LOG_DIR = path.join(os.homedir(), '.occasio');
|
|
14
|
+
const SESSION_FILE = path.join(LOG_DIR, 'session.json');
|
|
15
|
+
|
|
16
|
+
const col = {
|
|
17
|
+
r: s => `\x1b[31m${s}\x1b[0m`, g: s => `\x1b[32m${s}\x1b[0m`,
|
|
18
|
+
y: s => `\x1b[33m${s}\x1b[0m`, c: s => `\x1b[36m${s}\x1b[0m`,
|
|
19
|
+
d: s => `\x1b[2m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function todayStr() {
|
|
23
|
+
const d = new Date();
|
|
24
|
+
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
|
25
|
+
}
|
|
26
|
+
function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
|
|
27
|
+
|
|
28
|
+
function run() {
|
|
29
|
+
let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
|
|
30
|
+
console.log(col.b('\n⚡ Occasio\n'));
|
|
31
|
+
if (!s) { console.log(col.d(' No session data yet. Run: occasio claude\n')); return; }
|
|
32
|
+
|
|
33
|
+
const cacheSav = s.cache_savings || 0;
|
|
34
|
+
const laoSav = s.lao_cost_saved || 0;
|
|
35
|
+
const distSav = s.distill_cost_saved || 0;
|
|
36
|
+
const payload = laoSav + distSav;
|
|
37
|
+
const { savings: context } =
|
|
38
|
+
calcCompoundingSavings(s.run_id, s.log_file || getLogFile(), s.model || '');
|
|
39
|
+
const totalSav = payload + context + cacheSav;
|
|
40
|
+
const broaderCf = (s.cost || 0) + totalSav;
|
|
41
|
+
const savedPct = broaderCf > 0.00001 ? Math.round(totalSav / broaderCf * 100) : 0;
|
|
42
|
+
|
|
43
|
+
// Headline
|
|
44
|
+
if (totalSav > 0.00001) {
|
|
45
|
+
console.log(col.g(` Saved: $${totalSav.toFixed(4)}`) +
|
|
46
|
+
col.d(` (${savedPct}% off — would have cost $${broaderCf.toFixed(4)})`));
|
|
47
|
+
} else {
|
|
48
|
+
console.log(col.d(` Saved: $0.0000 (no interceptable tool calls in this session yet)`));
|
|
49
|
+
}
|
|
50
|
+
console.log(col.y(` Cost: $${s.cost.toFixed(4)}`));
|
|
51
|
+
|
|
52
|
+
// Plain-English coverage. Defensive: legacy sessions (pre-multi-round-fix)
|
|
53
|
+
// may have tools_attempted undercounted relative to tools_local_count.
|
|
54
|
+
// We clamp the denominator to at least the numerator so the displayed
|
|
55
|
+
// ratio is always 0–100% and never reads "X of Y < X (>100%)".
|
|
56
|
+
const localCnt = s.tools_local_count || 0;
|
|
57
|
+
const mcpCnt = s.tools_mcp_count || 0;
|
|
58
|
+
const attempted = s.tools_attempted || 0;
|
|
59
|
+
const totalLocal = localCnt + mcpCnt;
|
|
60
|
+
const denom = Math.max(attempted, totalLocal);
|
|
61
|
+
if (denom > 0) {
|
|
62
|
+
const cpct = Math.round(totalLocal / denom * 100);
|
|
63
|
+
const cColor = cpct >= 80 ? col.g : cpct >= 50 ? col.y : col.r;
|
|
64
|
+
console.log(cColor(` Ran locally: ${totalLocal} of ${denom} tool calls (${cpct}%)`));
|
|
65
|
+
}
|
|
66
|
+
if (s.blocked) console.log(col.r(` Blocked: ${s.blocked} secrets`));
|
|
67
|
+
if (s.secrets_redacted) console.log(col.c(` Redacted: ${s.secrets_redacted} secret${s.secrets_redacted !== 1 ? 's' : ''} in tool results`));
|
|
68
|
+
if (s.tools_transformed) console.log(col.c(` Transforms: ${s.tools_transformed} tool result${s.tools_transformed !== 1 ? 's' : ''} shaped`));
|
|
69
|
+
if (s.budget != null) {
|
|
70
|
+
const pct = Math.min(999, Math.round((s.cost || 0) / s.budget * 100));
|
|
71
|
+
const budgetStr = fmtBudget(s.cost || 0, s.budget);
|
|
72
|
+
const budgetColor = pct >= 100 ? col.r : pct >= 80 ? col.y : col.g;
|
|
73
|
+
console.log(budgetColor(` Budget: ${budgetStr}`));
|
|
74
|
+
if (s.budget_exceeded_count) console.log(col.r(` BudgetBlk: ${s.budget_exceeded_count} request(s) blocked`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Detail
|
|
78
|
+
console.log(col.d(` ────`));
|
|
79
|
+
console.log(col.d(` Requests: ${s.requests} · ${(s.input_tokens/1000).toFixed(1)}k tokens in · ${(s.output_tokens/1000).toFixed(1)}k out`));
|
|
80
|
+
if (totalSav > 0.00001) {
|
|
81
|
+
const parts = [];
|
|
82
|
+
if (payload > 0.00001) parts.push(`$${payload.toFixed(4)} payload`);
|
|
83
|
+
if (context > 0.00001) parts.push(`$${context.toFixed(4)} context`);
|
|
84
|
+
if (cacheSav > 0.00001) parts.push(`$${cacheSav.toFixed(4)} cache`);
|
|
85
|
+
if (parts.length) console.log(col.d(` Breakdown: ${parts.join(' + ')}`));
|
|
86
|
+
}
|
|
87
|
+
const tail = [];
|
|
88
|
+
if (s.mode) tail.push(`Mode: ${s.mode}`);
|
|
89
|
+
if (s.start) tail.push(`Since: ${new Date(s.start).toLocaleString()}`);
|
|
90
|
+
if (tail.length) console.log(col.d(` ${tail.join(' · ')}`));
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { run };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Token-cost arithmetic for Anthropic-priced models. Extracted from
|
|
2
|
+
// src/index.js so the proxy hot-path doesn't carry pricing data, and so
|
|
3
|
+
// MODEL_PRICES updates land in a file small enough to review at a glance.
|
|
4
|
+
//
|
|
5
|
+
// Prices are USD per 1M tokens. Cache-write is the one-time cost to
|
|
6
|
+
// populate a cache breakpoint; cache-read is the cheap subsequent hit.
|
|
7
|
+
//
|
|
8
|
+
// Substring matching is intentional — Anthropic's model_id strings often
|
|
9
|
+
// carry a dated suffix (claude-haiku-4-5-20251001) we want to absorb
|
|
10
|
+
// without a table update. The trade-off: a truly unknown model silently
|
|
11
|
+
// falls back to `default`. We warn once per unknown model so the failure
|
|
12
|
+
// is loud the first time and quiet thereafter.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
|
|
18
|
+
const MODEL_PRICES = {
|
|
19
|
+
'claude-opus-4-6': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
|
|
20
|
+
'claude-opus-4': { in: 15.00, out: 75.00, cache_write: 18.75, cache_read: 1.50 },
|
|
21
|
+
'claude-sonnet-4-6': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
22
|
+
'claude-sonnet-4': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
23
|
+
'claude-haiku-4-5': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
|
|
24
|
+
'claude-haiku-4': { in: 0.25, out: 1.25, cache_write: 0.30, cache_read: 0.03 },
|
|
25
|
+
'default': { in: 3.00, out: 15.00, cache_write: 3.75, cache_read: 0.30 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Track which unknown model names we've already complained about, so a
|
|
29
|
+
// long session with a new model surfaces the warning exactly once instead
|
|
30
|
+
// of on every request.
|
|
31
|
+
const _warnedUnknown = new Set();
|
|
32
|
+
|
|
33
|
+
function getPrice(model) {
|
|
34
|
+
if (!model) return MODEL_PRICES.default;
|
|
35
|
+
for (const [k, v] of Object.entries(MODEL_PRICES)) {
|
|
36
|
+
if (k !== 'default' && model.includes(k)) return v;
|
|
37
|
+
}
|
|
38
|
+
if (!_warnedUnknown.has(model)) {
|
|
39
|
+
_warnedUnknown.add(model);
|
|
40
|
+
// stderr so it doesn't pollute proxy stdout. Silenceable via env for
|
|
41
|
+
// CI runs that legitimately want to price unknown models as default.
|
|
42
|
+
if (!process.env.OCCASIO_QUIET_PRICING) {
|
|
43
|
+
process.stderr.write(
|
|
44
|
+
`[occasio] warning: unknown model "${model}" — falling back to default pricing ` +
|
|
45
|
+
`(in:$${MODEL_PRICES.default.in}/M, out:$${MODEL_PRICES.default.out}/M). ` +
|
|
46
|
+
`Add it to src/cost/prices.js to silence this.\n`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return MODEL_PRICES.default;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function calcCost(model, inp, out, cacheWrite = 0, cacheRead = 0) {
|
|
54
|
+
const p = getPrice(model);
|
|
55
|
+
return (inp / 1e6 * p.in) + (out / 1e6 * p.out)
|
|
56
|
+
+ (cacheWrite / 1e6 * p.cache_write) + (cacheRead / 1e6 * p.cache_read);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Savings from Anthropic prompt caching: cache reads are 10× cheaper than fresh input.
|
|
60
|
+
function calcCacheSavings(model, cacheReadTokens) {
|
|
61
|
+
if (!cacheReadTokens) return 0;
|
|
62
|
+
const p = getPrice(model);
|
|
63
|
+
return (cacheReadTokens / 1e6) * (p.in - p.cache_read);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Cross-request compounding savings: reads the run's JSONL entries in sequence order
|
|
67
|
+
// and weights each distilled batch by the exact number of subsequent API calls that
|
|
68
|
+
// carry the smaller result in their conversation history.
|
|
69
|
+
// Formula: Σ (distill_tokens_saved[i] / 1M × price_in × (N - i - 1))
|
|
70
|
+
// Returns { savings: float, carryInstances: int }
|
|
71
|
+
// carryInstances = total sum of subsequent-call counts across all distilled batches
|
|
72
|
+
// — the actual multiplier used — so the display is self-explanatory.
|
|
73
|
+
// Assumption: tool results accumulate in Claude Code's message history for all
|
|
74
|
+
// subsequent requests in the session (true for normal sessions; may not hold if
|
|
75
|
+
// Claude Code resets context mid-session).
|
|
76
|
+
function calcCompoundingSavings(runId, logFile, model) {
|
|
77
|
+
if (!runId) return { savings: 0, carryInstances: 0 };
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
const lines = fs.readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean);
|
|
81
|
+
entries = lines
|
|
82
|
+
.map(l => { try { return JSON.parse(l); } catch { return null; } })
|
|
83
|
+
.filter(e => e && e.run_id === runId);
|
|
84
|
+
} catch { return { savings: 0, carryInstances: 0 }; }
|
|
85
|
+
const N = entries.length;
|
|
86
|
+
if (N < 2) return { savings: 0, carryInstances: 0 };
|
|
87
|
+
const p = getPrice(model);
|
|
88
|
+
let savings = 0, carryInstances = 0;
|
|
89
|
+
for (let i = 0; i < N; i++) {
|
|
90
|
+
const dt = entries[i].distill_tokens_saved || 0;
|
|
91
|
+
if (dt > 0) {
|
|
92
|
+
const subsequent = N - i - 1;
|
|
93
|
+
savings += (dt / 1e6) * p.in * subsequent;
|
|
94
|
+
carryInstances += subsequent;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { savings, carryInstances };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
MODEL_PRICES,
|
|
102
|
+
getPrice,
|
|
103
|
+
calcCost,
|
|
104
|
+
calcCacheSavings,
|
|
105
|
+
calcCompoundingSavings,
|
|
106
|
+
};
|