slash-tokens 1.7.0 → 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 +37 -1
- package/README.md +19 -3
- package/dist/agent.d.ts +9 -2
- package/dist/agent.js +3 -3
- package/dist/catalog.d.ts +7 -0
- package/dist/catalog.js +50 -50
- package/dist/cli.js +221 -101
- package/dist/diff.d.ts +31 -0
- package/dist/diff.js +78 -0
- package/dist/index.d.ts +1 -1
- package/dist/models.js +1 -1
- package/dist/patterns.d.ts +12 -1
- package/dist/patterns.js +10 -10
- package/dist/preflight.d.ts +3 -3
- package/dist/preflight.js +3 -3
- package/dist/quote.d.ts +17 -5
- package/dist/quote.js +13 -5
- package/dist/report.d.ts +14 -0
- package/dist/report.js +31 -25
- package/dist/scanner.js +26 -19
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
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
|
+
|
|
22
|
+
## [1.7.1] — The Hired Agent Edition
|
|
23
|
+
|
|
24
|
+
*2026-10-10*
|
|
25
|
+
|
|
26
|
+
The scan counts each real LLM request once.
|
|
27
|
+
|
|
28
|
+
No change to `quote()`, `decide()`, `reconcile()`, `hire()`, `/auto` or prices.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **The scan (`npx slash-tokens`) counts each LLM request once.** It counted every import, client constructor and request line as its own call site, so one `client.messages.create(...)` behind `import Anthropic` and `new Anthropic()` was reported as two sites (and the request itself wasn't matched unless the client variable was named `anthropic`). The monthly estimate is per site, so it came out inflated: a two-request sample project read 4 sites and $35.77/mo in 1.7.0, and reads 2 sites and $17.89/mo now. A request is now one site; a file that only imports or builds a client counts once. Requests on any client variable are matched (`.messages.create(`, `.chat.completions.create(`, `.responses.create(`, `generateText(` …), in Python too.
|
|
32
|
+
- **The scan no longer counts look-alike calls.** Matching any client variable made `.messages.create(` also catch Twilio's SMS API and `.invoke(` catch Tauri / Electron IPC. A request now counts only in a file that uses that SDK (an import or a client: `import anthropic`, `from openai import`, `new Anthropic()`, `AsyncOpenAI()` …), unless the call names the provider itself (`fetch` to an AI API URL, `x.ai/api`, `cohere.chat`). Python Gemini (`generate_content`) is matched too.
|
|
33
|
+
- The scan's footnote reads "1 call site uses an assumed price" (was "use").
|
|
34
|
+
|
|
35
|
+
### Docs
|
|
36
|
+
- **Accuracy claims now say what was measured.** Request framing is measured on Nebius only; xAI comes from the Grok bench, OpenAI from its tiktoken cookbook, Anthropic and Google are allowances. The npm README's accuracy line, the root README's accuracy gate (GPT-5.x, not all GPT; GPT-6 takes the conservative default) and the `quote()` / `FRAMING` doc comments now say so. The 1.7.0 entry below called all framing "measured" and its live check didn't name Nebius; both are corrected in place.
|
|
37
|
+
- **`TEST-NOTES.md` is current.** It was the v1.4.0 plan: retired models, and a "preflightRoute agrees with /auto" invariant that has been false by design since 1.6.6 (`/auto` is frozen at its 1.6.5 targets). It now maps each invariant to the test that holds it and lists what isn't tested yet. Two of its invariants had no test and now do: `preflightRoute()` never picks a model the prompt doesn't fit, and `intercept.ts` uses the shared `PROVIDER_MODELS`. The `preflightRoute()` tie-break comment now matches the code (lower list price, not list order).
|
|
38
|
+
|
|
3
39
|
## [1.7.0] — The Hired Agent Edition
|
|
4
40
|
|
|
5
41
|
*2026-10-08*
|
|
@@ -14,7 +50,7 @@ New: `quote()`, `decide()`, `reconcile()`, `hire().run()`, `slash-tokens quote`,
|
|
|
14
50
|
- **`reconcile(quote, usage, { baseline, feeRate, waived })`** writes the receipt after a call: estimated vs actual tokens and cost (from the provider's own `usage`, OpenAI-style or Anthropic-style), `underReported` (the estimate counted fewer input tokens than were billed — Slash should never do this), whether the cost stayed within the quote, what was saved against a baseline model, and the agent's fee (10% of measured savings by default, shown and waived by default, never charged on a loss). Cached input is priced at the full input rate, so a cached call's cost is an upper bound.
|
|
15
51
|
- **`hire({ budget, baseline, floor, feeRate, waived }).run(job)`**: the agent you hire. Each job is quoted, booked on the cheapest model the policy allows (or blocked), run through your own `call(model, input)`, and reconciled into a receipt. The budget covers all jobs: each one may spend only what earlier jobs left. Blocked jobs never call the model. `agent.spent`, `agent.remaining`, `agent.receipts`.
|
|
16
52
|
- **`slash-tokens quote --model M [--file F | --text T | stdin] [--max-output N] [--min-output N] [--budget USD] [--floor 1-4] [--json]`** prints the quote and the decision (`--json` for pipelines). Exit 0 for go, downgrade and block (read `action`); 1 for bad input.
|
|
17
|
-
- **Quotes include each provider's request framing.** A provider bills the chat template and system preamble on top of your content. Content-only counting quoted 12 input tokens for a real Nebius call billed 23. `quote()` now adds
|
|
53
|
+
- **Quotes include each provider's request framing.** A provider bills the chat template and system preamble on top of your content. Content-only counting quoted 12 input tokens for a real Nebius call billed 23. `quote()` now adds framing: Nebius 16 per request + 7 per extra message, measured (real Token Factory calls, recorded in `bench/results-nemotron-requests.json`), xAI's 193-token system preamble (the Grok bench baseline), OpenAI's 3-per-message rule (its tiktoken cookbook), and a conservative allowance for Anthropic and Google. Two live Nebius calls after the fix: 4% and 8% over the bill, never under. `tests/request-framing.test.ts` holds every recorded bill. `preflight()` stays a content-only go/no-go.
|
|
18
54
|
- **`reconcile()` reads Nebius cache hits** (`prompt_cache_hit_tokens`).
|
|
19
55
|
- **npm metadata:** description now says what 1.7.0 does; keywords add nemotron, nvidia, nebius, agent, cost.
|
|
20
56
|
- **The catalog** (`CATALOG`): one source for every model's prices, context, provider, capability tier (1 small · 2 mid · 3 flagship · 4 frontier — the vendor's own line position, compared within one provider only) and `asOf` date. `MODELS` is now derived from it, with the same keys, order and fields.
|
package/README.md
CHANGED
|
@@ -10,7 +10,22 @@
|
|
|
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.
|
|
13
|
+
## v1.8.0 — The Merge Gate Edition
|
|
14
|
+
|
|
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
|
+
```
|
|
25
|
+
|
|
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
|
|
14
29
|
|
|
15
30
|
Quote the job, book the right model, prove it with a receipt.
|
|
16
31
|
|
|
@@ -23,7 +38,7 @@ const { decision, receipt } = await agent.run({
|
|
|
23
38
|
input: prompt,
|
|
24
39
|
model: 'nemotron-3-ultra',
|
|
25
40
|
maxOutputTokens: 2000,
|
|
26
|
-
call: (model, input) => yourClient(model, input), // returns { output, usage }
|
|
41
|
+
call: (model, input, { maxOutputTokens }) => yourClient(model, input, maxOutputTokens), // returns { output, usage }
|
|
27
42
|
})
|
|
28
43
|
|
|
29
44
|
decision.action // 'downgrade': nemotron-3-super does the job for less
|
|
@@ -35,7 +50,7 @@ receipt.saved // dollars saved against the model you asked for
|
|
|
35
50
|
- **Prove:** `reconcile()` checks the quote against what the provider billed.
|
|
36
51
|
- **Hire:** `hire()` does all three for every job, inside one budget. Its cut, 10% of measured savings, is on every receipt, waived.
|
|
37
52
|
|
|
38
|
-
Counts are calibrated against each provider's real tokenizer
|
|
53
|
+
Counts are calibrated against each provider's real tokenizer; models not benchmarked yet (GPT-6) get a conservative default. Quotes add what the provider bills on top of your text, measured on Nebius and a conservative allowance elsewhere. CI fails if a count comes in under the real one, or a quote under a real Nebius bill.
|
|
39
54
|
|
|
40
55
|
## Install
|
|
41
56
|
|
|
@@ -48,6 +63,7 @@ npm install slash-tokens
|
|
|
48
63
|
```bash
|
|
49
64
|
npx slash-tokens # find the LLM calls in this project and what they cost a month
|
|
50
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
|
|
51
67
|
```
|
|
52
68
|
|
|
53
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
|
|
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;
|
|
@@ -265,16 +265,16 @@ function slash(content, model) {
|
|
|
265
265
|
|
|
266
266
|
// src/patterns.ts
|
|
267
267
|
var AI_PATTERNS = [
|
|
268
|
-
{ name: "OpenAI",
|
|
269
|
-
{ name: "Anthropic",
|
|
270
|
-
{ name: "Vercel AI",
|
|
271
|
-
{ name: "LangChain",
|
|
272
|
-
{ name: "Gemini",
|
|
273
|
-
{ name: "Bedrock",
|
|
274
|
-
{ name: "Grok",
|
|
275
|
-
{ name: "fetch (AI)",
|
|
276
|
-
{ name: "Cohere",
|
|
277
|
-
{ name: "Mistral",
|
|
268
|
+
{ name: "OpenAI", call: /\.(?:chat\.completions|responses)\.create\(/g, uses: /\b(?:Async|Azure)?OpenAI\(|from\s+['"]openai['"]|^\s*(?:from\s+openai\s+import|import\s+openai)\b/gm },
|
|
269
|
+
{ name: "Anthropic", call: /\.messages\.(?:create|stream)\(/g, uses: /\b(?:Async)?Anthropic\(|from\s+['"]@anthropic-ai|^\s*(?:from\s+anthropic\s+import|import\s+anthropic)\b/gm },
|
|
270
|
+
{ name: "Vercel AI", call: /\b(?:generateText|streamText|generateObject|streamObject)\(/g, uses: /from\s+['"]ai['"]/g },
|
|
271
|
+
{ name: "LangChain", call: /\.invoke\(/g, uses: /from\s+['"]@?langchain|^\s*(?:from|import)\s+langchain|ChatOpenAI|ChatAnthropic/gm },
|
|
272
|
+
{ name: "Gemini", call: /\.(?:generateContent(?:Stream)?|generate_content(?:_stream)?)\(/g, uses: /GoogleGenerativeAI|from\s+['"]@google\/gen|^\s*(?:import\s+google\.generativeai|from\s+google(?:\.genai|\s+import\s+genai))/gm },
|
|
273
|
+
{ name: "Bedrock", call: /InvokeModelCommand\(/g, uses: /BedrockRuntimeClient/g },
|
|
274
|
+
{ name: "Grok", call: /x\.ai\/api|xai\.chat/g, uses: /from\s+['"]grok/g, standalone: true },
|
|
275
|
+
{ name: "fetch (AI)", call: /fetch\(.*api\.openai\.com|fetch\(.*api\.anthropic\.com|fetch\(.*generativelanguage\.googleapis/g, standalone: true },
|
|
276
|
+
{ name: "Cohere", call: /cohere\.chat/g, uses: /from\s+['"]cohere|CohereClient/g, standalone: true },
|
|
277
|
+
{ name: "Mistral", uses: /from\s+['"]@mistralai|MistralClient/g }
|
|
278
278
|
];
|
|
279
279
|
var SDK_REPRESENTATIVE_MODEL = {
|
|
280
280
|
Anthropic: "claude-sonnet-5",
|
|
@@ -337,32 +337,41 @@ function walkDir(dir, files) {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
|
+
function matchLines(regex, content) {
|
|
341
|
+
if (!regex)
|
|
342
|
+
return [];
|
|
343
|
+
const found = [];
|
|
344
|
+
regex.lastIndex = 0;
|
|
345
|
+
let match;
|
|
346
|
+
while ((match = regex.exec(content)) !== null) {
|
|
347
|
+
const line = content.substring(0, match.index).split(`
|
|
348
|
+
`).length;
|
|
349
|
+
if (!found.includes(line))
|
|
350
|
+
found.push(line);
|
|
351
|
+
}
|
|
352
|
+
return found;
|
|
353
|
+
}
|
|
340
354
|
function findCallSites(filePath, content) {
|
|
341
355
|
const sites = [];
|
|
342
356
|
const lines = content.split(`
|
|
343
357
|
`);
|
|
344
358
|
for (const pattern of AI_PATTERNS) {
|
|
345
|
-
pattern.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
`).length;
|
|
359
|
+
const usesLines = matchLines(pattern.uses, content);
|
|
360
|
+
const callLines = pattern.standalone || !pattern.uses || usesLines.length > 0 ? matchLines(pattern.call, content) : [];
|
|
361
|
+
const siteLines = callLines.length > 0 ? callLines : usesLines.slice(0, 1);
|
|
362
|
+
const estimatedModel = SDK_REPRESENTATIVE_MODEL[pattern.name] ?? UNKNOWN_SDK_REPRESENTATIVE_MODEL;
|
|
363
|
+
for (const lineNum of siteLines) {
|
|
351
364
|
const startLine = Math.max(0, lineNum - 5);
|
|
352
365
|
const endLine = Math.min(lines.length, lineNum + 20);
|
|
353
366
|
const context = lines.slice(startLine, endLine).join(`
|
|
354
367
|
`);
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
tokensPerCall,
|
|
363
|
-
estimatedModel
|
|
364
|
-
});
|
|
365
|
-
}
|
|
368
|
+
sites.push({
|
|
369
|
+
file: filePath,
|
|
370
|
+
line: lineNum,
|
|
371
|
+
sdk: pattern.name,
|
|
372
|
+
tokensPerCall: slash(context, estimatedModel),
|
|
373
|
+
estimatedModel
|
|
374
|
+
});
|
|
366
375
|
}
|
|
367
376
|
}
|
|
368
377
|
return sites;
|
|
@@ -402,6 +411,30 @@ function pad(s, n) {
|
|
|
402
411
|
function formatNum(n) {
|
|
403
412
|
return n.toLocaleString("en-US");
|
|
404
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
|
+
}
|
|
405
438
|
function printReport(sites, filesScanned, timeMs, cwd) {
|
|
406
439
|
console.log("");
|
|
407
440
|
console.log(`${ORANGE}${B} ⚡ /slash${R}`);
|
|
@@ -423,26 +456,7 @@ function printReport(sites, filesScanned, timeMs, cwd) {
|
|
|
423
456
|
console.log(`${WHITE} ${pad(loc, 40)} ${GOLD}${pad(site.sdk, 12)} ${ORANGE}~${formatNum(site.tokensPerCall)} tok/call${R}`);
|
|
424
457
|
}
|
|
425
458
|
console.log("");
|
|
426
|
-
const callsPerDay =
|
|
427
|
-
const dailyInput = sites.reduce((sum, s) => sum + s.tokensPerCall * callsPerDay, 0);
|
|
428
|
-
const dailyOutput = dailyInput * 2;
|
|
429
|
-
const dailyTotal = dailyInput + dailyOutput;
|
|
430
|
-
let monthlyCostInput = 0;
|
|
431
|
-
let monthlyCostOutput = 0;
|
|
432
|
-
let unknownProviderSites = 0;
|
|
433
|
-
for (const site of sites) {
|
|
434
|
-
const info = getModel(site.estimatedModel);
|
|
435
|
-
if (!info)
|
|
436
|
-
continue;
|
|
437
|
-
if (site.estimatedModel === UNKNOWN_SDK_REPRESENTATIVE_MODEL && !["Anthropic"].includes(site.sdk)) {
|
|
438
|
-
unknownProviderSites++;
|
|
439
|
-
}
|
|
440
|
-
const siteDailyInput = site.tokensPerCall * callsPerDay;
|
|
441
|
-
const siteDailyOutput = siteDailyInput * 2;
|
|
442
|
-
monthlyCostInput += siteDailyInput * 30 / 1e6 * info.input;
|
|
443
|
-
monthlyCostOutput += siteDailyOutput * 30 / 1e6 * info.output;
|
|
444
|
-
}
|
|
445
|
-
const monthlyCost = monthlyCostInput + monthlyCostOutput;
|
|
459
|
+
const { callsPerDay, dailyInput, dailyOutput, dailyTotal, monthlyCostInput, monthlyCostOutput, monthlyCost, unknownProviderSites } = estimateMonthly(sites);
|
|
446
460
|
console.log(`${ORANGE} DAILY TOKEN VOLUME ${GRAY}(${callsPerDay} calls/site/day)${R}`);
|
|
447
461
|
console.log(`${DIM} ${"─".repeat(60)}${R}`);
|
|
448
462
|
console.log(`${WHITE} Input tokens: ${B}${formatNum(dailyInput)}${R}`);
|
|
@@ -455,7 +469,7 @@ function printReport(sites, filesScanned, timeMs, cwd) {
|
|
|
455
469
|
console.log(`${WHITE} Output: ${B}$${monthlyCostOutput.toFixed(2)}/mo${R}`);
|
|
456
470
|
console.log(`${WHITE} Total: ${ORANGE}${B}$${monthlyCost.toFixed(2)}/mo${R}`);
|
|
457
471
|
if (unknownProviderSites > 0) {
|
|
458
|
-
console.log(`${GRAY} (${unknownProviderSites}
|
|
472
|
+
console.log(`${GRAY} (${unknownProviderSites} ${unknownProviderSites === 1 ? "call site uses" : "call sites use"} an assumed price — the detected SDK doesn't reveal the exact provider)${R}`);
|
|
459
473
|
}
|
|
460
474
|
console.log("");
|
|
461
475
|
console.log(`${GRAY} 4.8 KB WASM · sub-ms · zero deps${R}`);
|
|
@@ -619,6 +633,8 @@ function decide(task, policy = {}) {
|
|
|
619
633
|
continue;
|
|
620
634
|
if (CATALOG[m].tier < floor)
|
|
621
635
|
continue;
|
|
636
|
+
if (!policy.allowOlder && CATALOG[m].tier === CATALOG[requested.model].tier && CATALOG[m].gen < CATALOG[requested.model].gen)
|
|
637
|
+
continue;
|
|
622
638
|
const q = quote({ ...task, model: m });
|
|
623
639
|
if (!ok(q) || q.cost.high >= requested.cost.high)
|
|
624
640
|
continue;
|
|
@@ -644,6 +660,79 @@ function decide(task, policy = {}) {
|
|
|
644
660
|
};
|
|
645
661
|
}
|
|
646
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
|
+
|
|
647
736
|
// src/cli.ts
|
|
648
737
|
var args = process.argv.slice(2);
|
|
649
738
|
if (args.includes("--version") || args.includes("-V")) {
|
|
@@ -656,6 +745,8 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
656
745
|
}
|
|
657
746
|
if (args[0] === "quote") {
|
|
658
747
|
runQuote(args.slice(1)).then((code) => process.exit(code));
|
|
748
|
+
} else if (args[0] === "diff") {
|
|
749
|
+
process.exit(runDiff(args.slice(1)));
|
|
659
750
|
} else {
|
|
660
751
|
scanHere();
|
|
661
752
|
}
|
|
@@ -682,6 +773,32 @@ function scanHere() {
|
|
|
682
773
|
console.log("");
|
|
683
774
|
}
|
|
684
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
|
+
}
|
|
685
802
|
function flag(argv, name) {
|
|
686
803
|
const eq = argv.find((a) => a.startsWith(`--${name}=`));
|
|
687
804
|
if (eq)
|
|
@@ -739,7 +856,7 @@ async function runQuote(argv) {
|
|
|
739
856
|
}
|
|
740
857
|
function packageVersion() {
|
|
741
858
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
742
|
-
const pkg = JSON.parse(readFileSync2(
|
|
859
|
+
const pkg = JSON.parse(readFileSync2(join3(here, "..", "package.json"), "utf8"));
|
|
743
860
|
return pkg.version;
|
|
744
861
|
}
|
|
745
862
|
function printHelp() {
|
|
@@ -752,6 +869,9 @@ function printHelp() {
|
|
|
752
869
|
[--max-output N] [--min-output N] [--budget USD] [--floor 1-4] [--json]
|
|
753
870
|
price a job and decide: go, downgrade or block
|
|
754
871
|
|
|
872
|
+
slash-tokens diff <base> [head] [--json]
|
|
873
|
+
what a git change does to the scan's monthly LLM cost
|
|
874
|
+
|
|
755
875
|
--version, -V print version and exit
|
|
756
876
|
--help, -h print this help and exit
|
|
757
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/patterns.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
export interface Pattern {
|
|
2
2
|
name: string;
|
|
3
|
-
|
|
3
|
+
/** A request to the model. Each match is one call site. */
|
|
4
|
+
call?: RegExp;
|
|
5
|
+
/** The SDK is in use (an import, a client being built). Not a request:
|
|
6
|
+
* it counts as one call site only in a file with no matched call, e.g. a
|
|
7
|
+
* client built here and passed to a wrapper. An import plus a client plus
|
|
8
|
+
* one request is one call site, not three. */
|
|
9
|
+
uses?: RegExp;
|
|
10
|
+
/** The call names the provider by itself (an API URL, a provider-named
|
|
11
|
+
* client), so it counts without `uses` in the file. Otherwise a call counts
|
|
12
|
+
* only where the SDK is in use: `.messages.create(` is also Twilio's SMS
|
|
13
|
+
* API, and `.invoke(` is also Tauri / Electron IPC. */
|
|
14
|
+
standalone?: boolean;
|
|
4
15
|
}
|
|
5
16
|
export declare const AI_PATTERNS: Pattern[];
|
|
6
17
|
export declare const SDK_REPRESENTATIVE_MODEL: Record<string, string>;
|
package/dist/patterns.js
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
export const AI_PATTERNS = [
|
|
2
2
|
// OpenAI
|
|
3
|
-
{ name: 'OpenAI',
|
|
3
|
+
{ name: 'OpenAI', call: /\.(?:chat\.completions|responses)\.create\(/g, uses: /\b(?:Async|Azure)?OpenAI\(|from\s+['"]openai['"]|^\s*(?:from\s+openai\s+import|import\s+openai)\b/gm },
|
|
4
4
|
// Anthropic
|
|
5
|
-
{ name: 'Anthropic',
|
|
5
|
+
{ name: 'Anthropic', call: /\.messages\.(?:create|stream)\(/g, uses: /\b(?:Async)?Anthropic\(|from\s+['"]@anthropic-ai|^\s*(?:from\s+anthropic\s+import|import\s+anthropic)\b/gm },
|
|
6
6
|
// Vercel AI SDK
|
|
7
|
-
{ name: 'Vercel AI',
|
|
7
|
+
{ name: 'Vercel AI', call: /\b(?:generateText|streamText|generateObject|streamObject)\(/g, uses: /from\s+['"]ai['"]/g },
|
|
8
8
|
// LangChain
|
|
9
|
-
{ name: 'LangChain',
|
|
9
|
+
{ name: 'LangChain', call: /\.invoke\(/g, uses: /from\s+['"]@?langchain|^\s*(?:from|import)\s+langchain|ChatOpenAI|ChatAnthropic/gm },
|
|
10
10
|
// Google Gemini
|
|
11
|
-
{ name: 'Gemini',
|
|
11
|
+
{ name: 'Gemini', call: /\.(?:generateContent(?:Stream)?|generate_content(?:_stream)?)\(/g, uses: /GoogleGenerativeAI|from\s+['"]@google\/gen|^\s*(?:import\s+google\.generativeai|from\s+google(?:\.genai|\s+import\s+genai))/gm },
|
|
12
12
|
// AWS Bedrock
|
|
13
|
-
{ name: 'Bedrock',
|
|
13
|
+
{ name: 'Bedrock', call: /InvokeModelCommand\(/g, uses: /BedrockRuntimeClient/g },
|
|
14
14
|
// xAI Grok
|
|
15
|
-
{ name: 'Grok',
|
|
15
|
+
{ name: 'Grok', call: /x\.ai\/api|xai\.chat/g, uses: /from\s+['"]grok/g, standalone: true },
|
|
16
16
|
// Raw fetch to AI endpoints
|
|
17
|
-
{ name: 'fetch (AI)',
|
|
17
|
+
{ name: 'fetch (AI)', call: /fetch\(.*api\.openai\.com|fetch\(.*api\.anthropic\.com|fetch\(.*generativelanguage\.googleapis/g, standalone: true },
|
|
18
18
|
// Cohere
|
|
19
|
-
{ name: 'Cohere',
|
|
19
|
+
{ name: 'Cohere', call: /cohere\.chat/g, uses: /from\s+['"]cohere|CohereClient/g, standalone: true },
|
|
20
20
|
// Mistral
|
|
21
|
-
{ name: 'Mistral',
|
|
21
|
+
{ name: 'Mistral', uses: /from\s+['"]@mistralai|MistralClient/g },
|
|
22
22
|
];
|
|
23
23
|
// Representative model per detected SDK — the scanner only ever sees an
|
|
24
24
|
// SDK name (a regex match on import/call syntax), never the actual model
|
package/dist/preflight.d.ts
CHANGED
|
@@ -46,9 +46,9 @@ export declare function preflight(content: string, model: string): PreflightResu
|
|
|
46
46
|
* canonical model in PROVIDER_MODELS.
|
|
47
47
|
* - Returns null when: model unknown, provider unknown, or no cheaper same-
|
|
48
48
|
* provider alternative exists. Null is a valid result ("PASS, no route").
|
|
49
|
-
* - Cheapest SAME-PROVIDER alternative
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* - Cheapest SAME-PROVIDER alternative that fits the prompt, by cost for
|
|
50
|
+
* this prompt. When two costs round to the same value (tiny prompts),
|
|
51
|
+
* the lower input list price wins.
|
|
52
52
|
* - Respects the same init() config gates as intercept.ts findCheapestRoute: shouldRoute()
|
|
53
53
|
* (init({route: false}) must make this always return null, matching
|
|
54
54
|
* patchFetch() never routing) and isModelAllowed() (init({models: [...]})
|
package/dist/preflight.js
CHANGED
|
@@ -71,9 +71,9 @@ export function preflight(content, model) {
|
|
|
71
71
|
* canonical model in PROVIDER_MODELS.
|
|
72
72
|
* - Returns null when: model unknown, provider unknown, or no cheaper same-
|
|
73
73
|
* provider alternative exists. Null is a valid result ("PASS, no route").
|
|
74
|
-
* - Cheapest SAME-PROVIDER alternative
|
|
75
|
-
*
|
|
76
|
-
*
|
|
74
|
+
* - Cheapest SAME-PROVIDER alternative that fits the prompt, by cost for
|
|
75
|
+
* this prompt. When two costs round to the same value (tiny prompts),
|
|
76
|
+
* the lower input list price wins.
|
|
77
77
|
* - Respects the same init() config gates as intercept.ts findCheapestRoute: shouldRoute()
|
|
78
78
|
* (init({route: false}) must make this always return null, matching
|
|
79
79
|
* patchFetch() never routing) and isModelAllowed() (init({models: [...]})
|
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 {
|
|
@@ -68,10 +75,13 @@ export declare const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
|
|
68
75
|
* xAI a fixed system preamble of 185–193 tokens per request
|
|
69
76
|
* (baselines in bench/results-grok.json); 193 used.
|
|
70
77
|
* OpenAI 3 per message + 3 to prime the reply (OpenAI's tiktoken
|
|
71
|
-
* cookbook); 4 per message, the first one
|
|
78
|
+
* cookbook, not measured here); 4 per message, the first one
|
|
79
|
+
* inside `request`.
|
|
72
80
|
* Anthropic the calibration ground truth (count_tokens) already includes
|
|
73
|
-
* one message's framing; 4 per extra message
|
|
74
|
-
*
|
|
81
|
+
* one message's framing; 4 per extra message, an allowance,
|
|
82
|
+
* not yet measured.
|
|
83
|
+
* Google countTokens on content; 4 per message, an allowance, not yet
|
|
84
|
+
* measured.
|
|
75
85
|
*/
|
|
76
86
|
export declare const FRAMING: Record<string, {
|
|
77
87
|
request: number;
|
|
@@ -81,14 +91,16 @@ export declare const FRAMING: Record<string, {
|
|
|
81
91
|
* Price a job before it runs: input tokens, an output band, and a low–high
|
|
82
92
|
* cost. Input is the content counted with the model's calibration factor
|
|
83
93
|
* plus the provider's request framing (see FRAMING), so it doesn't come in
|
|
84
|
-
* under the bill
|
|
94
|
+
* under the bill (checked against real bills for Nebius; an allowance for
|
|
95
|
+
* the others); the long-context rate applies when the prompt crosses it.
|
|
85
96
|
*/
|
|
86
97
|
export declare function quote(task: Task): Quote;
|
|
87
98
|
/**
|
|
88
99
|
* Choose what to run under a budget and a quality floor.
|
|
89
100
|
*
|
|
90
101
|
* Candidates are the requested model plus its same-provider siblings at or
|
|
91
|
-
* above the floor
|
|
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
|
|
92
104
|
* init({ models }), none at all after init({ route: false })). Each is quoted
|
|
93
105
|
* on its own token count. A substitute must cost less than the requested
|
|
94
106
|
* model — decide() never raises the bill. The cheapest candidate that fits
|
package/dist/quote.js
CHANGED
|
@@ -17,10 +17,13 @@ export const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
|
|
17
17
|
* xAI a fixed system preamble of 185–193 tokens per request
|
|
18
18
|
* (baselines in bench/results-grok.json); 193 used.
|
|
19
19
|
* OpenAI 3 per message + 3 to prime the reply (OpenAI's tiktoken
|
|
20
|
-
* cookbook); 4 per message, the first one
|
|
20
|
+
* cookbook, not measured here); 4 per message, the first one
|
|
21
|
+
* inside `request`.
|
|
21
22
|
* Anthropic the calibration ground truth (count_tokens) already includes
|
|
22
|
-
* one message's framing; 4 per extra message
|
|
23
|
-
*
|
|
23
|
+
* one message's framing; 4 per extra message, an allowance,
|
|
24
|
+
* not yet measured.
|
|
25
|
+
* Google countTokens on content; 4 per message, an allowance, not yet
|
|
26
|
+
* measured.
|
|
24
27
|
*/
|
|
25
28
|
export const FRAMING = {
|
|
26
29
|
Nebius: { request: 16, perMessage: 7 },
|
|
@@ -44,7 +47,8 @@ function inputTokens(input, model, provider) {
|
|
|
44
47
|
* Price a job before it runs: input tokens, an output band, and a low–high
|
|
45
48
|
* cost. Input is the content counted with the model's calibration factor
|
|
46
49
|
* plus the provider's request framing (see FRAMING), so it doesn't come in
|
|
47
|
-
* under the bill
|
|
50
|
+
* under the bill (checked against real bills for Nebius; an allowance for
|
|
51
|
+
* the others); the long-context rate applies when the prompt crosses it.
|
|
48
52
|
*/
|
|
49
53
|
export function quote(task) {
|
|
50
54
|
const model = canonicalModel(task.model);
|
|
@@ -76,7 +80,8 @@ export function quote(task) {
|
|
|
76
80
|
* Choose what to run under a budget and a quality floor.
|
|
77
81
|
*
|
|
78
82
|
* Candidates are the requested model plus its same-provider siblings at or
|
|
79
|
-
* above the floor
|
|
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
|
|
80
85
|
* init({ models }), none at all after init({ route: false })). Each is quoted
|
|
81
86
|
* on its own token count. A substitute must cost less than the requested
|
|
82
87
|
* model — decide() never raises the bill. The cheapest candidate that fits
|
|
@@ -94,6 +99,9 @@ export function decide(task, policy = {}) {
|
|
|
94
99
|
continue;
|
|
95
100
|
if (CATALOG[m].tier < floor)
|
|
96
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;
|
|
97
105
|
const q = quote({ ...task, model: m });
|
|
98
106
|
if (!ok(q) || q.cost.high >= requested.cost.high)
|
|
99
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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}`);
|
|
@@ -74,7 +80,7 @@ export function printReport(sites, filesScanned, timeMs, cwd) {
|
|
|
74
80
|
console.log(`${WHITE} Output: ${B}$${monthlyCostOutput.toFixed(2)}/mo${R}`);
|
|
75
81
|
console.log(`${WHITE} Total: ${ORANGE}${B}$${monthlyCost.toFixed(2)}/mo${R}`);
|
|
76
82
|
if (unknownProviderSites > 0) {
|
|
77
|
-
console.log(`${GRAY} (${unknownProviderSites}
|
|
83
|
+
console.log(`${GRAY} (${unknownProviderSites} ${unknownProviderSites === 1 ? 'call site uses' : 'call sites use'} an assumed price — the detected SDK doesn't reveal the exact provider)${R}`);
|
|
78
84
|
}
|
|
79
85
|
console.log('');
|
|
80
86
|
console.log(`${GRAY} 4.8 KB WASM · sub-ms · zero deps${R}`);
|
package/dist/scanner.js
CHANGED
|
@@ -29,33 +29,40 @@ function walkDir(dir, files) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
function matchLines(regex, content) {
|
|
33
|
+
if (!regex)
|
|
34
|
+
return [];
|
|
35
|
+
const found = [];
|
|
36
|
+
regex.lastIndex = 0;
|
|
37
|
+
let match;
|
|
38
|
+
while ((match = regex.exec(content)) !== null) {
|
|
39
|
+
const line = content.substring(0, match.index).split('\n').length;
|
|
40
|
+
if (!found.includes(line))
|
|
41
|
+
found.push(line);
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
32
45
|
function findCallSites(filePath, content) {
|
|
33
46
|
const sites = [];
|
|
34
47
|
const lines = content.split('\n');
|
|
35
48
|
for (const pattern of AI_PATTERNS) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const lineNum = beforeMatch.split('\n').length;
|
|
49
|
+
const usesLines = matchLines(pattern.uses, content);
|
|
50
|
+
// A call counts where it names the provider itself, or where the SDK is in use.
|
|
51
|
+
const callLines = pattern.standalone || !pattern.uses || usesLines.length > 0 ? matchLines(pattern.call, content) : [];
|
|
52
|
+
const siteLines = callLines.length > 0 ? callLines : usesLines.slice(0, 1);
|
|
53
|
+
const estimatedModel = SDK_REPRESENTATIVE_MODEL[pattern.name] ?? UNKNOWN_SDK_REPRESENTATIVE_MODEL;
|
|
54
|
+
for (const lineNum of siteLines) {
|
|
43
55
|
// Estimate tokens from surrounding context (grab the function/block)
|
|
44
56
|
const startLine = Math.max(0, lineNum - 5);
|
|
45
57
|
const endLine = Math.min(lines.length, lineNum + 20);
|
|
46
58
|
const context = lines.slice(startLine, endLine).join('\n');
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
sdk: pattern.name,
|
|
55
|
-
tokensPerCall,
|
|
56
|
-
estimatedModel,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
+
sites.push({
|
|
60
|
+
file: filePath,
|
|
61
|
+
line: lineNum,
|
|
62
|
+
sdk: pattern.name,
|
|
63
|
+
tokensPerCall: slash(context, estimatedModel),
|
|
64
|
+
estimatedModel,
|
|
65
|
+
});
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
return sites;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slash-tokens",
|
|
3
|
-
"version": "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",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
"test": "bun test",
|
|
32
32
|
"check:engines": "node scripts/check-engines.mjs",
|
|
33
33
|
"check:freshness": "bun scripts/check-freshness.ts",
|
|
34
|
-
"bench:nemotron": "bun bench/calibrate-nemotron.ts"
|
|
34
|
+
"bench:nemotron": "bun bench/calibrate-nemotron.ts",
|
|
35
|
+
"bench:oss": "bun bench/oss/study.ts"
|
|
35
36
|
},
|
|
36
37
|
"author": "wolfejam",
|
|
37
38
|
"license": "MIT",
|