slash-tokens 1.7.1 → 1.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.0] — The Merge Gate Edition
4
+
5
+ *2026-10-11*
6
+
7
+ Know what a change will cost before it merges.
8
+
9
+ New: `slash-tokens diff`, a quality floor per job, the output cap passed to your client, and no older-generation swaps by default. `/auto` routing is unchanged. No price change.
10
+
11
+ ### Added
12
+ - **`slash-tokens diff <base> [head] [--json]`**: what a git change does to the scan's monthly LLM cost. It scans both refs of the repo in the current folder (head defaults to the working tree, so uncommitted edits count), and reports call sites and monthly cost at each, the change, and each file whose LLM calls or cost moved, largest first. Same assumptions as the scan (100 calls a site a day, output twice the input). `--json` is for CI. CLI only: it uses git, so it isn't in the library entry (which runs in browsers and Workers).
13
+ - **`hire().run()` tells your client the job's output cap.** `call(model, input, { maxOutputTokens })`: send it as the provider's max-tokens so the bill can't pass the quote. Two-argument clients keep working.
14
+ - **A quality floor per job.** `run({ …, floor })` overrides the agent's floor for that job, so one agent can plan on the flagship (floor 3) and edit on a mid-tier model (floor 2).
15
+ - The diff's per-file line reads "0 → 1 site" (was "sites").
16
+ - `estimateMonthly(sites)` in `report.ts`: the scan's monthly cost as one function, shared by the scan report and `diff`. The scan's numbers are unchanged.
17
+
18
+ ### Changed
19
+ - **`decide()` won't swap in an older generation of the same line by default.** A tier is the vendor's product line, not its generation, so "same tier" alone didn't mean "as good": at the default floor Gemini 3.8 Flash was swapped for 2.5 Flash. Each catalog model now has a `gen` (Claude 4.5 < 5 < 5.5, Gemini 2.5 < 3.8, GPT-5.4 < 6, Grok 4.3 < 4.7, Nemotron 3 < 3.5; aliases take the generation they resolve to), and a substitute in the same tier must be the same generation or newer. A smaller current line is still fair game at a lower floor (Opus 5.5 → Haiku, Gemini 3.8 Flash → 3.1 Flash-Lite). `decide(task, { allowOlder: true })` brings back the old behaviour. Newer-generation swaps are unchanged: Opus 4.8 → 5.5, GPT-5.6 Sol → GPT-6 Luna, Nemotron Ultra → 3.5 Lightning. `preflightRoute()` and `/auto` are unchanged.
20
+ - **OSS savings study re-run** (`bench/oss/RESULTS.md`): floor 1 is unchanged (pooled 71%); the default floor reads 29% pooled (was 34%) because Google's 34% was the 3.8 Flash → 2.5 Flash swap this release stops. The study now fails loudly without `rg` instead of writing zeros.
21
+
3
22
  ## [1.7.1] — The Hired Agent Edition
4
23
 
5
24
  *2026-10-10*
package/README.md CHANGED
@@ -10,11 +10,24 @@
10
10
  **Know what an LLM call will cost before you make it, and prove what you saved after.**
11
11
  4.8 KB WASM · sub-millisecond · zero dependencies · Claude, GPT, Grok, Gemini, Nemotron.
12
12
 
13
- ## v1.7.1 — The Hired Agent Edition
13
+ ## v1.8.0 — The Merge Gate Edition
14
14
 
15
- Quote the job, book the right model, prove it with a receipt.
15
+ Know what a change will cost before it merges.
16
+
17
+ ```bash
18
+ npx slash-tokens diff main
19
+ # LLM cost of this change (scan estimate, 100 calls a site a day)
20
+ # main: 1 call site, $8.71/mo
21
+ # working tree: 2 call sites, $17.29/mo
22
+ # change: +$8.58/mo
23
+ # src/summarise.ts (OpenAI): 0 → 1 site, +$8.58/mo
24
+ ```
16
25
 
17
- New in 1.7.1: the scan counts each real LLM request once.
26
+ Run it in CI on every merge request (`--json` for pipelines). Also new in 1.8.0: `decide()` never swaps in an older model generation unless you allow it, and `hire()` takes a quality floor per job and tells your client each job's output cap.
27
+
28
+ ## Hire the agent
29
+
30
+ Quote the job, book the right model, prove it with a receipt.
18
31
 
19
32
  ```js
20
33
  import { hire } from 'slash-tokens'
@@ -25,7 +38,7 @@ const { decision, receipt } = await agent.run({
25
38
  input: prompt,
26
39
  model: 'nemotron-3-ultra',
27
40
  maxOutputTokens: 2000,
28
- call: (model, input) => yourClient(model, input), // returns { output, usage }
41
+ call: (model, input, { maxOutputTokens }) => yourClient(model, input, maxOutputTokens), // returns { output, usage }
29
42
  })
30
43
 
31
44
  decision.action // 'downgrade': nemotron-3-super does the job for less
@@ -50,6 +63,7 @@ npm install slash-tokens
50
63
  ```bash
51
64
  npx slash-tokens # find the LLM calls in this project and what they cost a month
52
65
  echo "Fix the bug" | npx slash-tokens quote --model nemotron-3-ultra --floor 1
