@axplusb/kepler 0.0.1 → 1.0.1

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 (218) hide show
  1. package/README.md +82 -0
  2. package/package.json +36 -4
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/costs/route.ts +88 -0
  6. package/pulse/app/api/export/route.ts +77 -0
  7. package/pulse/app/api/history/route.ts +11 -0
  8. package/pulse/app/api/import/route.ts +31 -0
  9. package/pulse/app/api/memory/route.ts +52 -0
  10. package/pulse/app/api/plans/route.ts +9 -0
  11. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  12. package/pulse/app/api/projects/route.ts +121 -0
  13. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  14. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  15. package/pulse/app/api/sessions/route.ts +112 -0
  16. package/pulse/app/api/settings/route.ts +14 -0
  17. package/pulse/app/api/stats/route.ts +143 -0
  18. package/pulse/app/api/todos/route.ts +9 -0
  19. package/pulse/app/api/tools/route.ts +160 -0
  20. package/pulse/app/costs/page.tsx +179 -0
  21. package/pulse/app/export/page.tsx +465 -0
  22. package/pulse/app/favicon.ico +0 -0
  23. package/pulse/app/globals.css +263 -0
  24. package/pulse/app/help/page.tsx +142 -0
  25. package/pulse/app/history/page.tsx +157 -0
  26. package/pulse/app/layout.tsx +46 -0
  27. package/pulse/app/memory/page.tsx +365 -0
  28. package/pulse/app/overview-client.tsx +393 -0
  29. package/pulse/app/page.tsx +14 -0
  30. package/pulse/app/plans/page.tsx +308 -0
  31. package/pulse/app/projects/[slug]/page.tsx +390 -0
  32. package/pulse/app/projects/page.tsx +110 -0
  33. package/pulse/app/sessions/[id]/page.tsx +243 -0
  34. package/pulse/app/sessions/page.tsx +39 -0
  35. package/pulse/app/settings/page.tsx +188 -0
  36. package/pulse/app/todos/page.tsx +211 -0
  37. package/pulse/app/tools/page.tsx +249 -0
  38. package/pulse/cli.js +159 -0
  39. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  40. package/pulse/components/activity/streak-card.tsx +36 -0
  41. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  42. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  43. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  44. package/pulse/components/costs/model-token-table.tsx +60 -0
  45. package/pulse/components/global-search.tsx +193 -0
  46. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  47. package/pulse/components/layout/bottom-nav.tsx +52 -0
  48. package/pulse/components/layout/client-layout.tsx +31 -0
  49. package/pulse/components/layout/sidebar-context.tsx +50 -0
  50. package/pulse/components/layout/sidebar.tsx +182 -0
  51. package/pulse/components/layout/top-bar.tsx +121 -0
  52. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  53. package/pulse/components/overview/conversation-table.tsx +148 -0
  54. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  55. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  56. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  57. package/pulse/components/overview/stat-card.tsx +102 -0
  58. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  59. package/pulse/components/projects/project-card.tsx +175 -0
  60. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  61. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  62. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  63. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  64. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  65. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  66. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  67. package/pulse/components/sessions/session-badges.tsx +49 -0
  68. package/pulse/components/sessions/session-table.tsx +299 -0
  69. package/pulse/components/theme-provider.tsx +44 -0
  70. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  71. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  72. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  73. package/pulse/components/tools/version-history-table.tsx +32 -0
  74. package/pulse/components/ui/alert.tsx +66 -0
  75. package/pulse/components/ui/badge.tsx +48 -0
  76. package/pulse/components/ui/breadcrumb.tsx +109 -0
  77. package/pulse/components/ui/button.tsx +64 -0
  78. package/pulse/components/ui/calendar.tsx +220 -0
  79. package/pulse/components/ui/card.tsx +92 -0
  80. package/pulse/components/ui/command.tsx +158 -0
  81. package/pulse/components/ui/dialog.tsx +158 -0
  82. package/pulse/components/ui/input.tsx +21 -0
  83. package/pulse/components/ui/popover.tsx +89 -0
  84. package/pulse/components/ui/progress.tsx +31 -0
  85. package/pulse/components/ui/select.tsx +190 -0
  86. package/pulse/components/ui/separator.tsx +28 -0
  87. package/pulse/components/ui/sheet.tsx +143 -0
  88. package/pulse/components/ui/skeleton.tsx +13 -0
  89. package/pulse/components/ui/table.tsx +116 -0
  90. package/pulse/components/ui/tabs.tsx +91 -0
  91. package/pulse/components/ui/tooltip.tsx +57 -0
  92. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  93. package/pulse/components.json +23 -0
  94. package/pulse/eslint.config.mjs +18 -0
  95. package/pulse/lib/claude-reader.ts +594 -0
  96. package/pulse/lib/decode.ts +129 -0
  97. package/pulse/lib/pricing.ts +102 -0
  98. package/pulse/lib/replay-parser.ts +165 -0
  99. package/pulse/lib/tool-categories.ts +127 -0
  100. package/pulse/lib/utils.ts +6 -0
  101. package/pulse/next-env.d.ts +6 -0
  102. package/pulse/next.config.ts +16 -0
  103. package/pulse/package.json +45 -0
  104. package/pulse/postcss.config.mjs +7 -0
  105. package/pulse/public/activity.png +0 -0
  106. package/pulse/public/cc-lens.png +0 -0
  107. package/pulse/public/command-k.png +0 -0
  108. package/pulse/public/costs.png +0 -0
  109. package/pulse/public/dashboard-dark.png +0 -0
  110. package/pulse/public/dashboard-white.png +0 -0
  111. package/pulse/public/export.png +0 -0
  112. package/pulse/public/file.svg +1 -0
  113. package/pulse/public/globe.svg +1 -0
  114. package/pulse/public/next.svg +1 -0
  115. package/pulse/public/projects.png +0 -0
  116. package/pulse/public/session-chat.png +0 -0
  117. package/pulse/public/todos.png +0 -0
  118. package/pulse/public/tools.png +0 -0
  119. package/pulse/public/vercel.svg +1 -0
  120. package/pulse/public/window.svg +1 -0
  121. package/pulse/tsconfig.json +34 -0
  122. package/pulse/types/claude.ts +294 -0
  123. package/src/agents/loader.mjs +89 -0
  124. package/src/agents/parser.mjs +98 -0
  125. package/src/agents/teams.mjs +123 -0
  126. package/src/auth/oauth.mjs +220 -0
  127. package/src/auth/tarang-auth.mjs +277 -0
  128. package/src/config/cli-args.mjs +173 -0
  129. package/src/config/env.mjs +263 -0
  130. package/src/config/settings.mjs +132 -0
  131. package/src/context/ast-parser.mjs +298 -0
  132. package/src/context/bm25.mjs +85 -0
  133. package/src/context/retriever.mjs +270 -0
  134. package/src/context/skeleton.mjs +134 -0
  135. package/src/core/agent-loop.mjs +480 -0
  136. package/src/core/approval.mjs +273 -0
  137. package/src/core/backend-url.mjs +57 -0
  138. package/src/core/cache.mjs +105 -0
  139. package/src/core/callback-client.mjs +149 -0
  140. package/src/core/checkpoints.mjs +142 -0
  141. package/src/core/context-manager.mjs +198 -0
  142. package/src/core/headless.mjs +168 -0
  143. package/src/core/hooks-manager.mjs +87 -0
  144. package/src/core/jsonl-writer.mjs +351 -0
  145. package/src/core/local-agent.mjs +429 -0
  146. package/src/core/local-store.mjs +325 -0
  147. package/src/core/mode-selector.mjs +51 -0
  148. package/src/core/output-filter.mjs +177 -0
  149. package/src/core/paths.mjs +101 -0
  150. package/src/core/pricing.mjs +314 -0
  151. package/src/core/providers.mjs +219 -0
  152. package/src/core/rate-limiter.mjs +119 -0
  153. package/src/core/safety.mjs +200 -0
  154. package/src/core/scheduler.mjs +173 -0
  155. package/src/core/session-manager.mjs +317 -0
  156. package/src/core/session.mjs +143 -0
  157. package/src/core/settings-sync.mjs +85 -0
  158. package/src/core/stagnation.mjs +57 -0
  159. package/src/core/stream-client.mjs +367 -0
  160. package/src/core/streaming.mjs +182 -0
  161. package/src/core/system-prompt.mjs +135 -0
  162. package/src/core/tool-executor.mjs +725 -0
  163. package/src/hooks/engine.mjs +162 -0
  164. package/src/index.mjs +370 -0
  165. package/src/mcp/client.mjs +253 -0
  166. package/src/mcp/transport-shttp.mjs +130 -0
  167. package/src/mcp/transport-sse.mjs +131 -0
  168. package/src/mcp/transport-ws.mjs +134 -0
  169. package/src/permissions/checker.mjs +57 -0
  170. package/src/permissions/command-classifier.mjs +573 -0
  171. package/src/permissions/injection-check.mjs +60 -0
  172. package/src/permissions/path-check.mjs +102 -0
  173. package/src/permissions/prompt.mjs +73 -0
  174. package/src/permissions/sandbox.mjs +112 -0
  175. package/src/plugins/loader.mjs +138 -0
  176. package/src/skills/loader.mjs +147 -0
  177. package/src/skills/runner.mjs +55 -0
  178. package/src/telemetry/index.mjs +96 -0
  179. package/src/terminal/agents.mjs +177 -0
  180. package/src/terminal/analytics.mjs +292 -0
  181. package/src/terminal/ansi.mjs +421 -0
  182. package/src/terminal/main.mjs +150 -0
  183. package/src/terminal/repl.mjs +1484 -0
  184. package/src/terminal/tool-display.mjs +58 -0
  185. package/src/tools/agent.mjs +137 -0
  186. package/src/tools/ask-user.mjs +61 -0
  187. package/src/tools/bash.mjs +148 -0
  188. package/src/tools/cron-create.mjs +120 -0
  189. package/src/tools/cron-delete.mjs +49 -0
  190. package/src/tools/cron-list.mjs +37 -0
  191. package/src/tools/edit.mjs +82 -0
  192. package/src/tools/enter-worktree.mjs +69 -0
  193. package/src/tools/exit-worktree.mjs +57 -0
  194. package/src/tools/glob.mjs +117 -0
  195. package/src/tools/grep.mjs +129 -0
  196. package/src/tools/lint.mjs +71 -0
  197. package/src/tools/ls.mjs +58 -0
  198. package/src/tools/lsp.mjs +115 -0
  199. package/src/tools/multi-edit.mjs +94 -0
  200. package/src/tools/notebook-edit.mjs +96 -0
  201. package/src/tools/read-mcp-resource.mjs +57 -0
  202. package/src/tools/read.mjs +138 -0
  203. package/src/tools/registry.mjs +132 -0
  204. package/src/tools/remote-trigger.mjs +84 -0
  205. package/src/tools/send-message.mjs +64 -0
  206. package/src/tools/skill.mjs +52 -0
  207. package/src/tools/test-runner.mjs +49 -0
  208. package/src/tools/todo-write.mjs +68 -0
  209. package/src/tools/tool-search.mjs +77 -0
  210. package/src/tools/web-fetch.mjs +65 -0
  211. package/src/tools/web-search.mjs +89 -0
  212. package/src/tools/write.mjs +55 -0
  213. package/src/ui/banner.mjs +237 -0
  214. package/src/ui/commands.mjs +499 -0
  215. package/src/ui/formatter.mjs +379 -0
  216. package/src/ui/markdown.mjs +278 -0
  217. package/src/ui/slash-commands.mjs +258 -0
  218. package/index.js +0 -1
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Model Pricing — per-model cost calculation.
3
+ *
4
+ * Two sources of truth:
5
+ * 1. OpenRouter /api/v1/models endpoint (live pricing, fetched at startup)
6
+ * 2. Static fallback table (for non-OpenRouter providers: Anthropic, OpenAI, Google)
7
+ *
8
+ * All prices in USD per million tokens (MTok).
9
+ *
10
+ * PRD-028: Phase 1 — CLI-side pricing with live OpenRouter fetch.
11
+ */
12
+
13
+ // ── Static Fallback Table (non-OpenRouter providers) ─────────
14
+
15
+ const STATIC_PRICING = {
16
+ // ── Anthropic (direct API, not via OpenRouter) ──
17
+ 'claude-opus-4': { input: 15, output: 75, cache_read: 1.50, cache_write: 18.75 },
18
+ 'claude-sonnet-4': { input: 3, output: 15, cache_read: 0.30, cache_write: 3.75 },
19
+ 'claude-haiku-3-5': { input: 0.80, output: 4, cache_read: 0.08, cache_write: 1.0 },
20
+
21
+ // ── OpenAI (direct API) ──
22
+ 'gpt-4o': { input: 2.50, output: 10 },
23
+ 'gpt-4.1': { input: 2, output: 8 },
24
+ 'gpt-4.1-mini': { input: 0.40, output: 1.60 },
25
+ 'gpt-4.1-nano': { input: 0.10, output: 0.40 },
26
+ 'o3': { input: 2, output: 8 },
27
+ 'o3-mini': { input: 1.10, output: 4.40 },
28
+ 'o4-mini': { input: 1.10, output: 4.40 },
29
+
30
+ // ── Google (direct API) ──
31
+ 'gemini-2.5-pro': { input: 1.25, output: 10 },
32
+ 'gemini-2.5-flash': { input: 0.30, output: 2.50 },
33
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40 },
34
+
35
+ // ── Bedrock (Anthropic models via AWS) ──
36
+ 'us.anthropic.claude-sonnet-4-6-20260401-v1:0': { input: 3, output: 15, cache_read: 0.30, cache_write: 3.75 },
37
+ 'us.anthropic.claude-haiku-4-5-20251001-v1:0': { input: 0.80, output: 4, cache_read: 0.08, cache_write: 1.0 },
38
+ };
39
+
40
+ const DEFAULT_PRICING = { input: 3, output: 15 };
41
+
42
+ // ── Live OpenRouter Pricing Cache ────────────────────────────
43
+
44
+ let _openRouterPricing = null; // Map<model_id, { input, output }>
45
+ let _fetchPromise = null;
46
+
47
+ /**
48
+ * Fetch live pricing from OpenRouter's /api/v1/models endpoint.
49
+ * Called once at startup, cached for the session.
50
+ * Non-blocking — returns immediately, populates cache in background.
51
+ */
52
+ export async function fetchOpenRouterPricing() {
53
+ if (_openRouterPricing) return _openRouterPricing;
54
+ if (_fetchPromise) return _fetchPromise;
55
+
56
+ _fetchPromise = (async () => {
57
+ try {
58
+ const controller = new AbortController();
59
+ const timeout = setTimeout(() => controller.abort(), 8000);
60
+
61
+ const res = await fetch('https://openrouter.ai/api/v1/models', {
62
+ signal: controller.signal,
63
+ headers: { 'Accept': 'application/json' },
64
+ });
65
+ clearTimeout(timeout);
66
+
67
+ if (!res.ok) return null;
68
+
69
+ const data = await res.json();
70
+ const models = data.data || [];
71
+ const pricing = new Map();
72
+
73
+ for (const m of models) {
74
+ const id = m.id;
75
+ const p = m.pricing;
76
+ if (!id || !p) continue;
77
+
78
+ // OpenRouter returns per-token prices as strings
79
+ const input = parseFloat(p.prompt || '0') * 1_000_000; // convert to per-MTok
80
+ const output = parseFloat(p.completion || '0') * 1_000_000;
81
+
82
+ pricing.set(id, { input, output });
83
+ }
84
+
85
+ _openRouterPricing = pricing;
86
+ return pricing;
87
+ } catch {
88
+ // Network error, timeout, or parse error — fall back to static
89
+ return null;
90
+ } finally {
91
+ _fetchPromise = null;
92
+ }
93
+ })();
94
+
95
+ return _fetchPromise;
96
+ }
97
+
98
+ /**
99
+ * Start fetching OpenRouter pricing in the background.
100
+ * Call this at CLI startup — non-blocking.
101
+ */
102
+ export function prefetchPricing() {
103
+ fetchOpenRouterPricing().catch(() => {});
104
+ }
105
+
106
+ // ── Model ID Normalization ───────────────────────────────────
107
+
108
+ /**
109
+ * Normalize a model ID for pricing lookup.
110
+ * Strips version suffixes, date stamps, provider prefixes (openrouter/).
111
+ */
112
+ function normalizeModelId(model) {
113
+ if (!model) return '';
114
+ let id = model.trim().toLowerCase();
115
+
116
+ // Strip 'openrouter/' prefix
117
+ id = id.replace(/^openrouter\//, '');
118
+
119
+ // Strip date suffixes like -20250514 or -preview-05-06
120
+ id = id.replace(/-\d{8}$/, '');
121
+ id = id.replace(/-preview[-\d]*$/, '');
122
+
123
+ return id;
124
+ }
125
+
126
+ /**
127
+ * Look up pricing for a model ID.
128
+ *
129
+ * Search order:
130
+ * 1. OpenRouter live pricing (exact match)
131
+ * 2. Static table (exact → normalized → provider-stripped)
132
+ * 3. Default fallback ($3/$15 Sonnet)
133
+ *
134
+ * @param {string} model
135
+ * @returns {{ input: number, output: number, cache_read?: number, cache_write?: number }}
136
+ */
137
+ export function lookupPricing(model) {
138
+ if (!model) return DEFAULT_PRICING;
139
+
140
+ // 1. OpenRouter live cache (exact match — OpenRouter IDs are canonical)
141
+ if (_openRouterPricing) {
142
+ const orPrice = _openRouterPricing.get(model);
143
+ if (orPrice) return orPrice;
144
+
145
+ // Try normalized
146
+ const normalized = normalizeModelId(model);
147
+ const orNorm = _openRouterPricing.get(normalized);
148
+ if (orNorm) return orNorm;
149
+ }
150
+
151
+ // 2. Static table — exact match
152
+ if (STATIC_PRICING[model]) return STATIC_PRICING[model];
153
+
154
+ // Normalized match
155
+ const normalized = normalizeModelId(model);
156
+ if (STATIC_PRICING[normalized]) return STATIC_PRICING[normalized];
157
+
158
+ // Try without provider prefix (e.g. 'google/gemini-2.5-pro' → 'gemini-2.5-pro')
159
+ const withoutProvider = normalized.replace(/^[^/]+\//, '');
160
+ if (STATIC_PRICING[withoutProvider]) return STATIC_PRICING[withoutProvider];
161
+
162
+ // Try with common provider prefixes
163
+ for (const prefix of ['deepseek/', 'xiaomi/', 'google/', 'meta/', 'qwen/', 'moonshotai/']) {
164
+ if (STATIC_PRICING[prefix + withoutProvider]) return STATIC_PRICING[prefix + withoutProvider];
165
+ }
166
+
167
+ // 3. OpenRouter fuzzy match (without provider prefix)
168
+ if (_openRouterPricing) {
169
+ for (const [id, price] of _openRouterPricing) {
170
+ if (id.endsWith('/' + withoutProvider)) return price;
171
+ }
172
+ }
173
+
174
+ return DEFAULT_PRICING;
175
+ }
176
+
177
+ // ── Cost Calculation ─────────────────────────────────────────
178
+
179
+ /**
180
+ * Calculate cost from a usage object.
181
+ *
182
+ * Supports two formats:
183
+ * 1. New format (with per-model breakdown):
184
+ * { models: [{ model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens }] }
185
+ *
186
+ * 2. Legacy format (flat tokens, no model):
187
+ * { input_tokens, output_tokens }
188
+ *
189
+ * @param {Object} usage - Usage object from backend complete event
190
+ * @returns {{ total: number, breakdown: Array, accurate: boolean }}
191
+ */
192
+ export function calculateCost(usage) {
193
+ if (!usage) return { total: 0, breakdown: [], accurate: false };
194
+
195
+ // Best case: backend computed cost from live OpenRouter pricing
196
+ if (typeof usage.total_cost === 'number' && usage.models && usage.models.length > 0) {
197
+ const breakdown = usage.models.map(m => ({
198
+ model: m.model,
199
+ role: m.role || 'unknown',
200
+ input_tokens: m.input_tokens || 0,
201
+ output_tokens: m.output_tokens || 0,
202
+ cache_read_tokens: m.cache_read_tokens || 0,
203
+ cache_creation_tokens: m.cache_creation_tokens || 0,
204
+ cost: m.cost || 0,
205
+ free: (m.cost || 0) === 0 && (m.input_tokens || 0) > 0,
206
+ pricingSource: 'backend',
207
+ }));
208
+
209
+ return {
210
+ total: usage.total_cost,
211
+ breakdown,
212
+ accurate: true,
213
+ };
214
+ }
215
+
216
+ // Fallback: per-model breakdown without backend cost — CLI computes
217
+ if (usage.models && usage.models.length > 0) {
218
+ const breakdown = usage.models.map(m => {
219
+ const pricing = lookupPricing(m.model);
220
+ const inputCost = (m.input_tokens || 0) * pricing.input / 1_000_000;
221
+ const outputCost = (m.output_tokens || 0) * pricing.output / 1_000_000;
222
+ const cacheReadCost = (m.cache_read_tokens || 0) * (pricing.cache_read || pricing.input) / 1_000_000;
223
+ const cacheWriteCost = (m.cache_creation_tokens || 0) * (pricing.cache_write || pricing.input) / 1_000_000;
224
+ const cost = inputCost + outputCost + cacheReadCost + cacheWriteCost;
225
+
226
+ const usedLivePricing = _openRouterPricing?.has(m.model) || false;
227
+ const usedStaticPricing = STATIC_PRICING[m.model] || STATIC_PRICING[normalizeModelId(m.model)];
228
+
229
+ return {
230
+ model: m.model,
231
+ role: m.role || 'unknown',
232
+ input_tokens: m.input_tokens || 0,
233
+ output_tokens: m.output_tokens || 0,
234
+ cache_read_tokens: m.cache_read_tokens || 0,
235
+ cache_creation_tokens: m.cache_creation_tokens || 0,
236
+ cost,
237
+ free: pricing.input === 0 && pricing.output === 0,
238
+ pricingSource: usedLivePricing ? 'openrouter' : usedStaticPricing ? 'static' : 'default',
239
+ };
240
+ });
241
+
242
+ const allAccurate = breakdown.every(b => b.pricingSource !== 'default');
243
+
244
+ return {
245
+ total: breakdown.reduce((sum, b) => sum + b.cost, 0),
246
+ breakdown,
247
+ accurate: allAccurate,
248
+ };
249
+ }
250
+
251
+ // Legacy format: flat tokens, unknown model — use default pricing
252
+ const inputTokens = usage.total_input_tokens || usage.input_tokens || 0;
253
+ const outputTokens = usage.total_output_tokens || usage.output_tokens || 0;
254
+ const total = (inputTokens * DEFAULT_PRICING.input + outputTokens * DEFAULT_PRICING.output) / 1_000_000;
255
+
256
+ return {
257
+ total,
258
+ breakdown: [{
259
+ model: 'unknown',
260
+ role: 'unknown',
261
+ input_tokens: inputTokens,
262
+ output_tokens: outputTokens,
263
+ cost: total,
264
+ free: false,
265
+ pricingSource: 'default',
266
+ }],
267
+ accurate: false,
268
+ };
269
+ }
270
+
271
+ // ── Formatting ───────────────────────────────────────────────
272
+
273
+ /**
274
+ * Format a cost value as a dollar string.
275
+ * @param {number} cost
276
+ * @returns {string}
277
+ */
278
+ export function formatCostValue(cost) {
279
+ if (cost === 0) return '$0.00';
280
+ if (cost < 0.01) return `$${cost.toFixed(4)}`;
281
+ return `$${cost.toFixed(2)}`;
282
+ }
283
+
284
+ /**
285
+ * Format a token count for display (e.g. 42100 → '42.1k').
286
+ * @param {number} tokens
287
+ * @returns {string}
288
+ */
289
+ export function formatTokens(tokens) {
290
+ if (tokens === 0) return '0';
291
+ if (tokens < 1000) return String(tokens);
292
+ return `${(tokens / 1000).toFixed(1)}k`;
293
+ }
294
+
295
+ /**
296
+ * Check if a model is free-tier.
297
+ * @param {string} model
298
+ * @returns {boolean}
299
+ */
300
+ export function isFreeModel(model) {
301
+ const pricing = lookupPricing(model);
302
+ return pricing.input === 0 && pricing.output === 0;
303
+ }
304
+
305
+ /**
306
+ * Get pricing status (for /status display).
307
+ * @returns {{ source: string, modelCount: number }}
308
+ */
309
+ export function getPricingStatus() {
310
+ if (_openRouterPricing) {
311
+ return { source: 'openrouter-live', modelCount: _openRouterPricing.size };
312
+ }
313
+ return { source: 'static-fallback', modelCount: Object.keys(STATIC_PRICING).length };
314
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Multi-Provider — unified provider config and request/response transforms.
3
+ *
4
+ * Supports: Anthropic, OpenAI, Google, Bedrock (stub), Vertex (stub).
5
+ * Each provider defines endpoint, auth headers, and optional transforms.
6
+ */
7
+
8
+ const PROVIDERS = {
9
+ anthropic: {
10
+ name: 'Anthropic',
11
+ endpoint: 'https://api.anthropic.com/v1/messages',
12
+ envKey: 'ANTHROPIC_API_KEY',
13
+ authHeader(key) {
14
+ return {
15
+ 'x-api-key': key,
16
+ 'anthropic-version': '2023-06-01',
17
+ 'Content-Type': 'application/json',
18
+ };
19
+ },
20
+ models: ['claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-6'],
21
+ },
22
+
23
+ openai: {
24
+ name: 'OpenAI',
25
+ endpoint: 'https://api.openai.com/v1/chat/completions',
26
+ envKey: 'OPENAI_API_KEY',
27
+ authHeader(key) {
28
+ return {
29
+ 'Authorization': `Bearer ${key}`,
30
+ 'Content-Type': 'application/json',
31
+ };
32
+ },
33
+ models: ['gpt-4o', 'gpt-4o-mini', 'o1-preview', 'o1-mini', 'o3-mini'],
34
+ transformRequest(body) {
35
+ const messages = [];
36
+ if (body.system) {
37
+ messages.push({ role: 'system', content: body.system });
38
+ }
39
+ for (const msg of body.messages || []) {
40
+ if (typeof msg.content === 'string') {
41
+ messages.push({ role: msg.role, content: msg.content });
42
+ } else if (Array.isArray(msg.content)) {
43
+ for (const block of msg.content) {
44
+ if (block.type === 'tool_result') {
45
+ messages.push({
46
+ role: 'tool',
47
+ tool_call_id: block.tool_use_id,
48
+ content: block.content,
49
+ });
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ const tools = (body.tools || []).map(t => ({
56
+ type: 'function',
57
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
58
+ }));
59
+
60
+ return {
61
+ model: body.model,
62
+ messages,
63
+ ...(tools.length > 0 && { tools }),
64
+ ...(body.max_tokens && { max_tokens: body.max_tokens }),
65
+ ...(body.stream && { stream: true }),
66
+ };
67
+ },
68
+ transformResponse(data) {
69
+ const choice = data.choices?.[0];
70
+ if (!choice) throw new Error('No choices in OpenAI response');
71
+
72
+ const content = [];
73
+ if (choice.message?.content) {
74
+ content.push({ type: 'text', text: choice.message.content });
75
+ }
76
+ if (choice.message?.tool_calls) {
77
+ for (const tc of choice.message.tool_calls) {
78
+ content.push({
79
+ type: 'tool_use',
80
+ id: tc.id,
81
+ name: tc.function.name,
82
+ input: JSON.parse(tc.function.arguments || '{}'),
83
+ });
84
+ }
85
+ }
86
+
87
+ return {
88
+ content,
89
+ stop_reason: choice.finish_reason === 'stop' ? 'end_turn' : choice.finish_reason,
90
+ usage: {
91
+ input_tokens: data.usage?.prompt_tokens || 0,
92
+ output_tokens: data.usage?.completion_tokens || 0,
93
+ },
94
+ };
95
+ },
96
+ },
97
+
98
+ google: {
99
+ name: 'Google',
100
+ endpoint: 'https://generativelanguage.googleapis.com/v1beta/models',
101
+ envKey: 'GOOGLE_API_KEY',
102
+ altEnvKey: 'GEMINI_API_KEY',
103
+ authHeader(key) {
104
+ return { 'Content-Type': 'application/json' };
105
+ },
106
+ models: ['gemini-2.0-flash', 'gemini-2.0-pro', 'gemini-1.5-flash'],
107
+ transformRequest(body) {
108
+ const contents = [];
109
+ for (const msg of body.messages || []) {
110
+ const role = msg.role === 'assistant' ? 'model' : 'user';
111
+ if (typeof msg.content === 'string') {
112
+ contents.push({ role, parts: [{ text: msg.content }] });
113
+ }
114
+ }
115
+
116
+ return {
117
+ contents,
118
+ ...(body.system && {
119
+ systemInstruction: { parts: [{ text: body.system }] },
120
+ }),
121
+ };
122
+ },
123
+ transformResponse(data) {
124
+ const candidate = data.candidates?.[0];
125
+ if (!candidate) throw new Error('No candidates in Google response');
126
+
127
+ const content = [];
128
+ for (const part of candidate.content?.parts || []) {
129
+ if (part.text) content.push({ type: 'text', text: part.text });
130
+ }
131
+
132
+ return {
133
+ content,
134
+ stop_reason: 'end_turn',
135
+ usage: {
136
+ input_tokens: data.usageMetadata?.promptTokenCount || 0,
137
+ output_tokens: data.usageMetadata?.candidatesTokenCount || 0,
138
+ },
139
+ };
140
+ },
141
+ },
142
+
143
+ bedrock: {
144
+ name: 'AWS Bedrock',
145
+ endpoint: null, // Dynamic based on region
146
+ envKey: 'AWS_ACCESS_KEY_ID',
147
+ models: ['anthropic.claude-3-sonnet', 'anthropic.claude-3-haiku'],
148
+ authHeader() {
149
+ // AWS SigV4 signing would go here
150
+ return { 'Content-Type': 'application/json' };
151
+ },
152
+ getEndpoint(model, region = 'us-east-1') {
153
+ return `https://bedrock-runtime.${region}.amazonaws.com/model/${model}/invoke`;
154
+ },
155
+ },
156
+
157
+ vertex: {
158
+ name: 'Google Vertex AI',
159
+ endpoint: null, // Dynamic based on project/region
160
+ envKey: 'GOOGLE_APPLICATION_CREDENTIALS',
161
+ models: ['claude-sonnet-4-6@anthropic'],
162
+ authHeader() {
163
+ // GCP bearer token would go here
164
+ return { 'Content-Type': 'application/json' };
165
+ },
166
+ getEndpoint(model, project, region = 'us-central1') {
167
+ return `https://${region}-aiplatform.googleapis.com/v1/projects/${project}/locations/${region}/publishers/anthropic/models/${model}:rawPredict`;
168
+ },
169
+ },
170
+ };
171
+
172
+ /**
173
+ * Get the provider configuration for a given model.
174
+ * @param {string} model - model name
175
+ * @returns {object} provider config
176
+ */
177
+ export function getProvider(model) {
178
+ if (model.startsWith('claude') || model.startsWith('anthropic')) return PROVIDERS.anthropic;
179
+ if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3')) return PROVIDERS.openai;
180
+ if (model.startsWith('gemini')) return PROVIDERS.google;
181
+ return PROVIDERS.anthropic; // default
182
+ }
183
+
184
+ /**
185
+ * Get a provider by name.
186
+ * @param {string} name
187
+ * @returns {object|undefined}
188
+ */
189
+ export function getProviderByName(name) {
190
+ return PROVIDERS[name];
191
+ }
192
+
193
+ /**
194
+ * List all supported providers.
195
+ * @returns {Array<{ name: string, envKey: string, models: string[] }>}
196
+ */
197
+ export function listProviders() {
198
+ return Object.entries(PROVIDERS).map(([key, p]) => ({
199
+ id: key,
200
+ name: p.name,
201
+ envKey: p.envKey,
202
+ models: p.models || [],
203
+ hasEndpoint: !!p.endpoint,
204
+ }));
205
+ }
206
+
207
+ /**
208
+ * Check which providers have API keys configured.
209
+ * @returns {Array<{ id: string, name: string, configured: boolean }>}
210
+ */
211
+ export function checkProviderKeys() {
212
+ return Object.entries(PROVIDERS).map(([key, p]) => ({
213
+ id: key,
214
+ name: p.name,
215
+ configured: !!(process.env[p.envKey] || (p.altEnvKey && process.env[p.altEnvKey])),
216
+ }));
217
+ }
218
+
219
+ export { PROVIDERS };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Rate Limiter — handle 429 and 529 API responses.
3
+ *
4
+ * Implements exponential backoff with jitter for rate-limited
5
+ * and overloaded API responses. Tracks retry state per-instance.
6
+ */
7
+
8
+ export class RateLimiter {
9
+ /**
10
+ * @param {object} [options]
11
+ * @param {number} [options.maxRetries] - max number of retries (default: 5)
12
+ * @param {number} [options.baseDelay] - base delay in ms (default: 1000)
13
+ * @param {number} [options.maxDelay] - max delay in ms (default: 60000)
14
+ */
15
+ constructor(options = {}) {
16
+ this.maxRetries = options.maxRetries ?? 5;
17
+ this.baseDelay = options.baseDelay ?? 1000;
18
+ this.maxDelay = options.maxDelay ?? 60000;
19
+ this.retryAfter = 0;
20
+ this.retryCount = 0;
21
+ this.lastRetryAt = null;
22
+ }
23
+
24
+ /**
25
+ * Handle an API response and determine whether to retry.
26
+ * @param {{ status: number, headers: { get: (name: string) => string|null } }} response
27
+ * @returns {Promise<'ok'|'retry'|'fail'>}
28
+ */
29
+ async handleResponse(response) {
30
+ if (response.status === 429) {
31
+ // Rate limited
32
+ if (this.retryCount >= this.maxRetries) return 'fail';
33
+
34
+ const retryAfter = parseInt(response.headers?.get?.('retry-after') || '10', 10);
35
+ const delayMs = Math.min(retryAfter * 1000, this.maxDelay);
36
+ this.retryAfter = Date.now() + delayMs;
37
+ this.retryCount++;
38
+ this.lastRetryAt = new Date().toISOString();
39
+
40
+ await this.wait(delayMs);
41
+ return 'retry';
42
+ }
43
+
44
+ if (response.status === 529) {
45
+ // API overloaded
46
+ if (this.retryCount >= this.maxRetries) return 'fail';
47
+
48
+ const delay = this.calculateBackoff();
49
+ this.retryAfter = Date.now() + delay;
50
+ this.retryCount++;
51
+ this.lastRetryAt = new Date().toISOString();
52
+
53
+ await this.wait(delay);
54
+ return 'retry';
55
+ }
56
+
57
+ // Success — reset retry count
58
+ this.retryCount = 0;
59
+ return 'ok';
60
+ }
61
+
62
+ /**
63
+ * Calculate exponential backoff with jitter.
64
+ * @returns {number} delay in milliseconds
65
+ */
66
+ calculateBackoff() {
67
+ const exponential = this.baseDelay * Math.pow(2, this.retryCount);
68
+ const jitter = Math.random() * this.baseDelay;
69
+ return Math.min(exponential + jitter, this.maxDelay);
70
+ }
71
+
72
+ /**
73
+ * Check if we should wait before making a request.
74
+ * @returns {boolean}
75
+ */
76
+ shouldWait() {
77
+ return Date.now() < this.retryAfter;
78
+ }
79
+
80
+ /**
81
+ * Get remaining wait time in ms.
82
+ * @returns {number}
83
+ */
84
+ remainingWait() {
85
+ return Math.max(0, this.retryAfter - Date.now());
86
+ }
87
+
88
+ /**
89
+ * Reset all retry state.
90
+ */
91
+ reset() {
92
+ this.retryAfter = 0;
93
+ this.retryCount = 0;
94
+ this.lastRetryAt = null;
95
+ }
96
+
97
+ /**
98
+ * Get current limiter status.
99
+ */
100
+ status() {
101
+ return {
102
+ retryCount: this.retryCount,
103
+ maxRetries: this.maxRetries,
104
+ retryAfter: this.retryAfter,
105
+ lastRetryAt: this.lastRetryAt,
106
+ isWaiting: this.shouldWait(),
107
+ remainingMs: this.remainingWait(),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Wait for the specified duration.
113
+ * @param {number} ms
114
+ * @returns {Promise<void>}
115
+ */
116
+ wait(ms) {
117
+ return new Promise(resolve => setTimeout(resolve, ms));
118
+ }
119
+ }