@vimoxshah/tokenflow 1.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.
Files changed (101) hide show
  1. package/CONTRIBUTING.md +84 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/Refresh & Open Dashboard.command +22 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/tokenflow.js +1342 -0
  7. package/docs/architecture.md +193 -0
  8. package/docs/cli.md +390 -0
  9. package/docs/configuration.md +281 -0
  10. package/docs/creating-provider.md +262 -0
  11. package/docs/data-model.md +213 -0
  12. package/docs/getting-started.md +266 -0
  13. package/docs/live-mode.md +199 -0
  14. package/docs/media/architecture-hero.svg +86 -0
  15. package/docs/media/cost-editorial-dark.png +0 -0
  16. package/docs/media/health-terminal-light.png +0 -0
  17. package/docs/media/menubar-dark.png +0 -0
  18. package/docs/media/menubar-light.png +0 -0
  19. package/docs/media/models-terminal-dark.png +0 -0
  20. package/docs/media/overview-aurora-dark.png +0 -0
  21. package/docs/media/time-aurora-light.png +0 -0
  22. package/docs/providers.md +309 -0
  23. package/docs/skill.md +64 -0
  24. package/docs/troubleshooting.md +207 -0
  25. package/examples/config.example.yaml +92 -0
  26. package/examples/demo-data/README.md +38 -0
  27. package/examples/demo-data/sample-usage.csv +11 -0
  28. package/package.json +74 -0
  29. package/scripts/build-dmg.sh +33 -0
  30. package/scripts/build-menubar-app.sh +67 -0
  31. package/scripts/lint.js +111 -0
  32. package/scripts/validate-install.js +140 -0
  33. package/skills/tokenflow/SKILL.md +392 -0
  34. package/skills/tokenflow/examples/config.yaml +92 -0
  35. package/skills/tokenflow/examples/generic-mapping.json +26 -0
  36. package/skills/tokenflow/examples/session-transcript.md +191 -0
  37. package/skills/tokenflow/providers/adapter-template.js +135 -0
  38. package/skills/tokenflow/providers/detection-matrix.md +142 -0
  39. package/skills/tokenflow/schemas/config.schema.json +107 -0
  40. package/skills/tokenflow/schemas/normalized-record.json +63 -0
  41. package/src/analytics/aggregate.js +247 -0
  42. package/src/analytics/anomalies.js +222 -0
  43. package/src/analytics/capacity.js +278 -0
  44. package/src/analytics/comparison.js +96 -0
  45. package/src/analytics/dimensions.js +230 -0
  46. package/src/analytics/efficiency.js +138 -0
  47. package/src/analytics/forecast.js +202 -0
  48. package/src/analytics/index.js +327 -0
  49. package/src/analytics/insights.js +283 -0
  50. package/src/analytics/milestones.js +91 -0
  51. package/src/analytics/peak.js +106 -0
  52. package/src/analytics/productivity.js +166 -0
  53. package/src/analytics/token-usage.js +267 -0
  54. package/src/commands/diagnostics.js +88 -0
  55. package/src/commands/digest.js +155 -0
  56. package/src/commands/models-compare.js +96 -0
  57. package/src/core/budget.js +142 -0
  58. package/src/core/bundle.js +191 -0
  59. package/src/core/config.js +202 -0
  60. package/src/core/delivery.js +109 -0
  61. package/src/core/geo.js +99 -0
  62. package/src/core/ingest.js +457 -0
  63. package/src/core/interface-map.js +55 -0
  64. package/src/core/jsonl.js +124 -0
  65. package/src/core/live-status.js +417 -0
  66. package/src/core/model-map.js +157 -0
  67. package/src/core/notify.js +83 -0
  68. package/src/core/pricing.js +288 -0
  69. package/src/core/prompt-analytics.js +127 -0
  70. package/src/core/registry.js +107 -0
  71. package/src/core/restore.js +261 -0
  72. package/src/core/schedule.js +120 -0
  73. package/src/core/schema.js +316 -0
  74. package/src/core/sqlite.js +96 -0
  75. package/src/core/store.js +493 -0
  76. package/src/core/sync.js +151 -0
  77. package/src/core/units.js +147 -0
  78. package/src/core/validate.js +123 -0
  79. package/src/core/watch.js +287 -0
  80. package/src/core/yaml.js +209 -0
  81. package/src/export/bundler.js +107 -0
  82. package/src/export/csv.js +100 -0
  83. package/src/export/html-snapshot.js +101 -0
  84. package/src/export/menubar.js +158 -0
  85. package/src/index.js +18 -0
  86. package/src/providers/anthropic/index.js +294 -0
  87. package/src/providers/cline/index.js +120 -0
  88. package/src/providers/cursor/index.js +143 -0
  89. package/src/providers/generic/index.js +268 -0
  90. package/src/providers/git/index.js +188 -0
  91. package/src/providers/headroom/index.js +114 -0
  92. package/src/providers/hermes/index.js +299 -0
  93. package/src/providers/mock/index.js +117 -0
  94. package/src/providers/openai/index.js +370 -0
  95. package/src/providers/opencode/index.js +245 -0
  96. package/src/sdk.js +46 -0
  97. package/src/server/server.js +264 -0
  98. package/src/ui/app.js +2473 -0
  99. package/src/ui/charts.js +925 -0
  100. package/src/ui/index.html +42 -0
  101. package/src/ui/styles.css +644 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Provider + model-family classification.