66
+ npx slash-tokens diff main # what this branch does to that monthly cost
53
67
  ```
54
68
 
55
69
  ## Check every call, automatically
package/dist/agent.d.ts CHANGED
@@ -6,24 +6,31 @@ export interface HireOptions {
6
6
  budget?: number;
7
7
  /** Model the savings are measured against. Default: the model each job asks for. */
8
8
  baseline?: string;
9
- /** Lowest tier a substitute may have. Default: each job's requested tier. */
9
+ /** Lowest tier a substitute may have. A job's own `floor` overrides it. Default: each job's requested tier. */
10
10
  floor?: Tier;
11
11
  /** The agent's cut of measured savings. Default 0.10. */
12
12
  feeRate?: number;
13
13
  /** Fee shown on the receipt but not charged. Default true. */
14
14
  waived?: boolean;
15
15
  }
16
+ /** What hire().run() tells your client about the job, beyond the model and input. */
17
+ export interface CallOptions {
18
+ /** The job's output ceiling, the quote's high end. Send it as the provider's max-tokens so the bill can't pass the quote. */
19
+ maxOutputTokens: number;
20
+ }
16
21
  /**
17
22
  * What the agent calls to do the work: your provider client. `model` is the
18
23
  * booked catalog key (e.g. `nemotron-3.5-lightning`); map it to your
19
24
  * provider's API ID. Return the output and the provider's `usage` object.
20
25
  */
21
- export type CallModel = (model: string, input: string | Message[]) => Promise<{
26
+ export type CallModel = (model: string, input: string | Message[], opts: CallOptions) => Promise<{
22
27
  output: string;
23
28
  usage: Usage;
24
29
  }>;
25
30
  export interface Job extends Task {
26
31
  call: CallModel;
32
+ /** Lowest tier a substitute may have for this job. Default: the agent's floor, else the job's requested tier. */
33
+ floor?: Tier;
27
34
  }
28
35
  export interface RunResult {
29
36
  decision: Decision;
package/dist/agent.js CHANGED
@@ -15,14 +15,14 @@ export function hire(opts = {}) {
15
15
  get remaining() { return Math.max(budget - spent, 0); },
16
16
  get receipts() { return receipts; },
17
17
  async run(job) {
18
- const { call, ...task } = job;
19
- const policy = { floor: opts.floor };
18
+ const { call, floor, ...task } = job;
19
+ const policy = { floor: floor ?? opts.floor };
20
20
  if (budget !== Infinity)
21
21
  policy.budget = Math.max(budget - spent, 0);
22
22
  const decision = decide(task, policy);
23
23
  if (decision.action === 'block' || !decision.chosen)
24
24
  return { decision };
25
- const { output, usage } = await call(decision.chosen.model, task.input);
25
+ const { output, usage } = await call(decision.chosen.model, task.input, { maxOutputTokens: decision.chosen.outputTokens.max });
26
26
  const receipt = reconcile(decision.chosen, usage, {
27
27
  baseline: opts.baseline ?? decision.requested.model,
28
28
  feeRate: opts.feeRate,
package/dist/catalog.d.ts CHANGED
@@ -18,6 +18,13 @@ export declare const TIER_NAMES: Record<Tier, string>;
18
18
  export interface CatalogEntry {
19
19
  provider: string;
20
20
  tier: Tier;
21
+ /**
22
+ * Model generation within its provider (Claude 4.5 < 5 < 5.5, Gemini 2.5 <
23
+ * 3.8, GPT-5.4 < 6, Grok 4.3 < 4.7, Nemotron 3 < 3.5). Aliases take the
24
+ * generation they resolve to (grok-4.20 and grok-4-1-fast → 4.3). decide() won't swap in an older generation
25
+ * unless the policy allows it.
26
+ */
27
+ gen: number;
21
28
  /** Date the price was checked against the provider's own pricing page. */
22
29
  asOf: string;
23
30
  /** Last day this price holds, when the provider has announced a change. */
package/dist/catalog.js CHANGED
@@ -70,65 +70,65 @@ const NEMOTRON_LIGHTNING = { input: 0.06, output: 0.24, context: 1048576 };
70
70
  const ASOF = '2026-10-07';
71
71
  // Nemotron: read from the Token Factory API (see above).
72
72
  const ASOF_NEBIUS = '2026-10-08';
73
- function e(provider, tier, price) {
74
- return { provider, tier, asOf: provider === 'Nebius' ? ASOF_NEBIUS : ASOF, ...price };
73
+ function e(provider, tier, price, gen) {
74
+ return { provider, tier, gen, asOf: provider === 'Nebius' ? ASOF_NEBIUS : ASOF, ...price };
75
75
  }
76
76
  // Order is kept from the 1.6.6 MODELS table (new entries appended), so
77
77
  // preflight()'s option ordering for equal costs doesn't move.
78
78
  export const CATALOG = {
79
79
  // Anthropic — live names + generic aliases (same rates)
80
- 'claude-fable-5.1': e('Anthropic', 4, FABLE),
81
- 'claude-fable-5': e('Anthropic', 4, FABLE),
82
- 'claude-mythos-5.1': e('Anthropic', 4, FABLE),
83
- 'claude-mythos-5': e('Anthropic', 4, FABLE),
84
- 'claude-opus-5.5': e('Anthropic', 3, OPUS_55),
85
- 'claude-opus-5': e('Anthropic', 3, OPUS),
86
- 'claude-opus-4.8': e('Anthropic', 3, OPUS),
87
- 'claude-opus': e('Anthropic', 3, OPUS),
88
- 'claude-opus-4.7': e('Anthropic', 3, OPUS),
89
- 'claude-opus-4.6': e('Anthropic', 3, OPUS),
90
- 'claude-opus-4.5': e('Anthropic', 3, OPUS),
91
- 'claude-sonnet-5.5': e('Anthropic', 2, SONNET),
92
- 'claude-sonnet-5': e('Anthropic', 2, SONNET),
93
- 'claude-sonnet': e('Anthropic', 2, SONNET),
94
- 'claude-sonnet-4.6': e('Anthropic', 2, SONNET_4X),
95
- 'claude-sonnet-4.5': e('Anthropic', 2, SONNET_4X),
96
- 'claude-haiku-4.5': e('Anthropic', 1, HAIKU),
97
- 'claude-haiku': e('Anthropic', 1, HAIKU),
80
+ 'claude-fable-5.1': e('Anthropic', 4, FABLE, 5.1),
81
+ 'claude-fable-5': e('Anthropic', 4, FABLE, 5),
82
+ 'claude-mythos-5.1': e('Anthropic', 4, FABLE, 5.1),
83
+ 'claude-mythos-5': e('Anthropic', 4, FABLE, 5),
84
+ 'claude-opus-5.5': e('Anthropic', 3, OPUS_55, 5.5),
85
+ 'claude-opus-5': e('Anthropic', 3, OPUS, 5),
86
+ 'claude-opus-4.8': e('Anthropic', 3, OPUS, 4.8),
87
+ 'claude-opus': e('Anthropic', 3, OPUS, 5),
88
+ 'claude-opus-4.7': e('Anthropic', 3, OPUS, 4.7),
89
+ 'claude-opus-4.6': e('Anthropic', 3, OPUS, 4.6),
90
+ 'claude-opus-4.5': e('Anthropic', 3, OPUS, 4.5),
91
+ 'claude-sonnet-5.5': e('Anthropic', 2, SONNET, 5.5),
92
+ 'claude-sonnet-5': e('Anthropic', 2, SONNET, 5),
93
+ 'claude-sonnet': e('Anthropic', 2, SONNET, 5),
94
+ 'claude-sonnet-4.6': e('Anthropic', 2, SONNET_4X, 4.6),
95
+ 'claude-sonnet-4.5': e('Anthropic', 2, SONNET_4X, 4.5),
96
+ 'claude-haiku-4.5': e('Anthropic', 1, HAIKU, 4.5),
97
+ 'claude-haiku': e('Anthropic', 1, HAIKU, 4.5),
98
98
  // xAI — flagship 4.6, cheap same-provider 4.3. 4.20 / fast are aliases.
99
- 'grok-4.7': e('xAI', 3, GROK_46),
100
- 'grok-4.6': e('xAI', 3, GROK_46),
101
- 'grok-4.5': e('xAI', 3, GROK_46),
102
- 'grok-build-0.1': e('xAI', 2, GROK_BUILD),
103
- 'grok-4.3': e('xAI', 2, GROK_43),
104
- 'grok-4.20': e('xAI', 2, GROK_43),
105
- 'grok-4-1-fast': e('xAI', 2, GROK_43),
99
+ 'grok-4.7': e('xAI', 3, GROK_46, 4.7),
100
+ 'grok-4.6': e('xAI', 3, GROK_46, 4.6),
101
+ 'grok-4.5': e('xAI', 3, GROK_46, 4.5),
102
+ 'grok-build-0.1': e('xAI', 2, GROK_BUILD, 4.7),
103
+ 'grok-4.3': e('xAI', 2, GROK_43, 4.3),
104
+ 'grok-4.20': e('xAI', 2, GROK_43, 4.3),
105
+ 'grok-4-1-fast': e('xAI', 2, GROK_43, 4.3),
106
106
  // Google
107
- 'gemini-3.1-pro': e('Google', 3, GEMINI_PRO),
108
- 'gemini-3.1-pro-preview': e('Google', 3, GEMINI_PRO),
109
- 'gemini-3.8-flash': e('Google', 2, GEMINI_FLASH_3X),
110
- 'gemini-3.7-flash': e('Google', 2, GEMINI_FLASH_3X),
111
- 'gemini-3.6-flash': e('Google', 2, GEMINI_FLASH_3X),
112
- 'gemini-3.5-flash': e('Google', 2, GEMINI_35_FLASH),
113
- 'gemini-3.1-flash-lite': e('Google', 1, GEMINI_31_FLASH_LITE),
114
- 'gemini-3.5-flash-lite': e('Google', 1, GEMINI_FLASH),
115
- 'gemini-2.5-flash': e('Google', 2, GEMINI_FLASH),
107
+ 'gemini-3.1-pro': e('Google', 3, GEMINI_PRO, 3.1),
108
+ 'gemini-3.1-pro-preview': e('Google', 3, GEMINI_PRO, 3.1),
109
+ 'gemini-3.8-flash': e('Google', 2, GEMINI_FLASH_3X, 3.8),
110
+ 'gemini-3.7-flash': e('Google', 2, GEMINI_FLASH_3X, 3.7),
111
+ 'gemini-3.6-flash': e('Google', 2, GEMINI_FLASH_3X, 3.6),
112
+ 'gemini-3.5-flash': e('Google', 2, GEMINI_35_FLASH, 3.5),
113
+ 'gemini-3.1-flash-lite': e('Google', 1, GEMINI_31_FLASH_LITE, 3.1),
114
+ 'gemini-3.5-flash-lite': e('Google', 1, GEMINI_FLASH, 3.5),
115
+ 'gemini-2.5-flash': e('Google', 2, GEMINI_FLASH, 2.5),
116
116
  // OpenAI — live 5.6 ladder + GPT-6. 5.4 family kept as aliases (old prices).
117
- 'gpt-6-astra': e('OpenAI', 4, GPT_6_ASTRA),
118
- 'gpt-6.1-sol': e('OpenAI', 3, GPT_6_SOL),
119
- 'gpt-6-sol': e('OpenAI', 3, GPT_6_SOL),
120
- 'gpt-6-luna': e('OpenAI', 1, GPT_6_LUNA),
121
- 'gpt-5.6-sol': e('OpenAI', 3, GPT_SOL),
122
- 'gpt-5.6-terra': e('OpenAI', 2, GPT_TERRA),
123
- 'gpt-5.6-luna': e('OpenAI', 1, GPT_LUNA),
124
- 'gpt-5.4': e('OpenAI', 3, GPT_54),
125
- 'gpt-5.4-mini': e('OpenAI', 2, GPT_54_MINI),
126
- 'gpt-5.4-nano': e('OpenAI', 1, GPT_54_NANO),
117
+ 'gpt-6-astra': e('OpenAI', 4, GPT_6_ASTRA, 6),
118
+ 'gpt-6.1-sol': e('OpenAI', 3, GPT_6_SOL, 6.1),
119
+ 'gpt-6-sol': e('OpenAI', 3, GPT_6_SOL, 6),
120
+ 'gpt-6-luna': e('OpenAI', 1, GPT_6_LUNA, 6),
121
+ 'gpt-5.6-sol': e('OpenAI', 3, GPT_SOL, 5.6),
122
+ 'gpt-5.6-terra': e('OpenAI', 2, GPT_TERRA, 5.6),
123
+ 'gpt-5.6-luna': e('OpenAI', 1, GPT_LUNA, 5.6),
124
+ 'gpt-5.4': e('OpenAI', 3, GPT_54, 5.4),
125
+ 'gpt-5.4-mini': e('OpenAI', 2, GPT_54_MINI, 5.4),
126
+ 'gpt-5.4-nano': e('OpenAI', 1, GPT_54_NANO, 5.4),
127
127
  // NVIDIA Nemotron, served and billed by Nebius Token Factory
128
- 'nemotron-3-ultra': e('Nebius', 3, NEMOTRON_ULTRA),
129
- 'nemotron-3-super': e('Nebius', 2, NEMOTRON_SUPER),
130
- 'nemotron-3-nano': e('Nebius', 1, NEMOTRON_NANO),
131
- 'nemotron-3.5-lightning': e('Nebius', 1, NEMOTRON_LIGHTNING),
128
+ 'nemotron-3-ultra': e('Nebius', 3, NEMOTRON_ULTRA, 3),
129
+ 'nemotron-3-super': e('Nebius', 2, NEMOTRON_SUPER, 3),
130
+ 'nemotron-3-nano': e('Nebius', 1, NEMOTRON_NANO, 3),
131
+ 'nemotron-3.5-lightning': e('Nebius', 1, NEMOTRON_LIGHTNING, 3.5),
132
132
  };
133
133
  /**
134
134
  * Real API IDs that don't follow the table's naming → table keys.
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { readFileSync as readFileSync2 } from "node:fs";
5
- import { dirname, join as join2 } from "node:path";
5
+ import { dirname, join as join3 } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
8
  // src/scanner.ts
@@ -112,58 +112,58 @@ var NEMOTRON_NANO = { input: 0.06, output: 0.24, context: 262144 };
112
112
  var NEMOTRON_LIGHTNING = { input: 0.06, output: 0.24, context: 1048576 };
113
113
  var ASOF = "2026-10-07";
114
114
  var ASOF_NEBIUS = "2026-10-08";
115
- function e(provider, tier, price) {
116
- return { provider, tier, asOf: provider === "Nebius" ? ASOF_NEBIUS : ASOF, ...price };
115
+ function e(provider, tier, price, gen) {
116
+ return { provider, tier, gen, asOf: provider === "Nebius" ? ASOF_NEBIUS : ASOF, ...price };
117
117
  }
118
118
  var CATALOG = {
119
- "claude-fable-5.1": e("Anthropic", 4, FABLE),
120
- "claude-fable-5": e("Anthropic", 4, FABLE),
121
- "claude-mythos-5.1": e("Anthropic", 4, FABLE),
122
- "claude-mythos-5": e("Anthropic", 4, FABLE),
123
- "claude-opus-5.5": e("Anthropic", 3, OPUS_55),
124
- "claude-opus-5": e("Anthropic", 3, OPUS),
125
- "claude-opus-4.8": e("Anthropic", 3, OPUS),
126
- "claude-opus": e("Anthropic", 3, OPUS),
127
- "claude-opus-4.7": e("Anthropic", 3, OPUS),
128
- "claude-opus-4.6": e("Anthropic", 3, OPUS),
129
- "claude-opus-4.5": e("Anthropic", 3, OPUS),
130
- "claude-sonnet-5.5": e("Anthropic", 2, SONNET),
131
- "claude-sonnet-5": e("Anthropic", 2, SONNET),
132
- "claude-sonnet": e("Anthropic", 2, SONNET),
133
- "claude-sonnet-4.6": e("Anthropic", 2, SONNET_4X),
134
- "claude-sonnet-4.5": e("Anthropic", 2, SONNET_4X),
135
- "claude-haiku-4.5": e("Anthropic", 1, HAIKU),
136
- "claude-haiku": e("Anthropic", 1, HAIKU),
137
- "grok-4.7": e("xAI", 3, GROK_46),
138
- "grok-4.6": e("xAI", 3, GROK_46),
139
- "grok-4.5": e("xAI", 3, GROK_46),
140
- "grok-build-0.1": e("xAI", 2, GROK_BUILD),
141
- "grok-4.3": e("xAI", 2, GROK_43),
142
- "grok-4.20": e("xAI", 2, GROK_43),
143
- "grok-4-1-fast": e("xAI", 2, GROK_43),
144
- "gemini-3.1-pro": e("Google", 3, GEMINI_PRO),
145
- "gemini-3.1-pro-preview": e("Google", 3, GEMINI_PRO),
146
- "gemini-3.8-flash": e("Google", 2, GEMINI_FLASH_3X),
147
- "gemini-3.7-flash": e("Google", 2, GEMINI_FLASH_3X),
148
- "gemini-3.6-flash": e("Google", 2, GEMINI_FLASH_3X),
149
- "gemini-3.5-flash": e("Google", 2, GEMINI_35_FLASH),
150
- "gemini-3.1-flash-lite": e("Google", 1, GEMINI_31_FLASH_LITE),
151
- "gemini-3.5-flash-lite": e("Google", 1, GEMINI_FLASH),
152
- "gemini-2.5-flash": e("Google", 2, GEMINI_FLASH),
153
- "gpt-6-astra": e("OpenAI", 4, GPT_6_ASTRA),
154
- "gpt-6.1-sol": e("OpenAI", 3, GPT_6_SOL),
155
- "gpt-6-sol": e("OpenAI", 3, GPT_6_SOL),
156
- "gpt-6-luna": e("OpenAI", 1, GPT_6_LUNA),
157
- "gpt-5.6-sol": e("OpenAI", 3, GPT_SOL),
158
- "gpt-5.6-terra": e("OpenAI", 2, GPT_TERRA),
159
- "gpt-5.6-luna": e("OpenAI", 1, GPT_LUNA),
160
- "gpt-5.4": e("OpenAI", 3, GPT_54),
161
- "gpt-5.4-mini": e("OpenAI", 2, GPT_54_MINI),
162
- "gpt-5.4-nano": e("OpenAI", 1, GPT_54_NANO),
163
- "nemotron-3-ultra": e("Nebius", 3, NEMOTRON_ULTRA),
164
- "nemotron-3-super": e("Nebius", 2, NEMOTRON_SUPER),
165
- "nemotron-3-nano": e("Nebius", 1, NEMOTRON_NANO),
166
- "nemotron-3.5-lightning": e("Nebius", 1, NEMOTRON_LIGHTNING)
119
+ "claude-fable-5.1": e("Anthropic", 4, FABLE, 5.1),
120
+ "claude-fable-5": e("Anthropic", 4, FABLE, 5),
121
+ "claude-mythos-5.1": e("Anthropic", 4, FABLE, 5.1),
122
+ "claude-mythos-5": e("Anthropic", 4, FABLE, 5),
123
+ "claude-opus-5.5": e("Anthropic", 3, OPUS_55, 5.5),
124
+ "claude-opus-5": e("Anthropic", 3, OPUS, 5),
125
+ "claude-opus-4.8": e("Anthropic", 3, OPUS, 4.8),
126
+ "claude-opus": e("Anthropic", 3, OPUS, 5),
127
+ "claude-opus-4.7": e("Anthropic", 3, OPUS, 4.7),
128
+ "claude-opus-4.6": e("Anthropic", 3, OPUS, 4.6),
129
+ "claude-opus-4.5": e("Anthropic", 3, OPUS, 4.5),
130
+ "claude-sonnet-5.5": e("Anthropic", 2, SONNET, 5.5),
131
+ "claude-sonnet-5": e("Anthropic", 2, SONNET, 5),
132
+ "claude-sonnet": e("Anthropic", 2, SONNET, 5),
133
+ "claude-sonnet-4.6": e("Anthropic", 2, SONNET_4X, 4.6),
134
+ "claude-sonnet-4.5": e("Anthropic", 2, SONNET_4X, 4.5),
135
+ "claude-haiku-4.5": e("Anthropic", 1, HAIKU, 4.5),
136
+ "claude-haiku": e("Anthropic", 1, HAIKU, 4.5),
137
+ "grok-4.7": e("xAI", 3, GROK_46, 4.7),
138
+ "grok-4.6": e("xAI", 3, GROK_46, 4.6),
139
+ "grok-4.5": e("xAI", 3, GROK_46, 4.5),
140
+ "grok-build-0.1": e("xAI", 2, GROK_BUILD, 4.7),
141
+ "grok-4.3": e("xAI", 2, GROK_43, 4.3),
142
+ "grok-4.20": e("xAI", 2, GROK_43, 4.3),
143
+ "grok-4-1-fast": e("xAI", 2, GROK_43, 4.3),
144
+ "gemini-3.1-pro": e("Google", 3, GEMINI_PRO, 3.1),
145
+ "gemini-3.1-pro-preview": e("Google", 3, GEMINI_PRO, 3.1),
146
+ "gemini-3.8-flash": e("Google", 2, GEMINI_FLASH_3X, 3.8),
147
+ "gemini-3.7-flash": e("Google", 2, GEMINI_FLASH_3X, 3.7),
148
+ "gemini-3.6-flash": e("Google", 2, GEMINI_FLASH_3X, 3.6),
149
+ "gemini-3.5-flash": e("Google", 2, GEMINI_35_FLASH, 3.5),
150
+ "gemini-3.1-flash-lite": e("Google", 1, GEMINI_31_FLASH_LITE, 3.1),
151
+ "gemini-3.5-flash-lite": e("Google", 1, GEMINI_FLASH, 3.5),
152
+ "gemini-2.5-flash": e("Google", 2, GEMINI_FLASH, 2.5),
153
+ "gpt-6-astra": e("OpenAI", 4, GPT_6_ASTRA, 6),
154
+ "gpt-6.1-sol": e("OpenAI", 3, GPT_6_SOL, 6.1),
155
+ "gpt-6-sol": e("OpenAI", 3, GPT_6_SOL, 6),
156
+ "gpt-6-luna": e("OpenAI", 1, GPT_6_LUNA, 6),
157
+ "gpt-5.6-sol": e("OpenAI", 3, GPT_SOL, 5.6),
158
+ "gpt-5.6-terra": e("OpenAI", 2, GPT_TERRA, 5.6),
159
+ "gpt-5.6-luna": e("OpenAI", 1, GPT_LUNA, 5.6),
160
+ "gpt-5.4": e("OpenAI", 3, GPT_54, 5.4),
161
+ "gpt-5.4-mini": e("OpenAI", 2, GPT_54_MINI, 5.4),
162
+ "gpt-5.4-nano": e("OpenAI", 1, GPT_54_NANO, 5.4),
163
+ "nemotron-3-ultra": e("Nebius", 3, NEMOTRON_ULTRA, 3),
164
+ "nemotron-3-super": e("Nebius", 2, NEMOTRON_SUPER, 3),
165
+ "nemotron-3-nano": e("Nebius", 1, NEMOTRON_NANO, 3),
166
+ "nemotron-3.5-lightning": e("Nebius", 1, NEMOTRON_LIGHTNING, 3.5)
167
167
  };
168
168
  var API_ALIASES = {
169
169
  "nvidia/nemotron-3-ultra-550b-a55b": "nemotron-3-ultra",
@@ -173,7 +173,7 @@ var API_ALIASES = {
173
173
  };
174
174
 
175
175
  // src/models.ts
176
- var MODELS = Object.fromEntries(Object.entries(CATALOG).map(([name, { provider: _p, tier: _t, asOf: _a, priceUntil: _u, ...price }]) => [name, price]));
176
+ var MODELS = Object.fromEntries(Object.entries(CATALOG).map(([name, { provider: _p, tier: _t, gen: _g, asOf: _a, priceUntil: _u, ...price }]) => [name, price]));
177
177
  function canonicalModel(name) {
178
178
  if (MODELS[name])
179
179
  return name;
@@ -411,6 +411,30 @@ function pad(s, n) {
411
411
  function formatNum(n) {
412
412
  return n.toLocaleString("en-US");
413
413
  }
414
+ var CALLS_PER_SITE_PER_DAY = 100;
415
+ function estimateMonthly(sites) {
416
+ const callsPerDay = CALLS_PER_SITE_PER_DAY;
417
+ const dailyInput = sites.reduce((sum, s) => sum + s.tokensPerCall * callsPerDay, 0);
418
+ const dailyOutput = dailyInput * 2;
419
+ const dailyTotal = dailyInput + dailyOutput;
420
+ let monthlyCostInput = 0;
421
+ let monthlyCostOutput = 0;
422
+ let unknownProviderSites = 0;
423
+ for (const site of sites) {
424
+ const info = getModel(site.estimatedModel);
425
+ if (!info)
426
+ continue;
427
+ if (site.estimatedModel === UNKNOWN_SDK_REPRESENTATIVE_MODEL && !["Anthropic"].includes(site.sdk)) {
428
+ unknownProviderSites++;
429
+ }
430
+ const siteDailyInput = site.tokensPerCall * callsPerDay;
431
+ const siteDailyOutput = siteDailyInput * 2;
432
+ monthlyCostInput += siteDailyInput * 30 / 1e6 * info.input;
433
+ monthlyCostOutput += siteDailyOutput * 30 / 1e6 * info.output;
434
+ }
435
+ const monthlyCost = monthlyCostInput + monthlyCostOutput;
436
+ return { callsPerDay, dailyInput, dailyOutput, dailyTotal, monthlyCostInput, monthlyCostOutput, monthlyCost, unknownProviderSites };
437
+ }
414
438
  function printReport(sites, filesScanned, timeMs, cwd) {
415
439
  console.log("");
416
440
  console.log(`${ORANGE}${B} ⚡ /slash${R}`);
@@ -432,26 +456,7 @@ function printReport(sites, filesScanned, timeMs, cwd) {
432
456
  console.log(`${WHITE} ${pad(loc, 40)} ${GOLD}${pad(site.sdk, 12)} ${ORANGE}~${formatNum(site.tokensPerCall)} tok/call${R}`);
433
457
  }
434
458
  console.log("");
435
- const callsPerDay = 100;
436
- const dailyInput = sites.reduce((sum, s) => sum + s.tokensPerCall * callsPerDay, 0);
437
- const dailyOutput = dailyInput * 2;
438
- const dailyTotal = dailyInput + dailyOutput;
439
- let monthlyCostInput = 0;
440
- let monthlyCostOutput = 0;
441
- let unknownProviderSites = 0;
442
- for (const site of sites) {
443
- const info = getModel(site.estimatedModel);
444
- if (!info)
445
- continue;
446
- if (site.estimatedModel === UNKNOWN_SDK_REPRESENTATIVE_MODEL && !["Anthropic"].includes(site.sdk)) {
447
- unknownProviderSites++;
448
- }
449
- const siteDailyInput = site.tokensPerCall * callsPerDay;
450
- const siteDailyOutput = siteDailyInput * 2;
451
- monthlyCostInput += siteDailyInput * 30 / 1e6 * info.input;
452
- monthlyCostOutput += siteDailyOutput * 30 / 1e6 * info.output;
453
- }
454
- const monthlyCost = monthlyCostInput + monthlyCostOutput;
459
+ const { callsPerDay, dailyInput, dailyOutput, dailyTotal, monthlyCostInput, monthlyCostOutput, monthlyCost, unknownProviderSites } = estimateMonthly(sites);
455
460
  console.log(`${ORANGE} DAILY TOKEN VOLUME ${GRAY}(${callsPerDay} calls/site/day)${R}`);
456
461
  console.log(`${DIM} ${"─".repeat(60)}${R}`);
457
462
  console.log(`${WHITE} Input tokens: ${B}${formatNum(dailyInput)}${R}`);
@@ -628,6 +633,8 @@ function decide(task, policy = {}) {
628
633
  continue;
629
634
  if (CATALOG[m].tier < floor)
630
635
  continue;
636
+ if (!policy.allowOlder && CATALOG[m].tier === CATALOG[requested.model].tier && CATALOG[m].gen < CATALOG[requested.model].gen)
637
+ continue;
631
638
  const q = quote({ ...task, model: m });
632
639
  if (!ok(q) || q.cost.high >= requested.cost.high)
633
640
  continue;
@@ -653,6 +660,79 @@ function decide(task, policy = {}) {
653
660
  };
654
661
  }
655
662
 
663
+ // src/diff.ts
664
+ import { mkdtempSync, rmSync } from "fs";
665
+ import { tmpdir } from "os";
666
+ import { join as join2, relative } from "path";
667
+ import { spawnSync } from "child_process";
668
+ var round2 = (n) => Math.round(n * 100) / 100;
669
+ function exportRef(dir, ref) {
670
+ const out = mkdtempSync(join2(tmpdir(), "slash-diff-"));
671
+ const archive = spawnSync("git", ["-C", dir, "archive", "--format=tar", ref], { maxBuffer: 512 * 1024 * 1024 });
672
+ if (archive.status !== 0) {
673
+ rmSync(out, { recursive: true, force: true });
674
+ throw new Error(`git archive ${ref} failed: ${archive.stderr?.toString().trim() || "unknown error"}`);
675
+ }
676
+ const untar = spawnSync("tar", ["-x", "-C", out], { input: archive.stdout });
677
+ if (untar.status !== 0) {
678
+ rmSync(out, { recursive: true, force: true });
679
+ throw new Error(`could not unpack ${ref}: ${untar.stderr?.toString().trim()}`);
680
+ }
681
+ return out;
682
+ }
683
+ function scanSide(root) {
684
+ const { sites } = scan(root);
685
+ return {
686
+ sites: sites.map((s) => ({ ...s, rel: relative(root, s.file) })),
687
+ monthly: estimateMonthly(sites).monthlyCost
688
+ };
689
+ }
690
+ function scanDiff(dir, base, head) {
691
+ const baseDir = exportRef(dir, base);
692
+ const headDir = head ? exportRef(dir, head) : dir;
693
+ try {
694
+ const b = scanSide(baseDir);
695
+ const h = scanSide(headDir);
696
+ const groups = new Map;
697
+ const add = (s, side) => {
698
+ const key = `${s.rel}\x00${s.sdk}`;
699
+ const g = groups.get(key) ?? { file: s.rel, sdk: s.sdk, before: [], after: [] };
700
+ g[side].push(s);
701
+ groups.set(key, g);
702
+ };
703
+ b.sites.forEach((s) => add(s, "before"));
704
+ h.sites.forEach((s) => add(s, "after"));
705
+ const files = [];
706
+ for (const g of groups.values()) {
707
+ const monthlyBefore = estimateMonthly(g.before).monthlyCost;
708
+ const monthlyAfter = estimateMonthly(g.after).monthlyCost;
709
+ if (g.before.length === g.after.length && round2(monthlyBefore) === round2(monthlyAfter))
710
+ continue;
711
+ files.push({
712
+ file: g.file,
713
+ sdk: g.sdk,
714
+ sitesBefore: g.before.length,
715
+ sitesAfter: g.after.length,
716
+ monthlyBefore: round2(monthlyBefore),
717
+ monthlyAfter: round2(monthlyAfter),
718
+ monthlyDelta: round2(monthlyAfter - monthlyBefore)
719
+ });
720
+ }
721
+ files.sort((x, y) => Math.abs(y.monthlyDelta) - Math.abs(x.monthlyDelta) || x.file.localeCompare(y.file));
722
+ return {
723
+ base: { ref: base, sites: b.sites.length, monthlyCost: round2(b.monthly) },
724
+ head: { ref: head ?? "working tree", sites: h.sites.length, monthlyCost: round2(h.monthly) },
725
+ monthlyDelta: round2(h.monthly - b.monthly),
726
+ files,
727
+ callsPerSitePerDay: CALLS_PER_SITE_PER_DAY
728
+ };
729
+ } finally {
730
+ rmSync(baseDir, { recursive: true, force: true });
731
+ if (head)
732
+ rmSync(headDir, { recursive: true, force: true });
733
+ }
734
+ }
735
+
656
736
  // src/cli.ts
657
737
  var args = process.argv.slice(2);
658
738
  if (args.includes("--version") || args.includes("-V")) {
@@ -665,6 +745,8 @@ if (args.includes("--help") || args.includes("-h")) {
665
745
  }
666
746
  if (args[0] === "quote") {
667
747
  runQuote(args.slice(1)).then((code) => process.exit(code));
748
+ } else if (args[0] === "diff") {
749
+ process.exit(runDiff(args.slice(1)));
668
750
  } else {
669
751
  scanHere();
670
752
  }
@@ -691,6 +773,32 @@ function scanHere() {
691
773
  console.log("");
692
774
  }
693
775
  }
776
+ function runDiff(argv) {
777
+ try {
778
+ const refs = argv.filter((a) => !a.startsWith("--"));
779
+ if (refs.length < 1 || refs.length > 2)
780
+ throw new Error("usage: slash-tokens diff <base> [head] [--json]");
781
+ const d = scanDiff(process.cwd(), refs[0], refs[1]);
782
+ if (argv.includes("--json")) {
783
+ console.log(JSON.stringify(d, null, 2));
784
+ return 0;
785
+ }
786
+ const usd = (n) => `${n < 0 ? "-" : ""}$${Math.abs(n).toFixed(2)}`;
787
+ const sign = (n) => n > 0 ? `+${usd(n)}` : usd(n);
788
+ console.log(`LLM cost of this change (scan estimate, ${d.callsPerSitePerDay} calls a site a day)`);
789
+ const sites = (n) => `${n} call site${n === 1 ? "" : "s"}`;
790
+ console.log(` ${d.base.ref}: ${sites(d.base.sites)}, ${usd(d.base.monthlyCost)}/mo`);
791
+ console.log(` ${d.head.ref}: ${sites(d.head.sites)}, ${usd(d.head.monthlyCost)}/mo`);
792
+ console.log(` change: ${sign(d.monthlyDelta)}/mo`);
793
+ for (const f of d.files) {
794
+ console.log(` ${f.file} (${f.sdk}): ${f.sitesBefore} → ${f.sitesAfter} site${f.sitesAfter === 1 ? "" : "s"}, ${sign(f.monthlyDelta)}/mo`);
795
+ }
796
+ return 0;
797
+ } catch (err) {
798
+ console.error(`slash-tokens diff: ${err.message}`);
799
+ return 1;
800
+ }
801
+ }
694
802
  function flag(argv, name) {
695
803
  const eq = argv.find((a) => a.startsWith(`--${name}=`));
696
804
  if (eq)
@@ -748,7 +856,7 @@ async function runQuote(argv) {
748
856
  }
749
857
  function packageVersion() {
750
858
  const here = dirname(fileURLToPath(import.meta.url));
751
- const pkg = JSON.parse(readFileSync2(join2(here, "..", "package.json"), "utf8"));
859
+ const pkg = JSON.parse(readFileSync2(join3(here, "..", "package.json"), "utf8"));
752
860
  return pkg.version;
753
861
  }
754
862
  function printHelp() {
@@ -761,6 +869,9 @@ function printHelp() {
761
869
  [--max-output N] [--min-output N] [--budget USD] [--floor 1-4] [--json]
762
870
  price a job and decide: go, downgrade or block
763
871
 
872
+ slash-tokens diff <base> [head] [--json]
873
+ what a git change does to the scan's monthly LLM cost
874
+
764
875
  --version, -V print version and exit
765
876
  --help, -h print this help and exit
766
877
  --key=KEY optional; CLI scans never charge
package/dist/diff.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ export interface ScanSide {
2
+ /** The git ref scanned, or "working tree". */
3
+ ref: string;
4
+ sites: number;
5
+ monthlyCost: number;
6
+ }
7
+ export interface FileDelta {
8
+ file: string;
9
+ sdk: string;
10
+ sitesBefore: number;
11
+ sitesAfter: number;
12
+ monthlyBefore: number;
13
+ monthlyAfter: number;
14
+ monthlyDelta: number;
15
+ }
16
+ export interface ScanDiff {
17
+ base: ScanSide;
18
+ head: ScanSide;
19
+ /** head.monthlyCost − base.monthlyCost, USD a month, at the scan's assumed traffic. */
20
+ monthlyDelta: number;
21
+ /** Files whose LLM call sites or cost changed, largest change first. */
22
+ files: FileDelta[];
23
+ callsPerSitePerDay: number;
24
+ }
25
+ /**
26
+ * What a change does to the scan's monthly LLM cost: scan `base` and `head`
27
+ * (default: the working tree) of the git repo at `dir`, and compare. Same
28
+ * assumptions as the scan (100 calls a site a day, output twice the input,
29
+ * each site priced at its SDK's representative model).
30
+ */
31
+ export declare function scanDiff(dir: string, base: string, head?: string): ScanDiff;
package/dist/diff.js ADDED
@@ -0,0 +1,78 @@
1
+ import { mkdtempSync, rmSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join, relative } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { scan } from './scanner.js';
6
+ import { estimateMonthly, CALLS_PER_SITE_PER_DAY } from './report.js';
7
+ const round2 = (n) => Math.round(n * 100) / 100;
8
+ /** Unpack one git ref of the repo at `dir` into a temp folder (git archive). */
9
+ function exportRef(dir, ref) {
10
+ const out = mkdtempSync(join(tmpdir(), 'slash-diff-'));
11
+ const archive = spawnSync('git', ['-C', dir, 'archive', '--format=tar', ref], { maxBuffer: 512 * 1024 * 1024 });
12
+ if (archive.status !== 0) {
13
+ rmSync(out, { recursive: true, force: true });
14
+ throw new Error(`git archive ${ref} failed: ${archive.stderr?.toString().trim() || 'unknown error'}`);
15
+ }
16
+ const untar = spawnSync('tar', ['-x', '-C', out], { input: archive.stdout });
17
+ if (untar.status !== 0) {
18
+ rmSync(out, { recursive: true, force: true });
19
+ throw new Error(`could not unpack ${ref}: ${untar.stderr?.toString().trim()}`);
20
+ }
21
+ return out;
22
+ }
23
+ function scanSide(root) {
24
+ const { sites } = scan(root);
25
+ return {
26
+ sites: sites.map(s => ({ ...s, rel: relative(root, s.file) })),
27
+ monthly: estimateMonthly(sites).monthlyCost,
28
+ };
29
+ }
30
+ /**
31
+ * What a change does to the scan's monthly LLM cost: scan `base` and `head`
32
+ * (default: the working tree) of the git repo at `dir`, and compare. Same
33
+ * assumptions as the scan (100 calls a site a day, output twice the input,
34
+ * each site priced at its SDK's representative model).
35
+ */
36
+ export function scanDiff(dir, base, head) {
37
+ const baseDir = exportRef(dir, base);
38
+ const headDir = head ? exportRef(dir, head) : dir;
39
+ try {
40
+ const b = scanSide(baseDir);
41
+ const h = scanSide(headDir);
42
+ const groups = new Map();
43
+ const add = (s, side) => {
44
+ const key = `${s.rel}\u0000${s.sdk}`;
45
+ const g = groups.get(key) ?? { file: s.rel, sdk: s.sdk, before: [], after: [] };
46
+ g[side].push(s);
47
+ groups.set(key, g);
48
+ };
49
+ b.sites.forEach(s => add(s, 'before'));
50
+ h.sites.forEach(s => add(s, 'after'));
51
+ const files = [];
52
+ for (const g of groups.values()) {
53
+ const monthlyBefore = estimateMonthly(g.before).monthlyCost;
54
+ const monthlyAfter = estimateMonthly(g.after).monthlyCost;
55
+ if (g.before.length === g.after.length && round2(monthlyBefore) === round2(monthlyAfter))
56
+ continue;
57
+ files.push({
58
+ file: g.file, sdk: g.sdk,
59
+ sitesBefore: g.before.length, sitesAfter: g.after.length,
60
+ monthlyBefore: round2(monthlyBefore), monthlyAfter: round2(monthlyAfter),
61
+ monthlyDelta: round2(monthlyAfter - monthlyBefore),
62
+ });
63
+ }
64
+ files.sort((x, y) => Math.abs(y.monthlyDelta) - Math.abs(x.monthlyDelta) || x.file.localeCompare(y.file));
65
+ return {
66
+ base: { ref: base, sites: b.sites.length, monthlyCost: round2(b.monthly) },
67
+ head: { ref: head ?? 'working tree', sites: h.sites.length, monthlyCost: round2(h.monthly) },
68
+ monthlyDelta: round2(h.monthly - b.monthly),
69
+ files,
70
+ callsPerSitePerDay: CALLS_PER_SITE_PER_DAY,
71
+ };
72
+ }
73
+ finally {
74
+ rmSync(baseDir, { recursive: true, force: true });
75
+ if (head)
76
+ rmSync(headDir, { recursive: true, force: true });
77
+ }
78
+ }
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export type { Task, Message, Quote, Policy, Decision, Action } from './quote.js'
6
6
  export { reconcile, normalizeUsage } from './receipt.js';
7
7
  export type { Usage, Receipt, ReconcileOptions } from './receipt.js';
8
8
  export { hire } from './agent.js';
9
- export type { HireOptions, Job, CallModel, RunResult, Agent } from './agent.js';
9
+ export type { HireOptions, Job, CallModel, CallOptions, RunResult, Agent } from './agent.js';
10
10
  export { CATALOG, TIER_NAMES } from './catalog.js';
11
11
  export type { CatalogEntry, Tier } from './catalog.js';
12
12
  export { PROVIDER_MODELS, providerOf } from './providers.js';
package/dist/models.js CHANGED
@@ -2,7 +2,7 @@ import { CATALOG, API_ALIASES } from './catalog.js';
2
2
  // The price table, derived from the catalog (catalog.ts): same keys, same
3
3
  // order, same fields as before 1.7.0. Old keys stay as aliases so existing
4
4
  // call sites don't throw.
5
- export const MODELS = Object.fromEntries(Object.entries(CATALOG).map(([name, { provider: _p, tier: _t, asOf: _a, priceUntil: _u, ...price }]) => [name, price]));
5
+ export const MODELS = Object.fromEntries(Object.entries(CATALOG).map(([name, { provider: _p, tier: _t, gen: _g, asOf: _a, priceUntil: _u, ...price }]) => [name, price]));
6
6
  /**
7
7
  * Real API IDs → table keys, strictly: lowercase, drop a trailing date stamp
8
8
  * (`-20250514`), and write version numbers with dots (`claude-opus-4-7` →
package/dist/quote.d.ts CHANGED
@@ -42,6 +42,13 @@ export interface Policy {
42
42
  * own tier, so nothing is swapped for a smaller line unless you allow it.
43
43
  */
44
44
  floor?: Tier;
45
+ /**
46
+ * Let a substitute be an older generation of the requested model's own
47
+ * line (same tier: Gemini 3.8 Flash → 2.5 Flash). Default false: a tier is a
48
+ * product line, not a generation, so "same tier" alone doesn't mean "as
49
+ * good". A smaller current line (floor 1: Opus 5.5 → Haiku) is unaffected.
50
+ */
51
+ allowOlder?: boolean;
45
52
  }
46
53
  export type Action = 'go' | 'downgrade' | 'block';
47
54
  export interface Decision {
@@ -92,7 +99,8 @@ export declare function quote(task: Task): Quote;
92
99
  * Choose what to run under a budget and a quality floor.
93
100
  *
94
101
  * Candidates are the requested model plus its same-provider siblings at or
95
- * above the floor (never a NOT_ROUTE_TARGETS model, never one excluded by
102
+ * above the floor, never an older generation of the requested line (unless allowOlder; never
103
+ * a NOT_ROUTE_TARGETS model, never one excluded by
96
104
  * init({ models }), none at all after init({ route: false })). Each is quoted
97
105
  * on its own token count. A substitute must cost less than the requested
98
106
  * model — decide() never raises the bill. The cheapest candidate that fits
package/dist/quote.js CHANGED
@@ -80,7 +80,8 @@ export function quote(task) {
80
80
  * Choose what to run under a budget and a quality floor.
81
81
  *
82
82
  * Candidates are the requested model plus its same-provider siblings at or
83
- * above the floor (never a NOT_ROUTE_TARGETS model, never one excluded by
83
+ * above the floor, never an older generation of the requested line (unless allowOlder; never
84
+ * a NOT_ROUTE_TARGETS model, never one excluded by
84
85
  * init({ models }), none at all after init({ route: false })). Each is quoted
85
86
  * on its own token count. A substitute must cost less than the requested
86
87
  * model — decide() never raises the bill. The cheapest candidate that fits
@@ -98,6 +99,9 @@ export function decide(task, policy = {}) {
98
99
  continue;
99
100
  if (CATALOG[m].tier < floor)
100
101
  continue;
102
+ // Older generation of the same line (tier) only: a smaller current line is fine.
103
+ if (!policy.allowOlder && CATALOG[m].tier === CATALOG[requested.model].tier && CATALOG[m].gen < CATALOG[requested.model].gen)
104
+ continue;
101
105
  const q = quote({ ...task, model: m });
102
106
  if (!ok(q) || q.cost.high >= requested.cost.high)
103
107
  continue; // never raise the cost
package/dist/report.d.ts CHANGED
@@ -1,2 +1,16 @@
1
1
  import { CallSite } from './scanner.js';
2
+ /** Assumed traffic for the scan's estimate: 100 calls per site per day, output twice the input. */
3
+ export declare const CALLS_PER_SITE_PER_DAY = 100;
4
+ export interface MonthlyEstimate {
5
+ callsPerDay: number;
6
+ dailyInput: number;
7
+ dailyOutput: number;
8
+ dailyTotal: number;
9
+ monthlyCostInput: number;
10
+ monthlyCostOutput: number;
11
+ monthlyCost: number;
12
+ unknownProviderSites: number;
13
+ }
14
+ /** The scan's daily token volume and monthly cost for a set of call sites. */
15
+ export declare function estimateMonthly(sites: CallSite[]): MonthlyEstimate;
2
16
  export declare function printReport(sites: CallSite[], filesScanned: number, timeMs: number, cwd: string): void;
package/dist/report.js CHANGED
@@ -14,30 +14,11 @@ function pad(s, n) {
14
14
  function formatNum(n) {
15
15
  return n.toLocaleString('en-US');
16
16
  }
17
- export function printReport(sites, filesScanned, timeMs, cwd) {
18
- console.log('');
19
- console.log(`${ORANGE}${B} ⚡ /slash${R}`);
20
- console.log(`${GRAY} Token optimization analysis${R}`);
21
- console.log('');
22
- console.log(`${DIM} Evaluated ${filesScanned} files in ${timeMs}ms${R}`);
23
- console.log('');
24
- if (sites.length === 0) {
25
- console.log(`${WHITE} No AI API call sites detected.${R}`);
26
- console.log(`${GRAY} Supported: OpenAI, Anthropic, Vercel AI, LangChain, Gemini, Bedrock, Grok${R}`);
27
- console.log(`${GRAY} Run this in a project that already calls an LLM.${R}`);
28
- console.log('');
29
- return;
30
- }
31
- // Call sites
32
- console.log(`${ORANGE} CALL SITES${R}`);
33
- console.log(`${DIM} ${'─'.repeat(60)}${R}`);
34
- for (const site of sites) {
35
- const loc = site.file.replace(cwd + '/', '') + ':' + site.line;
36
- console.log(`${WHITE} ${pad(loc, 40)} ${GOLD}${pad(site.sdk, 12)} ${ORANGE}~${formatNum(site.tokensPerCall)} tok/call${R}`);
37
- }
38
- console.log('');
39
- // Daily estimates (100 calls/site/day)
40
- const callsPerDay = 100;
17
+ /** Assumed traffic for the scan's estimate: 100 calls per site per day, output twice the input. */
18
+ export const CALLS_PER_SITE_PER_DAY = 100;
19
+ /** The scan's daily token volume and monthly cost for a set of call sites. */
20
+ export function estimateMonthly(sites) {
21
+ const callsPerDay = CALLS_PER_SITE_PER_DAY;
41
22
  const dailyInput = sites.reduce((sum, s) => sum + s.tokensPerCall * callsPerDay, 0);
42
23
  const dailyOutput = dailyInput * 2; // Estimate output at 2x input
43
24
  const dailyTotal = dailyInput + dailyOutput;
@@ -62,6 +43,31 @@ export function printReport(sites, filesScanned, timeMs, cwd) {
62
43
  monthlyCostOutput += (siteDailyOutput * 30 / 1000000) * info.output;
63
44
  }
64
45
  const monthlyCost = monthlyCostInput + monthlyCostOutput;
46
+ return { callsPerDay, dailyInput, dailyOutput, dailyTotal, monthlyCostInput, monthlyCostOutput, monthlyCost, unknownProviderSites };
47
+ }
48
+ export function printReport(sites, filesScanned, timeMs, cwd) {
49
+ console.log('');
50
+ console.log(`${ORANGE}${B} ⚡ /slash${R}`);
51
+ console.log(`${GRAY} Token optimization analysis${R}`);
52
+ console.log('');
53
+ console.log(`${DIM} Evaluated ${filesScanned} files in ${timeMs}ms${R}`);
54
+ console.log('');
55
+ if (sites.length === 0) {
56
+ console.log(`${WHITE} No AI API call sites detected.${R}`);
57
+ console.log(`${GRAY} Supported: OpenAI, Anthropic, Vercel AI, LangChain, Gemini, Bedrock, Grok${R}`);
58
+ console.log(`${GRAY} Run this in a project that already calls an LLM.${R}`);
59
+ console.log('');
60
+ return;
61
+ }
62
+ // Call sites
63
+ console.log(`${ORANGE} CALL SITES${R}`);
64
+ console.log(`${DIM} ${'─'.repeat(60)}${R}`);
65
+ for (const site of sites) {
66
+ const loc = site.file.replace(cwd + '/', '') + ':' + site.line;
67
+ console.log(`${WHITE} ${pad(loc, 40)} ${GOLD}${pad(site.sdk, 12)} ${ORANGE}~${formatNum(site.tokensPerCall)} tok/call${R}`);
68
+ }
69
+ console.log('');
70
+ const { callsPerDay, dailyInput, dailyOutput, dailyTotal, monthlyCostInput, monthlyCostOutput, monthlyCost, unknownProviderSites } = estimateMonthly(sites);
65
71
  console.log(`${ORANGE} DAILY TOKEN VOLUME ${GRAY}(${callsPerDay} calls/site/day)${R}`);
66
72
  console.log(`${DIM} ${'─'.repeat(60)}${R}`);
67
73
  console.log(`${WHITE} Input tokens: ${B}${formatNum(dailyInput)}${R}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slash-tokens",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Know what an LLM call will cost before you make it, and prove what you saved after. Quote, decide, receipt. 4.8 KB WASM, zero dependencies.",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",