@zenithfoundry/slm-gate 1.2.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 (106) hide show
  1. package/.env.example +669 -0
  2. package/LICENSE +21 -0
  3. package/README.md +317 -0
  4. package/configs/antigravity/.env.16gb.example +674 -0
  5. package/configs/antigravity/.env.24gb.example +674 -0
  6. package/configs/antigravity/.env.32gb.example +674 -0
  7. package/configs/antigravity/README.md +109 -0
  8. package/configs/claude-code/.env.16gb.example +674 -0
  9. package/configs/claude-code/.env.24gb.example +674 -0
  10. package/configs/claude-code/.env.32gb.example +674 -0
  11. package/configs/claude-code/README.md +52 -0
  12. package/configs/claude-desktop/.env.16gb.example +674 -0
  13. package/configs/claude-desktop/.env.24gb.example +674 -0
  14. package/configs/claude-desktop/.env.32gb.example +674 -0
  15. package/configs/claude-desktop/README.md +37 -0
  16. package/configs/cline-continue-opencode/.env.16gb.example +674 -0
  17. package/configs/cline-continue-opencode/.env.24gb.example +674 -0
  18. package/configs/cline-continue-opencode/.env.32gb.example +674 -0
  19. package/configs/cline-continue-opencode/README.md +34 -0
  20. package/configs/cursor/.env.16gb.example +674 -0
  21. package/configs/cursor/.env.24gb.example +674 -0
  22. package/configs/cursor/.env.32gb.example +674 -0
  23. package/configs/cursor/README.md +26 -0
  24. package/configs/generic-http/.env.16gb.example +674 -0
  25. package/configs/generic-http/.env.24gb.example +674 -0
  26. package/configs/generic-http/.env.32gb.example +674 -0
  27. package/configs/generic-http/README.md +20 -0
  28. package/configs/generic-stdio/.env.16gb.example +674 -0
  29. package/configs/generic-stdio/.env.24gb.example +674 -0
  30. package/configs/generic-stdio/.env.32gb.example +674 -0
  31. package/configs/generic-stdio/README.md +24 -0
  32. package/configs/preserve/README.md +26 -0
  33. package/configs/preserve/tls.json +61 -0
  34. package/dist/adapters/tech-lead-stack.js +38 -0
  35. package/dist/cache/index.js +173 -0
  36. package/dist/cli.js +256 -0
  37. package/dist/config.js +255 -0
  38. package/dist/dashboard/data.js +149 -0
  39. package/dist/dashboard/export.js +42 -0
  40. package/dist/dashboard/serve.js +63 -0
  41. package/dist/doctor.js +338 -0
  42. package/dist/hardware.js +126 -0
  43. package/dist/home-dir.js +39 -0
  44. package/dist/ledger/flush-lifecycle.js +50 -0
  45. package/dist/ledger/index.js +946 -0
  46. package/dist/ledger/report.js +69 -0
  47. package/dist/ledger/setup-dashboard.js +456 -0
  48. package/dist/ledger/smoke.js +37 -0
  49. package/dist/ledger/sync-config.js +177 -0
  50. package/dist/ledger/sync.js +307 -0
  51. package/dist/ledger/verify.js +185 -0
  52. package/dist/ledger/wipe-langfuse.js +130 -0
  53. package/dist/llm-gate/distill.js +239 -0
  54. package/dist/llm-gate/formats/anthropic.js +185 -0
  55. package/dist/llm-gate/formats/chat-completions.js +103 -0
  56. package/dist/llm-gate/formats/contract.js +29 -0
  57. package/dist/llm-gate/formats/gemini.js +84 -0
  58. package/dist/llm-gate/formats/internal.js +1 -0
  59. package/dist/llm-gate/formats/openai.js +77 -0
  60. package/dist/llm-gate/formats/responses.js +146 -0
  61. package/dist/llm-gate/forward.js +150 -0
  62. package/dist/llm-gate/index.js +40 -0
  63. package/dist/llm-gate/local-first.js +217 -0
  64. package/dist/llm-gate/pipeline.js +267 -0
  65. package/dist/llm-gate/server.js +289 -0
  66. package/dist/mcp-gate/ground.js +64 -0
  67. package/dist/mcp-gate/index.js +57 -0
  68. package/dist/mcp-gate/pipeline.js +252 -0
  69. package/dist/mcp-gate/server.js +302 -0
  70. package/dist/mcp-gate/tool-names.js +57 -0
  71. package/dist/models/check.js +26 -0
  72. package/dist/models/footprint.js +137 -0
  73. package/dist/models/helpers.js +91 -0
  74. package/dist/models/index.js +5 -0
  75. package/dist/models/reasoning.js +91 -0
  76. package/dist/models/roles.js +9 -0
  77. package/dist/models/slm.js +243 -0
  78. package/dist/models/types.js +1 -0
  79. package/dist/pricing/index.js +115 -0
  80. package/dist/pricing/plans.js +54 -0
  81. package/dist/pricing/providers.js +172 -0
  82. package/dist/resolver/index.js +277 -0
  83. package/dist/resolver/types.js +1 -0
  84. package/dist/setup/claim.js +41 -0
  85. package/dist/setup/gate-command.js +41 -0
  86. package/dist/setup/init.js +92 -0
  87. package/dist/setup/local-models.js +123 -0
  88. package/dist/setup/model-gate.js +220 -0
  89. package/dist/setup/notify.js +45 -0
  90. package/dist/setup/ollama-install.js +53 -0
  91. package/dist/setup/parent-watch.js +84 -0
  92. package/dist/setup/required-models.js +20 -0
  93. package/dist/setup/startup.js +132 -0
  94. package/dist/setup/tool-settings.js +101 -0
  95. package/dist/utils/backoff.js +47 -0
  96. package/dist/utils/compression.js +145 -0
  97. package/dist/utils/constants.js +22 -0
  98. package/dist/utils/duration.js +43 -0
  99. package/dist/utils/elision.js +556 -0
  100. package/dist/utils/embedding.js +32 -0
  101. package/dist/utils/entry-point.js +23 -0
  102. package/dist/utils/local-only.js +82 -0
  103. package/dist/utils/preserve-patterns.js +115 -0
  104. package/dist/utils/safety.js +30 -0
  105. package/dist/verifier/index.js +67 -0
  106. package/package.json +121 -0
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import { roles } from './roles.js';
3
+ import { withSlmTimeout } from './helpers.js';
4
+ import { CONFIG } from '../config.js';
5
+ export function checkAgreement(samples) {
6
+ if (samples.length === 0)
7
+ return null;
8
+ const normalize = (val) => {
9
+ if (typeof val === 'string') {
10
+ return val.toLowerCase().replace(/\s+/g, ' ').replace(/[.,!?]$/, '').trim();
11
+ }
12
+ return JSON.stringify(val);
13
+ };
14
+ const counts = new Map();
15
+ let maxCount = 0;
16
+ let majorityValue = null;
17
+ for (const sample of samples) {
18
+ const norm = normalize(sample);
19
+ const existing = counts.get(norm) || { count: 0, original: sample };
20
+ existing.count += 1;
21
+ counts.set(norm, existing);
22
+ if (existing.count > maxCount) {
23
+ maxCount = existing.count;
24
+ majorityValue = existing.original;
25
+ }
26
+ }
27
+ const threshold = Math.floor(samples.length / 2) + 1;
28
+ return maxCount >= threshold ? majorityValue : null;
29
+ }
30
+ export async function selfConsistency(slm, model, prompt, schema, k = 3, temperature = 0.7) {
31
+ const promises = Array.from({ length: k }).map(() => withSlmTimeout(slm.generateJSON(model, prompt, schema, temperature), 'selfConsistency', CONFIG.SLM_TIMEOUT_MS).catch(() => null));
32
+ const results = await Promise.all(promises);
33
+ const validResults = results.filter((r) => r !== null);
34
+ const agreed = checkAgreement(validResults);
35
+ if (agreed !== null) {
36
+ return agreed;
37
+ }
38
+ // Fallback to a temp=0 run if no agreement
39
+ return withSlmTimeout(slm.generateJSON(model, prompt, schema, 0), 'selfConsistency', CONFIG.SLM_TIMEOUT_MS);
40
+ }
41
+ export async function classify(slm, text) {
42
+ const schema = z.object({
43
+ category: z.enum(['classify', 'extract', 'format', 'boolean', 'short_factual', 'trivial_edit', 'other'])
44
+ });
45
+ const prompt = `Categorize the following user request into exactly one category:
46
+ - 'format': Generating, structuring, or converting data to JSON, XML, CSV, or markdown.
47
+ - 'other': Math word problems, arithmetic calculations, multi-step reasoning, logic, coding, or complex tasks.
48
+ - 'short_factual': Simple direct fact lookup (e.g. "What is the capital of Japan?"). NEVER use for arithmetic or math.
49
+ - 'boolean': Answering yes/no or true/false questions.
50
+ - 'extract': Extracting specific data spans from provided text.
51
+ - 'classify': Classifying items into categories.
52
+ - 'trivial_edit': Fixing spelling or grammar.
53
+
54
+ Rule: Any question requiring arithmetic calculation, word problem math, or multi-step logic MUST be classified as 'other'.
55
+
56
+ Text: ${text}`;
57
+ const result = await withSlmTimeout(slm.generateJSON(roles.gate, prompt, schema, 0), 'classify', CONFIG.SLM_TIMEOUT_MS);
58
+ return result.category;
59
+ }
60
+ export async function compress(slm, text) {
61
+ const schema = z.object({
62
+ compressedText: z.string()
63
+ });
64
+ const prompt = `Compress the following text while maintaining the core meaning.\n\nText: ${text}`;
65
+ const result = await withSlmTimeout(slm.generateJSON(roles.gate, prompt, schema, 0), 'compress', CONFIG.SLM_TIMEOUT_MS);
66
+ return result.compressedText;
67
+ }
68
+ /**
69
+ * Compresses ONE narrative run for `distillToolResult`, which never sends protected content here, so
70
+ * this prompt carries no placeholder-custody rules — a 3B model reliably summarises prose and reliably
71
+ * loses opaque tokens. An explicit word budget is what actually drives the ratio: without it the model
72
+ * rewords instead of condensing (measured 4-8% vs 44-60%). Shared by the MCP layer and the model gate.
73
+ *
74
+ * @param params.slm The local model client
75
+ * @param params.text The narrative run to compress
76
+ * @param params.task Optional task context for the model
77
+ * @returns The compressed run
78
+ */
79
+ export function compressNarrativeRun(params) {
80
+ const { slm, text, task } = params;
81
+ const wordCount = text.trim().split(/\s+/).length;
82
+ const targetWords = Math.max(20, Math.ceil(wordCount * 0.35));
83
+ const prompt = `Compress the text below to AT MOST ${targetWords} words.\n\nKeep: every instruction, requirement, constraint, name, number, path and technical specific.\nDelete: background, history, rationale, motivation, repetition and filler.\nOutput ONLY the compressed text as terse bullet points. No preamble, no heading.\n\nTask context: ${task || 'None'}\n\n${text}`;
84
+ // This was the only model call in the pipeline with neither a token ceiling nor a timeout.
85
+ // Ollama sends HTTP response headers only AFTER generation completes, so the effective cap
86
+ // is Node fetch's 300s header timeout surfacing as `fetch failed`, misclassified as a
87
+ // transport error, long after the MCP client had given up. SLM_TIMEOUT_MS now governs it.
88
+ // The ceiling is per-run and generous against the target so a summary is never cut mid-
89
+ // sentence; the run is discarded anyway if it comes back longer than the original.
90
+ return withSlmTimeout(slm.generateText(CONFIG.SLM_GATE_MODEL, [{ role: 'user', content: prompt }], CONFIG.TEMPERATURE, Math.max(128, Math.ceil(wordCount * 0.6))), 'distill', CONFIG.SLM_TIMEOUT_MS);
91
+ }
@@ -0,0 +1,9 @@
1
+ import { CONFIG } from '../config.js';
2
+ export const roles = {
3
+ get brain() {
4
+ return CONFIG.SLM_BRAIN_MODEL;
5
+ },
6
+ get gate() {
7
+ return CONFIG.SLM_GATE_MODEL;
8
+ }
9
+ };
@@ -0,0 +1,243 @@
1
+ import { Ollama } from 'ollama';
2
+ import { zodToJsonSchema } from 'zod-to-json-schema';
3
+ import { CONFIG } from '../config.js';
4
+ import { SlmFormatError } from './helpers.js';
5
+ function stripThinkTags(text) {
6
+ return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
7
+ }
8
+ export class SLM {
9
+ client;
10
+ constructor(client) {
11
+ this.client = client || new Ollama({ host: CONFIG.OLLAMA_HOST });
12
+ }
13
+ /**
14
+ * Generates a deterministic JSON payload from the Small Language Model.
15
+ * This is used internally for agentic logic, routing decisions, and prompt conditioning.
16
+ *
17
+ * It enforces a strict JSON schema and parses the response. If the model returns malformed JSON,
18
+ * it will catch the error and retry the generation with a temperature of 0 (fully deterministic).
19
+ *
20
+ * @WARNING Do not use markdown extraction, regex parsing, or `stop` tokens to force JSON generation.
21
+ * Native Structured Outputs (`format: jsonSchema` or `response_format: { type: "json_schema" }`) MUST be used.
22
+ * Relying on prompt engineering and markdown parsing for JSON has caused severe rambling and timeouts
23
+ * (especially on Apple Silicon / llama.cpp) where the model fails to emit a closing brace or stop token.
24
+ *
25
+ * @param model The ID of the local model to execute (e.g., 'qwen2.5-coder:3b')
26
+ * @param prompt The instruction prompt for the model
27
+ * @param schema A Zod schema defining the exact JSON structure expected back
28
+ * @param temperature Generation temperature (randomness). Defaults to CONFIG.TEMPERATURE
29
+ * @returns A validated, strongly-typed JSON object matching the provided schema
30
+ */
31
+ async generateJSON(model, prompt, schema, temperature = CONFIG.TEMPERATURE) {
32
+ const attempt = async (temp) => {
33
+ // Convert the Zod schema to a standard JSON schema so the LLM understands the expected structure
34
+ const jsonSchema = zodToJsonSchema(schema);
35
+ let responseText = '';
36
+ const promptWithSchema = `${prompt}\n\nRespond with valid JSON matching the schema.`;
37
+ if (CONFIG.SLM_PROVIDER === 'ollama') {
38
+ const response = await this.client.chat({
39
+ model,
40
+ messages: [{ role: 'user', content: promptWithSchema }],
41
+ format: jsonSchema,
42
+ keep_alive: CONFIG.OLLAMA_KEEP_ALIVE,
43
+ // `think` is a REQUEST-level field, not an `options` entry. Ollama silently drops
44
+ // unknown keys from `options`: a thinking model
45
+ // (qwen3 / qwen3.5, the default brain for ram-24 and above) reasoned until it hit
46
+ // num_predict, returned done_reason:length with EMPTY content, and the parse failed.
47
+ // That produced `resolver_error: format` on essentially every standalone call, twice
48
+ // per call because of the retry below. Keep this out of `options`.
49
+ think: false,
50
+ // Every key below is a real Ollama option, so a future typo fails the build
51
+ // instead of being silently ignored the way `think` was.
52
+ options: {
53
+ temperature: temp,
54
+ num_ctx: CONFIG.NUM_CTX,
55
+ num_predict: 2000
56
+ }
57
+ });
58
+ responseText = response.message.content;
59
+ }
60
+ else if (CONFIG.SLM_PROVIDER === 'openai') {
61
+ const response = await fetch(`${CONFIG.OLLAMA_HOST}/v1/chat/completions`, {
62
+ method: 'POST',
63
+ headers: {
64
+ 'Content-Type': 'application/json',
65
+ ...(CONFIG.CLOUD_API_KEY && { 'Authorization': `Bearer ${CONFIG.CLOUD_API_KEY}` })
66
+ },
67
+ body: JSON.stringify({
68
+ model,
69
+ messages: [{ role: 'user', content: promptWithSchema }],
70
+ temperature: temp,
71
+ max_tokens: 2000,
72
+ response_format: {
73
+ type: "json_schema",
74
+ json_schema: {
75
+ name: "slm_output",
76
+ strict: true,
77
+ schema: jsonSchema
78
+ }
79
+ }
80
+ }),
81
+ signal: AbortSignal.timeout(CONFIG.SLM_TIMEOUT_MS)
82
+ });
83
+ if (!response.ok) {
84
+ throw new Error(`OpenAI API error: ${response.statusText}`);
85
+ }
86
+ const data = await response.json();
87
+ responseText = data.choices[0].message.content;
88
+ }
89
+ else {
90
+ throw new Error(`Unsupported SLM_PROVIDER: ${CONFIG.SLM_PROVIDER}`);
91
+ }
92
+ let stripped = stripThinkTags(responseText).trim();
93
+ try {
94
+ const parsed = JSON.parse(stripped);
95
+ return schema.parse(parsed);
96
+ }
97
+ catch (err) {
98
+ throw new SlmFormatError(`Failed to parse or validate JSON: ${err.message}\nContent: ${stripped}`);
99
+ }
100
+ };
101
+ try {
102
+ return await attempt(temperature);
103
+ }
104
+ catch (err) {
105
+ if (err instanceof SlmFormatError) {
106
+ // If the model hallucinated malformed JSON, retry once with zero temperature (maximum determinism)
107
+ return await attempt(0);
108
+ }
109
+ throw err;
110
+ }
111
+ }
112
+ /**
113
+ * Generates a raw, unstructured text completion from the SLM.
114
+ *
115
+ * Architectural Note:
116
+ * This maintains a deliberate separation of concerns between internal reasoning
117
+ * and external proxying. Unlike `generateJSON` (which is used strictly for internal,
118
+ * deterministic agentic logic and routing), `generateText` is used to directly proxy
119
+ * conversational responses back to the end-user/client. Bypassing schema enforcement
120
+ * here eliminates JSON formatting token overhead and allows for seamless real-time
121
+ * Markdown streaming via `streamText`.
122
+ *
123
+ * @param model The ID of the model to execute
124
+ * @param messages Array of history messages
125
+ * @param temperature Generation temperature, default from CONFIG
126
+ * @param maxTokens Optional ceiling on generated tokens. Left unset for conversational
127
+ * proxying (llm-gate), where truncating the user's answer would be wrong. Summarising
128
+ * callers (mcp-gate distill) MUST pass one: with temperature 0 and repetitive input a 3B
129
+ * model can loop until the context window fills, which at local speeds runs for minutes.
130
+ * @returns Raw text completion
131
+ */
132
+ async generateText(model, messages, temperature = CONFIG.TEMPERATURE, maxTokens) {
133
+ if (CONFIG.SLM_PROVIDER === 'ollama') {
134
+ const response = await this.client.chat({
135
+ model,
136
+ messages,
137
+ keep_alive: CONFIG.OLLAMA_KEEP_ALIVE,
138
+ // Request-level, never inside `options` — see generateJSON for what that cost us.
139
+ think: false,
140
+ options: {
141
+ temperature,
142
+ num_ctx: CONFIG.NUM_CTX,
143
+ ...(maxTokens !== undefined && { num_predict: maxTokens })
144
+ }
145
+ });
146
+ return stripThinkTags(response.message.content);
147
+ }
148
+ else if (CONFIG.SLM_PROVIDER === 'openai') {
149
+ const response = await fetch(`${CONFIG.OLLAMA_HOST}/v1/chat/completions`, {
150
+ method: 'POST',
151
+ headers: {
152
+ 'Content-Type': 'application/json',
153
+ ...(CONFIG.CLOUD_API_KEY && { 'Authorization': `Bearer ${CONFIG.CLOUD_API_KEY}` })
154
+ },
155
+ body: JSON.stringify({
156
+ model,
157
+ messages,
158
+ temperature,
159
+ ...(maxTokens !== undefined && { max_tokens: maxTokens })
160
+ }),
161
+ signal: AbortSignal.timeout(CONFIG.SLM_TIMEOUT_MS)
162
+ });
163
+ if (!response.ok) {
164
+ throw new Error(`OpenAI API error: ${response.statusText}`);
165
+ }
166
+ const data = await response.json();
167
+ return stripThinkTags(data.choices[0].message.content);
168
+ }
169
+ throw new Error(`Unsupported SLM_PROVIDER: ${CONFIG.SLM_PROVIDER}`);
170
+ }
171
+ /**
172
+ * Generates a streaming text completion from the SLM.
173
+ *
174
+ * Note on Syntax (`async *streamText`):
175
+ * An asterisk (*) used with a function defines a generator function, which is a
176
+ * special function that can pause its work and resume it later. You usually write
177
+ * it as `function* myFunc()` or as a short method inside an object like `*myFunc()`.
178
+ * By returning an AsyncGenerator, it yields content chunks iteratively as they
179
+ * are produced by the local model.
180
+ *
181
+ * @param model The ID of the model to execute
182
+ * @param messages Array of history messages
183
+ * @param temperature Generation temperature, default from CONFIG
184
+ * @returns AsyncGenerator yielding string chunks
185
+ */
186
+ async *streamText(model, messages, temperature = CONFIG.TEMPERATURE) {
187
+ if (CONFIG.SLM_PROVIDER === 'ollama') {
188
+ const response = await this.client.chat({
189
+ model,
190
+ messages,
191
+ stream: true,
192
+ keep_alive: CONFIG.OLLAMA_KEEP_ALIVE,
193
+ // Request-level, never inside `options` — see generateJSON for what that cost us.
194
+ think: false,
195
+ options: {
196
+ temperature,
197
+ num_ctx: CONFIG.NUM_CTX
198
+ }
199
+ });
200
+ for await (const chunk of response) {
201
+ yield chunk.message.content;
202
+ }
203
+ }
204
+ else {
205
+ // Fallback: just yield the whole text at once for non-streaming providers
206
+ const text = await this.generateText(model, messages, temperature);
207
+ yield text;
208
+ }
209
+ }
210
+ /**
211
+ * Generates embeddings for the given text using the specified local model.
212
+ */
213
+ async embed(model, text) {
214
+ if (CONFIG.SLM_PROVIDER === 'ollama') {
215
+ const response = await this.client.embeddings({
216
+ model,
217
+ prompt: text,
218
+ keep_alive: CONFIG.OLLAMA_KEEP_ALIVE,
219
+ });
220
+ return response.embedding;
221
+ }
222
+ else {
223
+ // For openai-compatible embeddings endpoint
224
+ const response = await fetch(`${CONFIG.OLLAMA_HOST}/v1/embeddings`, {
225
+ method: 'POST',
226
+ headers: {
227
+ 'Content-Type': 'application/json',
228
+ ...(CONFIG.CLOUD_API_KEY && { 'Authorization': `Bearer ${CONFIG.CLOUD_API_KEY}` })
229
+ },
230
+ body: JSON.stringify({
231
+ model,
232
+ input: text
233
+ }),
234
+ signal: AbortSignal.timeout(CONFIG.SLM_TIMEOUT_MS)
235
+ });
236
+ if (!response.ok) {
237
+ throw new Error(`OpenAI API error (embeddings): ${response.statusText}`);
238
+ }
239
+ const data = await response.json();
240
+ return data.data[0].embedding;
241
+ }
242
+ }
243
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ /**
2
+ * USD per 1,000,000 tokens (standard, non-batch, non-cached input).
3
+ *
4
+ * Verified against provider documentation on 2026-09-13:
5
+ * OpenAI https://developers.openai.com/api/docs/pricing
6
+ * Anthropic https://platform.claude.com/docs/en/about-claude/models/overview
7
+ * Google https://ai.google.dev/gemini-api/docs/pricing
8
+ *
9
+ * Re-verify before trusting cost figures: providers change these often, and a stale rate
10
+ * silently corrupts every "Cost Saved" number on the dashboard.
11
+ */
12
+ export const PRICING = {
13
+ // ── OpenAI ──
14
+ 'gpt-6-astra': { in: 10, out: 50 },
15
+ 'gpt-5.6-sol': { in: 4, out: 20 }, // promotional through 2026-11-21
16
+ 'gpt-5.6-terra': { in: 2, out: 12 },
17
+ 'gpt-5.6-luna': { in: 0.20, out: 1.20 },
18
+ 'gpt-5.6-cyber': { in: 12.50, out: 75 },
19
+ // ── Anthropic ──
20
+ 'claude-fable-5-1': { in: 10, out: 50 },
21
+ 'claude-opus-5': { in: 5, out: 25 },
22
+ 'claude-sonnet-5': { in: 2, out: 10 },
23
+ 'claude-haiku-4-5': { in: 1, out: 5 },
24
+ // ── Google ──
25
+ // 3.8/3.7/3.6 Flash share promotional pricing through 2026-12-31,
26
+ // after which input rises to 1.50 and output to 7.50.
27
+ 'gemini-3.8-flash': { in: 0.75, out: 3.75 },
28
+ 'gemini-3.7-flash': { in: 0.75, out: 3.75 },
29
+ 'gemini-3.6-flash': { in: 0.75, out: 3.75 },
30
+ 'gemini-3.5-flash': { in: 1.50, out: 9.00 },
31
+ 'gemini-3.5-flash-lite': { in: 0.30, out: 2.50 },
32
+ 'gemini-3.1-flash-lite': { in: 0.25, out: 1.50 },
33
+ // Pro preview is tiered: these are the <=200k-token prompt rates.
34
+ 'gemini-3.1-pro-preview': { in: 2.00, out: 12.00 },
35
+ // Retired 2026-10-16 (Gemini Developer API). Retained only so historical ledger rows
36
+ // still price correctly; do not use for new traffic.
37
+ 'gemini-2.5-pro': { in: 1.25, out: 10.00 },
38
+ 'gemini-2.5-flash': { in: 0.30, out: 2.50 },
39
+ 'gemini-2.5-flash-lite': { in: 0.10, out: 0.40 },
40
+ 'gemini-2.5-flash-free': { in: 0, out: 0 },
41
+ // To add a model, use its exact API id and cite the pricing page above.
42
+ };
43
+ export const LOCAL_RATE = { in: 0, out: 0 };
44
+ export function isLocal(model) {
45
+ // Keep as ONLY a fallback guess. The real decision is explicit in the ledger caller.
46
+ if (model.startsWith('local:'))
47
+ return true;
48
+ const lower = model.toLowerCase();
49
+ // 'gemma' deliberately excluded: providerFromModel() classifies it as the Gemini
50
+ // provider, so treating it as local here made cost and provider attribution disagree.
51
+ // A genuinely local gemma should be tagged 'local:' by the caller.
52
+ return ['qwen', 'llama', 'phi', 'mistral', 'deepseek'].some(tag => lower.includes(tag));
53
+ }
54
+ /**
55
+ * Strips host-specific decorations from a model id so it matches a PRICING key.
56
+ *
57
+ * Agents report ids like `claude-opus-5[1m]` (context-window suffix) or
58
+ * `anthropic/claude-sonnet-5` (vendor-prefixed router style). Without normalisation these
59
+ * all missed the table and silently fell back to the reference cloud model's rates.
60
+ *
61
+ * @param model Raw model id as reported by the host
62
+ * @returns Normalised id suitable for a PRICING lookup
63
+ */
64
+ export function normalizeModelId(model) {
65
+ return model
66
+ .trim()
67
+ .replace(/\[[^\]]*\]$/, '') // trailing [1m] context-window marker
68
+ .replace(/^[^/]+\//, '') // vendor/ prefix
69
+ .replace(/[:@](latest|preview)$/, '')
70
+ .trim();
71
+ }
72
+ export function ratesFor(model) {
73
+ if (model in PRICING) {
74
+ return PRICING[model];
75
+ }
76
+ const normalized = normalizeModelId(model);
77
+ if (normalized in PRICING) {
78
+ return PRICING[normalized];
79
+ }
80
+ // Longest-prefix match so dated snapshots (claude-opus-5-20260401) resolve to their family.
81
+ const prefixMatch = Object.keys(PRICING)
82
+ .filter(key => normalized.startsWith(key))
83
+ .sort((a, b) => b.length - a.length)[0];
84
+ if (prefixMatch) {
85
+ return PRICING[prefixMatch];
86
+ }
87
+ const known = Object.keys(PRICING).join(', ');
88
+ const error = new Error(`Pricing missing for model: ${model}. Known API models are: ${known}`);
89
+ error.name = 'PricingMissingError';
90
+ throw error;
91
+ }
92
+ export function calculateCostUsd(model, inTok, outTok) {
93
+ let rate;
94
+ if (isLocal(model)) {
95
+ rate = LOCAL_RATE;
96
+ }
97
+ else {
98
+ rate = ratesFor(model);
99
+ }
100
+ return (inTok * rate.in + outTok * rate.out) / 1e6;
101
+ }
102
+ export function safeCalculateCostUsd(model, inTok, outTok, fallbackModel = 'gemini-2.5-flash') {
103
+ const target = model || fallbackModel;
104
+ try {
105
+ return calculateCostUsd(target, inTok, outTok);
106
+ }
107
+ catch {
108
+ try {
109
+ return calculateCostUsd(fallbackModel, inTok, outTok);
110
+ }
111
+ catch {
112
+ return 0;
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @fileoverview Plan registry for authoritative window lengths. Window budgets are NOT
3
+ * derived from plans: providers publish no token counts (see providers.ts).
4
+ *
5
+ * IMPORTANT MAINTAINER WARNING:
6
+ * Providers change these limits often. Re-verify the numbers against the cited sources!
7
+ *
8
+ * Sources:
9
+ * claude : https://support.claude.com/en/articles/11049741-what-is-the-max-plan
10
+ * chatgpt : https://help.openai.com (search "ChatGPT usage limits")
11
+ * gemini : https://support.google.com/gemini/answer/16275805
12
+ */
13
+ // Define raw constants as pinned by research
14
+ const RAW_PLANS = {
15
+ // CLAUDE — 5h rolling window + weekly cap
16
+ 'claude-pro': { provider: 'claude', windowMinutes: 300 },
17
+ 'claude-max-5x': { provider: 'claude', windowMinutes: 300 },
18
+ 'claude-max-20x': { provider: 'claude', windowMinutes: 300 },
19
+ // CHATGPT — 3h rolling window
20
+ 'chatgpt-go': { provider: 'chatgpt', windowMinutes: 180 },
21
+ 'chatgpt-plus': { provider: 'chatgpt', windowMinutes: 180 },
22
+ 'chatgpt-pro-5x': { provider: 'chatgpt', windowMinutes: 180 },
23
+ 'chatgpt-pro-20x': { provider: 'chatgpt', windowMinutes: 180 },
24
+ // GEMINI — 5h compute-based rolling window + weekly
25
+ 'gemini-plus': { provider: 'gemini', windowMinutes: 300 },
26
+ 'gemini-pro': { provider: 'gemini', windowMinutes: 300 },
27
+ 'gemini-ultra': { provider: 'gemini', windowMinutes: 300 },
28
+ };
29
+ const SOURCES = {
30
+ claude: 'https://support.claude.com/en/articles/11049741-what-is-the-max-plan',
31
+ chatgpt: 'https://help.openai.com',
32
+ gemini: 'https://support.google.com/gemini/answer/16275805'
33
+ };
34
+ /**
35
+ * Returns a fully resolved subscription plan.
36
+ */
37
+ export function getSubscriptionPlan(planKey) {
38
+ const raw = RAW_PLANS[planKey];
39
+ if (!raw) {
40
+ throw new Error(`Unknown plan key: '${planKey}'. Valid keys: ${Object.keys(RAW_PLANS).join(', ')}`);
41
+ }
42
+ return {
43
+ provider: raw.provider,
44
+ windowMinutes: raw.windowMinutes,
45
+ source: SOURCES[raw.provider],
46
+ verifiedOn: '2026-09-09'
47
+ };
48
+ }
49
+ export function isValidPlanKey(planKey) {
50
+ return planKey in RAW_PLANS;
51
+ }
52
+ export function getValidPlanKeys() {
53
+ return Object.keys(RAW_PLANS);
54
+ }