3
+ *
4
+ * Rules are DATA, not code paths: the list below is the built-in ruleset and
5
+ * a user's `~/.tokenflow/config.yaml` can prepend their own rules
6
+ * (`modelMappings:`) without touching the dashboard. A model that matches
7
+ * nothing becomes provider `unknown` with its raw string preserved — it is
8
+ * never bucketed into a plausible-looking vendor.
9
+ */
10
+
11
+ /** @typedef {{match: string, provider: string, label: string, family?: string, familyFrom?: string[]}} ModelRule */
12
+
13
+ /** @type {ModelRule[]} */
14
+ export const BUILTIN_MODEL_RULES = [
15
+ {
16
+ match: '(^|/)claude|^anthropic\\.|claude-instant',
17
+ provider: 'anthropic',
18
+ label: 'Anthropic',
19
+ familyFrom: ['opus', 'sonnet', 'haiku'],
20
+ },
21
+ {
22
+ match: '(^|/)(gpt|chatgpt|o[1345](-|$)|codex|text-davinci|davinci)',
23
+ provider: 'openai',
24
+ label: 'OpenAI',
25
+ },
26
+ { match: 'deepseek', provider: 'deepseek', label: 'DeepSeek' },
27
+ { match: '(^|/)(glm|charglm|cogview)|z-?ai', provider: 'zai', label: 'Z.ai / GLM' },
28
+ { match: '(^|/)(gemini|gemma|palm|bison)', provider: 'google', label: 'Google' },
29
+ { match: '(^|/)(qwen|qwq)', provider: 'alibaba', label: 'Alibaba / Qwen' },
30
+ { match: 'kimi|moonshot', provider: 'moonshot', label: 'Moonshot / Kimi' },
31
+ { match: '(^|/)grok', provider: 'xai', label: 'xAI / Grok' },
32
+ { match: '(^|/)(llama|codellama)', provider: 'meta', label: 'Meta / Llama' },
33
+ { match: '(^|/)(mistral|mixtral|codestral|magistral)', provider: 'mistral', label: 'Mistral' },
34
+ { match: '(^|/)(command-?r?|cohere)', provider: 'cohere', label: 'Cohere' },
35
+ { match: '(^|/)(nova)|^amazon\\.', provider: 'amazon', label: 'Amazon / Nova' },
36
+ { match: '(^|/)(phi-|orca)', provider: 'microsoft', label: 'Microsoft' },
37
+ { match: '(^|/)composer-', provider: 'cursor', label: 'Cursor' },
38
+ // Gateways publish free/preview models under their own namespace; the slug
39
+ // prefix is the publisher's own attribution, same evidence as any vendor
40
+ // prefix above.
41
+ { match: '(^|/)minimax|(^|/)abab', provider: 'minimax', label: 'MiniMax' },
42
+ { match: 'upstage|(^|/)solar', provider: 'upstage', label: 'Upstage' },
43
+ { match: 'tencent|hunyuan|(^|/)hy\\d', provider: 'tencent', label: 'Tencent / Hunyuan' },
44
+ { match: '^nvidia|(^|/)nemotron', provider: 'nvidia', label: 'NVIDIA' },
45
+ { match: 'xiaomi|(^|/)(mi-?)?mimo', provider: 'xiaomi', label: 'Xiaomi / MiMo' },
46
+ // OpenRouter's own house/stealth series ("openrouter/owl-alpha"). This must
47
+ // stay AFTER the vendor rules: "openrouter/deepseek/v3" is DeepSeek, and
48
+ // only slugs no vendor rule can identify fall back to the namespace owner.
49
+ { match: '(^|/)openrouter/', provider: 'openrouter', label: 'OpenRouter' },
50
+ // OpenCode's own gateway catalog (served through the opencode adapter):
51
+ // the "x" preview family and the muse-spark community series. These slugs
52
+ // are published by OpenCode itself, so attribution follows the publisher.
53
+ { match: '(^|/)x-preview(-|$)', provider: 'opencode', label: 'OpenCode' },
54
+ { match: '(^|/)muse-spark', provider: 'opencode', label: 'OpenCode' },
55
+ // Hermes-routed stealth/preview models ("stealth/<name>"): these arrive via
56
+ // the Nous/openrouter gateways and carry no vendor slug of their own, but
57
+ // "unknown" hides real usage from the provider breakdown. Attribute to the
58
+ // nous gateway family so the UI shows where the traffic actually ran.
59
+ { match: '(^|/)stealth/', provider: 'nous', label: 'Nous (stealth)' },
60
+ // Billing-layer fallbacks from adapters: "@<gateway>" means the model slug
61
+ // was never recorded but the billing layer knows who served the call.
62
+ { match: '^@nous$', provider: 'nous', label: 'Nous' },
63
+ { match: '^@openrouter$', provider: 'openrouter', label: 'OpenRouter' },
64
+ // OpenRouter free-tier catalogues expose additional house/stealth slugs
65
+ // ("stepfun/step-*:free" and friends) that no vendor rule catches; route
66
+ // them to the namespace owner instead of "unknown".
67
+ { match: '(^|/)stepfun/', provider: 'stepfun', label: 'StepFun' },
68
+ ];
69
+
70
+ const compiled = new WeakMap();
71
+
72
+ function compile(rules) {
73
+ let c = compiled.get(rules);
74
+ if (!c) {
75
+ c = rules.map((r) => ({ ...r, re: new RegExp(r.match, 'i') }));
76
+ compiled.set(rules, c);
77
+ }
78
+ return c;
79
+ }
80
+
81
+ /**
82
+ * @param {string|null} raw model string as reported by the source
83
+ * @param {{rules?: ModelRule[], providerHint?: string|null}} [opt]
84
+ * providerHint is used ONLY when the model string matches nothing — a hint
85
+ * never overrides evidence from the model name itself.
86
+ * @returns {{provider:string, provider_label:string, model:string, model_family:string}}
87
+ */
88
+ export function classifyModel(raw, opt = {}) {
89
+ const rules = compile(opt.rules ?? BUILTIN_MODEL_RULES);
90
+ const model = raw && String(raw).trim() ? String(raw).trim() : 'unknown';
91
+ if (model === 'unknown') {
92
+ return {
93
+ provider: opt.providerHint || 'unknown',
94
+ provider_label: opt.providerHint ? titleize(opt.providerHint) : 'Unknown',
95
+ model,
96
+ model_family: 'Unknown',
97
+ };
98
+ }
99
+
100
+ for (const r of rules) {
101
+ if (!r.re.test(model)) continue;
102
+ return {
103
+ provider: r.provider,
104
+ provider_label: r.label,
105
+ model,
106
+ model_family: r.family ?? familyOf(model, r),
107
+ };
108
+ }
109
+ return {
110
+ provider: opt.providerHint || 'unknown',
111
+ provider_label: opt.providerHint ? titleize(opt.providerHint) : 'Unknown',
112
+ model,
113
+ model_family: opt.providerHint ? `${titleize(opt.providerHint)} (unmapped)` : 'Unmapped',
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Family label: the human-meaningful tier, with build dates and vendor
119
+ * prefixes stripped, so `claude-opus-4-5-20260101` and `claude-opus-5` land in
120
+ * comparable buckets without us pretending to know a version taxonomy.
121
+ */
122
+ function familyOf(model, rule) {
123
+ const m = model.toLowerCase();
124
+ if (rule.familyFrom) {
125
+ for (const tier of rule.familyFrom) {
126
+ if (m.includes(tier)) {
127
+ const ver = m.match(new RegExp(`${tier}[-_]?(\\d+(?:[.-]\\d+)?)`)) || m.match(new RegExp(`(\\d+(?:[.-]\\d+)?)[-_]?${tier}`));
128
+ const v = ver ? ' ' + ver[1].replace('-', '.') : '';
129
+ return titleize(rule.provider === 'anthropic' ? 'Claude ' + tier : tier) + v;
130
+ }
131
+ }
132
+ }
133
+ // Strip a vendor prefix ("openrouter/x/y" -> "y"), then a trailing date stamp.
134
+ let base = m.split('/').pop();
135
+ base = base.replace(/[-_](\d{8}|\d{4}-\d{2}-\d{2}|latest|preview|exp)$/g, '');
136
+ // Collapse a trailing variant suffix (gpt-5.6-sol -> gpt-5.6) but keep it as
137
+ // a distinct family when it is the only distinguishing token.
138
+ const generation = base.match(/^([a-z]+[-_]?\d+(?:\.\d+)?)/);
139
+ if (generation) {
140
+ const suffix = base.slice(generation[1].length).replace(/^[-_]/, '');
141
+ const g = titleize(generation[1]);
142
+ return suffix ? `${g} (${suffix})` : g;
143
+ }
144
+ return titleize(base);
145
+ }
146
+
147
+ function titleize(s) {
148
+ return String(s)
149
+ .replace(/[-_]/g, ' ')
150
+ .replace(/\b([a-z])/g, (m) => m.toUpperCase())
151
+ .replace(/\bGpt\b/g, 'GPT')
152
+ .replace(/\bGlm\b/g, 'GLM')
153
+ .replace(/\bAi\b/g, 'AI')
154
+ .trim();
155
+ }
156
+
157
+ export { titleize };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Best-effort OS notifications.
3
+ *
4
+ * TokenFlow's privacy model forbids network calls, so alerts surface through
5
+ * whatever the operating system already provides: osascript on macOS,
6
+ * notify-send on Linux, a toast via PowerShell on Windows 10/11. Everything
7
+ * here is fire-and-forget: a missing or failing notifier degrades to silence
8
+ * (the status file still records the alert), never to a crash or a hang.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+
12
+ /** Escape a string for an AppleScript double-quoted literal. */
13
+ export function appleScriptEscape(s) {
14
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
15
+ }
16
+
17
+ /** Escape for XML text nodes (PowerShell toast content travels as XML). */
18
+ export function xmlEscape(s) {
19
+ return String(s)
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;');
23
+ }
24
+
25
+ /**
26
+ * Show a notification. Resolves regardless of delivery outcome.
27
+ * @param {{title:string, body?:string}} p
28
+ */
29
+ export function notify({ title, body = '' }) {
30
+ const t = String(title).slice(0, 120);
31
+ const b = String(body).slice(0, 240);
32
+ try {
33
+ switch (process.platform) {
34
+ case 'darwin': {
35
+ const script = `display notification "${appleScriptEscape(b)}" with title "${appleScriptEscape(t)}"`;
36
+ return spawnDetached('osascript', ['-e', script]);
37
+ }
38
+ case 'win32': {
39
+ // EncodedCommand sidesteps every quoting hazard between cmd, PowerShell
40
+ // and the XML payload. The well-known PowerShell AppId is what makes
41
+ // the toast appear on systems where "TokenFlow" itself has no identity.
42
+ const ps = `
43
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
44
+ $xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
45
+ $xml.GetElementsByTagName('text').Item(0).AppendChild($xml.CreateTextNode('${xmlEscape(t)}')) | Out-Null
46
+ $xml.GetElementsByTagName('text').Item(1).AppendChild($xml.CreateTextNode('${xmlEscape(b)}')) | Out-Null
47
+ $app = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
48
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show([Windows.UI.Notifications.ToastNotification]::new($xml))
49
+ `;
50
+ const encoded = Buffer.from(ps, 'utf16le').toString('base64');
51
+ return spawnDetached('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded]);
52
+ }
53
+ default: {
54
+ // Linux and the BSDs: the freedesktop desktop-notification spec.
55
+ return spawnDetached('notify-send', ['-a', 'TokenFlow', t, b]);
56
+ }
57
+ }
58
+ } catch (err) {
59
+ // Notifications are strictly additive; a broken notifier must never take
60
+ // the watch daemon down. The transition is still recorded in status.json.
61
+ return Promise.resolve({ delivered: false, reason: err.message });
62
+ }
63
+ }
64
+
65
+ function spawnDetached(cmd, args) {
66
+ return new Promise((resolve) => {
67
+ try {
68
+ const child = spawn(cmd, args, {
69
+ detached: true,
70
+ stdio: 'ignore',
71
+ windowsHide: true,
72
+ });
73
+ child.once('error', (err) => resolve({ delivered: false, reason: err.message }));
74
+ child.once('spawn', () => resolve({ delivered: true }));
75
+ child.unref();
76
+ // Belt and braces: a notifier that never emits either event must not
77
+ // hold the event loop open.
78
+ setTimeout(() => resolve({ delivered: false, reason: 'timeout' }), 3000).unref();
79
+ } catch (err) {
80
+ resolve({ delivered: false, reason: err.message });
81
+ }
82
+ });
83
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Cost estimation.
3
+ *
4
+ * ## Hard rules
5
+ *
6
+ * 1. **Never invent a price.** A model with no entry and no user override
7
+ * produces `estimated_cost: null` and is listed in the "pricing not
8
+ * configured" report, ranked by token volume. A `$0` is only ever a real
9
+ * measured zero.
10
+ * 2. **Every rate has a source.** Each entry names the `PRICING_SOURCES` key it
11
+ * came from, so `tokenflow pricing --sources` can show where a number came
12
+ * from and when it was fetched. Third-party sources are marked as such.
13
+ * 3. **Estimated is labelled estimated.** A cost that a source actually billed
14
+ * (a gateway's own `cost_usd`) is `measured` and kept in its own column.
15
+ *
16
+ * ## Service tiers matter more than people expect
17
+ *
18
+ * OpenAI's Fast mode (renamed from "priority" on 2026-07-30) bills at **4x**
19
+ * the standard rate. Anthropic's Batch API bills at 0.5x. Both adapters record
20
+ * `metadata.service_tier`, so the estimate applies the right multiplier per
21
+ * request instead of quietly assuming everything was standard-rate.
22
+ *
23
+ * ## Known limitation: long-context tiers
24
+ *
25
+ * Several vendors charge a premium above a context threshold (Anthropic above
26
+ * 200K, OpenAI's "long context" rows). Applying that needs a per-request prompt
27
+ * size AND a per-model threshold and premium, which are not uniformly
28
+ * published. This table uses the short-context rate, so a long-context-heavy
29
+ * workload is UNDER-estimated. The UI says so rather than pretending
30
+ * otherwise.
31
+ *
32
+ * Rates are USD per 1,000,000 tokens.
33
+ */
34
+
35
+ export const PRICING_TABLE_VERSION = '2026-08-20';
36
+
37
+ /** Where each group of rates came from. Surfaced by `tokenflow pricing --sources`. */
38
+ export const PRICING_SOURCES = {
39
+ anthropic: {
40
+ url: 'https://platform.claude.com/docs/en/about-claude/pricing',
41
+ fetched: '2026-08-20',
42
+ confidence: 'official',
43
+ },
44
+ openai: {
45
+ url: 'https://developers.openai.com/api/docs/pricing',
46
+ fetched: '2026-08-20',
47
+ confidence: 'official',
48
+ },
49
+ 'openai-thirdparty': {
50
+ url: 'https://openrouter.ai/openai/gpt-5.5',
51
+ fetched: '2026-08-20',
52
+ confidence: 'third-party',
53
+ note: 'gpt-5.5 is no longer on OpenAI\'s own pricing page; this mirrors its baseline rate.',
54
+ },
55
+ deepseek: {
56
+ url: 'https://api-docs.deepseek.com/quick_start/pricing',
57
+ fetched: '2026-08-20',
58
+ confidence: 'official',
59
+ note: 'DeepSeek charges peak (01:00-04:00 and 06:00-10:00 UTC) at 2x off-peak. These are OFF-PEAK rates, so a peak-heavy workload is under-estimated.',
60
+ },
61
+ zai: {
62
+ url: 'https://docs.z.ai/guides/overview/pricing',
63
+ fetched: '2026-08-20',
64
+ confidence: 'official',
65
+ },
66
+ google: {
67
+ url: 'https://ai.google.dev/gemini-api/docs/pricing',
68
+ fetched: '2026-08-20',
69
+ confidence: 'official',
70
+ note: 'Flash/Pro rates vary above a 200K prompt; the short-context rate is used.',
71
+ },
72
+ legacy: {
73
+ url: 'https://platform.claude.com/docs/en/about-claude/pricing',
74
+ fetched: '2026-08-20',
75
+ confidence: 'official-historical',
76
+ note: 'Retired models, kept so historical records still cost out.',
77
+ },
78
+ };
79
+
80
+ /**
81
+ * Published list prices. First regex match wins, so specific patterns come
82
+ * before general ones.
83
+ *
84
+ * `cacheWrite` is the short-TTL (5-minute) write rate; `cacheRefresh` is the
85
+ * long-TTL (1-hour) rate applied to the `cache_refresh_tokens` subset.
86
+ *
87
+ * @type {{match:string,in:number,out:number,cacheRead?:number,cacheWrite?:number,cacheRefresh?:number,src:string}[]}
88
+ */
89
+ export const BUILTIN_PRICES = [
90
+ // ---- Anthropic — platform.claude.com, fetched 2026-08-20 ----------------
91
+ { match: 'claude-(fable|mythos)-5', in: 10, out: 50, cacheRead: 1, cacheWrite: 12.5, cacheRefresh: 20, src: 'anthropic' },
92
+ { match: 'claude-opus-4-(1|0)|claude-4-1-opus|claude-4-opus', in: 15, out: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheRefresh: 30, src: 'legacy' },
93
+ { match: 'claude-opus-(5|4-8|4-7|4-6|4-5)', in: 5, out: 25, cacheRead: 0.5, cacheWrite: 6.25, cacheRefresh: 10, src: 'anthropic' },
94
+ { match: 'claude-sonnet-5', in: 2, out: 10, cacheRead: 0.2, cacheWrite: 2.5, cacheRefresh: 4, src: 'anthropic' },
95
+ { match: 'claude-sonnet-4-(6|5)|claude-sonnet-4($|-2)', in: 3, out: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheRefresh: 6, src: 'anthropic' },
96
+ { match: 'claude-haiku-4-5|claude-4-5-haiku', in: 1, out: 5, cacheRead: 0.1, cacheWrite: 1.25, cacheRefresh: 2, src: 'anthropic' },
97
+ { match: 'claude-(3-5-haiku|haiku-3-5)', in: 0.8, out: 4, cacheRead: 0.08, cacheWrite: 1, cacheRefresh: 1.6, src: 'legacy' },
98
+ // Older generations, so records predating the current line-up still cost out.
99
+ { match: 'claude-3-opus', in: 15, out: 75, cacheRead: 1.5, cacheWrite: 18.75, cacheRefresh: 30, src: 'legacy' },
100
+ { match: 'claude-3-haiku', in: 0.25, out: 1.25, cacheRead: 0.03, cacheWrite: 0.3, cacheRefresh: 0.5, src: 'legacy' },
101
+ { match: 'claude-3-(5|7)-sonnet', in: 3, out: 15, cacheRead: 0.3, cacheWrite: 3.75, cacheRefresh: 6, src: 'legacy' },
102
+
103
+ // ---- OpenAI — developers.openai.com, fetched 2026-08-20 -----------------
104
+ // Cache writes are not billed separately; cached input is the read rate.
105
+ { match: '^gpt-5\\.6-sol', in: 2.5, out: 15, cacheRead: 0.25, cacheWrite: 0, cacheRefresh: 0, src: 'openai' },
106
+ { match: '^gpt-5\\.6-terra', in: 1, out: 6, cacheRead: 0.1, cacheWrite: 0, cacheRefresh: 0, src: 'openai' },
107
+ { match: '^gpt-5\\.6-luna', in: 0.1, out: 0.6, cacheRead: 0.01, cacheWrite: 0, cacheRefresh: 0, src: 'openai' },
108
+ { match: '^gpt-5\\.3-codex|^gpt-5-codex', in: 1.75, out: 14, cacheRead: 0.175, cacheWrite: 0, cacheRefresh: 0, src: 'openai' },
109
+ { match: '^chat-latest', in: 5, out: 30, cacheRead: 0.5, cacheWrite: 0, cacheRefresh: 0, src: 'openai' },
110
+ { match: '^gpt-5\\.5', in: 5, out: 30, cacheRead: 0.5, cacheWrite: 0, cacheRefresh: 0, src: 'openai-thirdparty' },
111
+ // Older OpenAI generations.
112
+ { match: '^gpt-4o-mini', in: 0.15, out: 0.6, cacheRead: 0.075, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
113
+ { match: '^gpt-4o', in: 2.5, out: 10, cacheRead: 1.25, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
114
+ { match: '^gpt-4\\.1-nano', in: 0.1, out: 0.4, cacheRead: 0.025, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
115
+ { match: '^gpt-4\\.1-mini', in: 0.4, out: 1.6, cacheRead: 0.1, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
116
+ { match: '^gpt-4\\.1', in: 2, out: 8, cacheRead: 0.5, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
117
+ { match: '^gpt-5-nano', in: 0.05, out: 0.4, cacheRead: 0.005, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
118
+ { match: '^gpt-5-mini', in: 0.25, out: 2, cacheRead: 0.025, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
119
+ { match: '^gpt-5($|[^.\\d])', in: 1.25, out: 10, cacheRead: 0.125, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
120
+ { match: '^o4-mini', in: 1.1, out: 4.4, cacheRead: 0.275, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
121
+ { match: '^o3-mini', in: 1.1, out: 4.4, cacheRead: 0.55, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
122
+ { match: '^o3', in: 2, out: 8, cacheRead: 0.5, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
123
+
124
+ // ---- DeepSeek — api-docs.deepseek.com, fetched 2026-08-20 (OFF-PEAK) ----
125
+ { match: 'deepseek-v4-flash', in: 0.22, out: 0.66, cacheRead: 0.007, cacheWrite: 0, cacheRefresh: 0, src: 'deepseek' },
126
+ { match: 'deepseek-v4-pro|deepseek-v4($|[^-])', in: 0.66, out: 1.98, cacheRead: 0.022, cacheWrite: 0, cacheRefresh: 0, src: 'deepseek' },
127
+ { match: 'deepseek-(chat|v3)', in: 0.27, out: 1.1, cacheRead: 0.07, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
128
+ { match: 'deepseek-(reasoner|r1)', in: 0.55, out: 2.19, cacheRead: 0.14, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
129
+
130
+ // ---- Z.ai / GLM — docs.z.ai, fetched 2026-08-20 -------------------------
131
+ { match: '^glm-5\\.(3|2|1)', in: 1.4, out: 4.4, cacheRead: 0.26, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
132
+ { match: '^glm-5-turbo', in: 1.2, out: 4, cacheRead: 0.24, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
133
+ { match: '^glm-5($|[^.\\d-])', in: 1, out: 3.2, cacheRead: 0.2, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
134
+ { match: '^glm-4\\.7-flashx', in: 0.07, out: 0.4, cacheRead: 0.01, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
135
+ { match: '^glm-4\\.(7|6|5)-flash', in: 0, out: 0, cacheRead: 0, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
136
+ { match: '^glm-4\\.5-airx', in: 1.1, out: 4.5, cacheRead: 0.22, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
137
+ { match: '^glm-4\\.5-air', in: 0.2, out: 1.1, cacheRead: 0.03, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
138
+ { match: '^glm-4\\.5-x', in: 2.2, out: 8.9, cacheRead: 0.45, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
139
+ { match: '^glm-4\\.(7|6|5)', in: 0.6, out: 2.2, cacheRead: 0.11, cacheWrite: 0, cacheRefresh: 0, src: 'zai' },
140
+
141
+ // ---- Google — ai.google.dev, fetched 2026-08-20 (short context) ---------
142
+ { match: 'gemini-3\\.(7|6)-flash', in: 0.75, out: 3.75, cacheRead: 0.075, cacheWrite: 0, cacheRefresh: 0, src: 'google' },
143
+ { match: 'gemini-3\\.5-flash-lite', in: 0.3, out: 2.5, cacheRead: 0.03, cacheWrite: 0, cacheRefresh: 0, src: 'google' },
144
+ { match: 'gemini-3\\.5-flash', in: 1.5, out: 9, cacheRead: 0.15, cacheWrite: 0, cacheRefresh: 0, src: 'google' },
145
+ { match: 'gemini-3\\.1-flash-lite', in: 0.25, out: 1.5, cacheRead: 0.025, cacheWrite: 0, cacheRefresh: 0, src: 'google' },
146
+ { match: 'gemini-2\\.5-pro', in: 1.25, out: 10, cacheRead: 0.125, cacheWrite: 0, cacheRefresh: 0, src: 'google' },
147
+ { match: 'gemini-2\\.5-flash-lite', in: 0.1, out: 0.4, cacheRead: 0.01, cacheWrite: 0, cacheRefresh: 0, src: 'google' },
148
+ { match: 'gemini-2\\.5-flash', in: 0.3, out: 2.5, cacheRead: 0.03, cacheWrite: 0, cacheRefresh: 0, src: 'google' },
149
+ { match: 'gemini-2\\.0-flash-lite', in: 0.075, out: 0.3, cacheRead: 0.01875, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
150
+ { match: 'gemini-2\\.0-flash', in: 0.1, out: 0.4, cacheRead: 0.025, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
151
+ { match: 'gemini-1\\.5-flash', in: 0.075, out: 0.3, cacheRead: 0.01875, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
152
+ { match: 'gemini-1\\.5-pro', in: 1.25, out: 5, cacheRead: 0.3125, cacheWrite: 0, cacheRefresh: 0, src: 'legacy' },
153
+ ];
154
+
155
+ /**
156
+ * Service-tier multipliers, applied to every rate for a request.
157
+ *
158
+ * OpenAI: Fast mode (renamed from "priority" on 2026-07-30) is 4x standard;
159
+ * Batch and Flex are billed at standard rates.
160
+ * Anthropic: the Batch API is 0.5x.
161
+ *
162
+ * An unrecognised tier is treated as 1x — the standard rate — rather than
163
+ * guessed at.
164
+ */
165
+ export const TIER_MULTIPLIERS = {
166
+ openai: { priority: 4, fast: 4, standard: 1, default: 1, auto: 1, batch: 1, flex: 1, scale: 1 },
167
+ anthropic: { batch: 0.5, standard: 1, default: 1, auto: 1, priority: 1 },
168
+ _default: { batch: 0.5, standard: 1, default: 1, auto: 1 },
169
+ };
170
+
171
+ /**
172
+ * @param {string|null} tier value of `metadata.service_tier`
173
+ * @param {string} provider
174
+ * @returns {{mult:number, known:boolean, tier:string|null}}
175
+ */
176
+ export function tierMultiplier(tier, provider) {
177
+ if (!tier) return { mult: 1, known: false, tier: null };
178
+ const t = String(tier).toLowerCase();
179
+ const table = TIER_MULTIPLIERS[provider] || TIER_MULTIPLIERS._default;
180
+ if (Object.prototype.hasOwnProperty.call(table, t)) return { mult: table[t], known: true, tier: t };
181
+ return { mult: 1, known: false, tier: t };
182
+ }
183
+
184
+ /**
185
+ * Family fallback multipliers, used ONLY to derive a cache rate when an entry
186
+ * has an input/output price but no explicit cache rate. Never used to invent an
187
+ * input or output price. An absent multiplier is unknown, which is null.
188
+ */
189
+ const CACHE_MULTIPLIERS = {
190
+ anthropic: { read: 0.1, write: 1.25, refresh: 2.0 },
191
+ openai: { read: 0.1, write: 0, refresh: 0 },
192
+ deepseek: { read: 0.26, write: 0, refresh: 0 },
193
+ google: { read: 0.25, write: 0, refresh: 0 },
194
+ zai: { read: 0.2, write: 0, refresh: 0 },
195
+ _default: { read: null, write: null, refresh: null },
196
+ };
197
+
198
+ function mult(v, base) {
199
+ if (v === null || v === undefined) return null;
200
+ return base * v;
201
+ }
202
+
203
+ export function buildPriceBook(userPricing = {}) {
204
+ const user = Object.entries(userPricing.models || {}).map(([match, v]) => ({
205
+ match: v.match || `^${escapeRe(match)}$`,
206
+ key: match,
207
+ in: num(v.in ?? v.input),
208
+ out: num(v.out ?? v.output),
209
+ cacheRead: num(v.cacheRead ?? v.cache_read),
210
+ cacheWrite: num(v.cacheWrite ?? v.cache_write),
211
+ cacheRefresh: num(v.cacheRefresh ?? v.cache_refresh),
212
+ src: 'user',
213
+ origin: 'user',
214
+ }));
215
+ const builtin = BUILTIN_PRICES.map((p) => ({ ...p, origin: 'builtin' }));
216
+ const all = [...user, ...builtin].map((p) => ({ ...p, re: new RegExp(p.match, 'i') }));
217
+ return {
218
+ version: PRICING_TABLE_VERSION,
219
+ sources: PRICING_SOURCES,
220
+ entries: all,
221
+ lookup(model, provider) {
222
+ if (!model) return null;
223
+ for (const e of all) {
224
+ if (!e.re.test(model)) continue;
225
+ if (e.in === null || e.out === null || e.in === undefined || e.out === undefined) continue;
226
+ const m = CACHE_MULTIPLIERS[provider] || CACHE_MULTIPLIERS._default;
227
+ return {
228
+ in: e.in,
229
+ out: e.out,
230
+ cacheRead: e.cacheRead ?? mult(m.read, e.in),
231
+ cacheWrite: e.cacheWrite ?? mult(m.write, e.in),
232
+ cacheRefresh: e.cacheRefresh ?? mult(m.refresh, e.in),
233
+ origin: e.origin,
234
+ src: e.src,
235
+ match: e.match,
236
+ };
237
+ }
238
+ return null;
239
+ },
240
+ };
241
+ }
242
+
243
+ /**
244
+ * @param {object} tok token fields
245
+ * @param {string} model
246
+ * @param {string} provider
247
+ * @param {ReturnType<typeof buildPriceBook>} book
248
+ * @param {{tier?:string|null}} [opt]
249
+ * @returns {{cost:number|null, basis:'estimated'|null, partial:boolean, tier:string|null, tierMult:number, src:string|null}}
250
+ */
251
+ export function estimateCost(tok, model, provider, book, opt = {}) {
252
+ const p = book.lookup(model, provider);
253
+ if (!p) return { cost: null, basis: null, partial: false, tier: null, tierMult: 1, src: null };
254
+
255
+ const { mult: tm, tier } = tierMultiplier(opt.tier ?? null, provider);
256
+ let cost = 0;
257
+ let partial = false;
258
+ const add = (tokens, rate) => {
259
+ if (tokens === null || tokens === undefined) return;
260
+ if (rate === null || rate === undefined || !Number.isFinite(rate)) {
261
+ if (tokens > 0) partial = true;
262
+ return;
263
+ }
264
+ cost += (tokens / 1e6) * rate * tm;
265
+ };
266
+ add(tok.input_tokens, p.in);
267
+ add(tok.output_tokens, p.out);
268
+ add(tok.cache_read_tokens, p.cacheRead);
269
+ // Cache writes split into the long-TTL (refresh) subset and the rest, because
270
+ // the two are billed at different rates.
271
+ const refresh = tok.cache_refresh_tokens ?? null;
272
+ const write = tok.cache_write_tokens ?? null;
273
+ if (refresh !== null && write !== null && p.cacheRefresh !== null) {
274
+ add(Math.max(0, write - refresh), p.cacheWrite);
275
+ add(refresh, p.cacheRefresh);
276
+ } else {
277
+ add(write, p.cacheWrite);
278
+ }
279
+ if (!Number.isFinite(cost)) return { cost: null, basis: null, partial: true, tier, tierMult: tm, src: p.src };
280
+ return { cost, basis: 'estimated', partial, tier, tierMult: tm, src: p.src ?? null };
281
+ }
282
+
283
+ function num(v) {
284
+ return v === undefined || v === null || v === '' || Number.isNaN(Number(v)) ? null : Number(v);
285
+ }
286
+ function escapeRe(s) {
287
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
288
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Prompt-level analytics — strictly opt-in, privacy-preserving by design.
3
+ *
4
+ * DEFAULT STATE: OFF. No adapter captures prompts unless
5
+ * promptAnalytics:
6
+ * enabled: true
7
+ * storeRaw: false # raw content stays OFF unless separately opted in
8
+ * is present in config.yaml.
9
+ *
10
+ * What is captured when enabled (per request):
11
+ * - sha256(prompt)[:16] — a one-way hash; cannot be reversed to the prompt
12
+ * - category — heuristic keyword classifier (code-review,
13
+ * debug, test, docs, architecture, other)
14
+ * - model, tokens, cost — already collected by every adapter
15
+ *
16
+ * What is NEVER captured: raw prompt text (unless storeRaw: true), file
17
+ * contents, environment variables, credentials.
18
+ *
19
+ * Repeated-prompt detection falls out of the hash: identical normalized
20
+ * prompts share a hash across days/models.
21
+ */
22
+ import crypto from 'node:crypto';
23
+
24
+ export const CATEGORIES = [
25
+ ['code-review', /\b(review|pr|pull.request|diff|audit)\b/i],
26
+ ['debugging', /\b(debug|error|bug|fix|stack.?trace|exception|failing)\b/i],
27
+ ['testing', /\b(test|spec|coverage|jest|vitest|pytest)\b/i],
28
+ ['documentation', /\b(doc|readme|comment|javadoc|explain this code)/i],
29
+ ['architecture', /\b(architect|design|refactor|structure|pattern|migrate)\b/i],
30
+ ];
31
+ /** Type note for tsc: entries are [label, RegExp]. */
32
+ const CATEGORY_RULES = CATEGORIES;
33
+
34
+ export function normalizePrompt(text) {
35
+ return String(text || '')
36
+ .toLowerCase()
37
+ .replace(/\s+/g, ' ')
38
+ .replace(/[^\w ]/g, '')
39
+ .trim()
40
+ .slice(0, 2000);
41
+ }
42
+
43
+ /** One-way hash. Short prefix only — collisions acceptable for analytics. */
44
+ export function hashPrompt(text) {
45
+ return crypto.createHash('sha256').update(normalizePrompt(text)).digest('hex').slice(0, 16);
46
+ }
47
+
48
+ export function categorize(text) {
49
+ const n = normalizePrompt(text);
50
+ for (const [cat, re] of CATEGORY_RULES) if (re instanceof RegExp && re.test(n)) return cat;
51
+ return 'uncategorized';
52
+ }
53
+
54
+ /**
55
+ * The record adapters may emit when prompt analytics is enabled.
56
+ * Returns null fields when input has no prompt text (e.g. cache-only rows).
57
+ */
58
+ /**
59
+ * @param {{promptText?: string, model?: string|null, tokens?: number|null, cost?: number|null, timestamp?: string|null}} args
60
+ */
61
+ export function makePromptRecord({ promptText, model, tokens, cost, timestamp } = {}) {
62
+ if (!promptText) return null;
63
+ return {
64
+ promptHash: hashPrompt(promptText),
65
+ category: categorize(promptText),
66
+ model: model || null,
67
+ tokens: tokens ?? null,
68
+ estCostUsd: cost ?? null,
69
+ timestamp: timestamp || new Date().toISOString(),
70
+ // Raw text is captured ONLY when the user separately opts in.
71
+ ...(this?.storeRaw ? { raw: String(promptText).slice(0, 4000) } : {}),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Aggregate prompt records → the views the gauntlet asks for.
77
+ * @param {Array<{promptHash,category,model,tokens,estCostUsd}>} records
78
+ */
79
+ export function aggregate(records) {
80
+ const byCat = new Map();
81
+ const byHash = new Map();
82
+ let totalTokens = 0;
83
+ let cacheCapable = 0;
84
+
85
+ for (const r of records) {
86
+ const c = byCat.get(r.category) || { requests: 0, tokens: 0, cost: 0 };
87
+ c.requests += 1;
88
+ c.tokens += r.tokens || 0;
89
+ c.cost += r.estCostUsd || 0;
90
+ byCat.set(r.category, c);
91
+
92
+ const h = byHash.get(r.promptHash) || { count: 0, categories: new Set(), models: new Set(), cost: 0 };
93
+ h.count += 1;
94
+ h.categories.add(r.category);
95
+ h.models.add(r.model);
96
+ h.cost += r.estCostUsd || 0;
97
+ byHash.set(r.promptHash, h);
98
+
99
+ totalTokens += r.tokens || 0;
100
+ cacheCapable += 1;
101
+ }
102
+
103
+ const categories = [...byCat.entries()]
104
+ .map(([category, v]) => ({
105
+ category,
106
+ requests: v.requests,
107
+ tokens: v.tokens,
108
+ estCostUsd: Math.round(v.cost * 100) / 100,
109
+ avgCostPerRequest: v.requests ? Math.round((v.cost / v.requests) * 10000) / 10000 : null,
110
+ }))
111
+ .sort((a, b) => b.estCostUsd - a.estCostUsd);
112
+
113
+ const repeats = [...byHash.entries()]
114
+ .filter(([, h]) => h.count > 1)
115
+ .map(([hash, h]) => ({ hash, count: h.count, models: [...h.models] }))
116
+ .sort((a, b) => b.count - a.count)
117
+ .slice(0, 10);
118
+
119
+ return {
120
+ totalPrompts: records.length,
121
+ uniquePrompts: byHash.size,
122
+ repeatRatePct: records.length ? Math.round(((records.length - byHash.size) / records.length) * 1000) / 10 : 0,
123
+ avgTokensPerPrompt: records.length ? Math.round(totalTokens / records.length) : null,
124
+ categories,
125
+ repeats,
126
+ };
127
+ }