slash-tokens 1.7.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.1] — The Hired Agent Edition
4
+
5
+ *2026-10-10*
6
+
7
+ The scan counts each real LLM request once.
8
+
9
+ No change to `quote()`, `decide()`, `reconcile()`, `hire()`, `/auto` or prices.
10
+
11
+ ### Fixed
12
+ - **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.
13
+ - **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.
14
+ - The scan's footnote reads "1 call site uses an assumed price" (was "use").
15
+
16
+ ### Docs
17
+ - **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.
18
+ - **`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).
19
+
3
20
  ## [1.7.0] — The Hired Agent Edition
4
21
 
5
22
  *2026-10-08*
@@ -14,7 +31,7 @@ New: `quote()`, `decide()`, `reconcile()`, `hire().run()`, `slash-tokens quote`,
14
31
  - **`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
32
  - **`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
33
  - **`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 measured framing: Nebius 16 per request + 7 per extra message (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, and a conservative allowance for Anthropic and Google. Live check 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.
34
+ - **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
35
  - **`reconcile()` reads Nebius cache hits** (`prompt_cache_hit_tokens`).
19
36
  - **npm metadata:** description now says what 1.7.0 does; keywords add nemotron, nvidia, nebius, agent, cost.
20
37
  - **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,10 +10,12 @@
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.0 — The Hired Agent Edition
13
+ ## v1.7.1 — The Hired Agent Edition
14
14
 
15
15
  Quote the job, book the right model, prove it with a receipt.
16
16
 
17
+ New in 1.7.1: the scan counts each real LLM request once.
18
+
17
19
  ```js
18
20
  import { hire } from 'slash-tokens'
19
21
 
@@ -35,7 +37,7 @@ receipt.saved // dollars saved against the model you asked for
35
37
  - **Prove:** `reconcile()` checks the quote against what the provider billed.
36
38
  - **Hire:** `hire()` does all three for every job, inside one budget. Its cut, 10% of measured savings, is on every receipt, waived.
37
39
 
38
- Counts are calibrated against each provider's real tokenizer, quotes add what each provider bills on top of your text, and CI fails if either comes in under the real number.
40
+ 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
41
 
40
42
  ## Install
41
43
 
package/dist/cli.js CHANGED
@@ -265,16 +265,16 @@ function slash(content, model) {
265
265
 
266
266
  // src/patterns.ts
267
267
  var AI_PATTERNS = [
268
- { name: "OpenAI", regex: /openai\.chat\.completions\.create|new OpenAI\(|from\s+['"]openai['"]/g },
269
- { name: "Anthropic", regex: /anthropic\.messages\.create|new Anthropic\(|from\s+['"]@anthropic-ai/g },
270
- { name: "Vercel AI", regex: /from\s+['"]ai['"]|generateText|streamText|generateObject/g },
271
- { name: "LangChain", regex: /from\s+['"]langchain|ChatOpenAI|ChatAnthropic|\.invoke\(/g },
272
- { name: "Gemini", regex: /GoogleGenerativeAI|generateContent|from\s+['"]@google\/generative/g },
273
- { name: "Bedrock", regex: /BedrockRuntimeClient|InvokeModelCommand/g },
274
- { name: "Grok", regex: /x\.ai\/api|xai\.chat|from\s+['"]grok/g },
275
- { name: "fetch (AI)", regex: /fetch\(.*api\.openai\.com|fetch\(.*api\.anthropic\.com|fetch\(.*generativelanguage\.googleapis/g },
276
- { name: "Cohere", regex: /from\s+['"]cohere|CohereClient|cohere\.chat/g },
277
- { name: "Mistral", regex: /from\s+['"]@mistralai|MistralClient/g }
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.regex.lastIndex = 0;
346
- let match;
347
- while ((match = pattern.regex.exec(content)) !== null) {
348
- const beforeMatch = content.substring(0, match.index);
349
- const lineNum = beforeMatch.split(`
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
- const estimatedModel = SDK_REPRESENTATIVE_MODEL[pattern.name] ?? UNKNOWN_SDK_REPRESENTATIVE_MODEL;
356
- const tokensPerCall = slash(context, estimatedModel);
357
- if (!sites.some((s) => s.line === lineNum && s.sdk === pattern.name)) {
358
- sites.push({
359
- file: filePath,
360
- line: lineNum,
361
- sdk: pattern.name,
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;
@@ -455,7 +464,7 @@ function printReport(sites, filesScanned, timeMs, cwd) {
455
464
  console.log(`${WHITE} Output: ${B}$${monthlyCostOutput.toFixed(2)}/mo${R}`);
456
465
  console.log(`${WHITE} Total: ${ORANGE}${B}$${monthlyCost.toFixed(2)}/mo${R}`);
457
466
  if (unknownProviderSites > 0) {
458
- console.log(`${GRAY} (${unknownProviderSites} call site${unknownProviderSites === 1 ? "" : "s"} use an assumed price — the detected SDK doesn't reveal the exact provider)${R}`);
467
+ 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
468
  }
460
469
  console.log("");
461
470
  console.log(`${GRAY} 4.8 KB WASM · sub-ms · zero deps${R}`);
@@ -1,6 +1,17 @@
1
1
  export interface Pattern {
2
2
  name: string;
3
- regex: RegExp;
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', regex: /openai\.chat\.completions\.create|new OpenAI\(|from\s+['"]openai['"]/g },
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', regex: /anthropic\.messages\.create|new Anthropic\(|from\s+['"]@anthropic-ai/g },
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', regex: /from\s+['"]ai['"]|generateText|streamText|generateObject/g },
7
+ { name: 'Vercel AI', call: /\b(?:generateText|streamText|generateObject|streamObject)\(/g, uses: /from\s+['"]ai['"]/g },
8
8
  // LangChain
9
- { name: 'LangChain', regex: /from\s+['"]langchain|ChatOpenAI|ChatAnthropic|\.invoke\(/g },
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', regex: /GoogleGenerativeAI|generateContent|from\s+['"]@google\/generative/g },
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', regex: /BedrockRuntimeClient|InvokeModelCommand/g },
13
+ { name: 'Bedrock', call: /InvokeModelCommand\(/g, uses: /BedrockRuntimeClient/g },
14
14
  // xAI Grok
15
- { name: 'Grok', regex: /x\.ai\/api|xai\.chat|from\s+['"]grok/g },
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)', regex: /fetch\(.*api\.openai\.com|fetch\(.*api\.anthropic\.com|fetch\(.*generativelanguage\.googleapis/g },
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', regex: /from\s+['"]cohere|CohereClient|cohere\.chat/g },
19
+ { name: 'Cohere', call: /cohere\.chat/g, uses: /from\s+['"]cohere|CohereClient/g, standalone: true },
20
20
  // Mistral
21
- { name: 'Mistral', regex: /from\s+['"]@mistralai|MistralClient/g },
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
@@ -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 by input price. If two alternatives
50
- * tie on price (unlikely but possible), returns the first encountered in
51
- * PROVIDER_MODELS order.
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 by input price. If two alternatives
75
- * tie on price (unlikely but possible), returns the first encountered in
76
- * PROVIDER_MODELS order.
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
@@ -68,10 +68,13 @@ export declare const DEFAULT_MAX_OUTPUT_TOKENS = 4096;
68
68
  * xAI a fixed system preamble of 185–193 tokens per request
69
69
  * (baselines in bench/results-grok.json); 193 used.
70
70
  * OpenAI 3 per message + 3 to prime the reply (OpenAI's tiktoken
71
- * cookbook); 4 per message, the first one inside `request`.
71
+ * cookbook, not measured here); 4 per message, the first one
72
+ * inside `request`.
72
73
  * Anthropic the calibration ground truth (count_tokens) already includes
73
- * one message's framing; 4 per extra message.
74
- * Google countTokens on content; 4 per message, not yet measured.
74
+ * one message's framing; 4 per extra message, an allowance,
75
+ * not yet measured.
76
+ * Google countTokens on content; 4 per message, an allowance, not yet
77
+ * measured.
75
78
  */
76
79
  export declare const FRAMING: Record<string, {
77
80
  request: number;
@@ -81,7 +84,8 @@ export declare const FRAMING: Record<string, {
81
84
  * Price a job before it runs: input tokens, an output band, and a low–high
82
85
  * cost. Input is the content counted with the model's calibration factor
83
86
  * plus the provider's request framing (see FRAMING), so it doesn't come in
84
- * under the bill; the long-context rate applies when the prompt crosses it.
87
+ * under the bill (checked against real bills for Nebius; an allowance for
88
+ * the others); the long-context rate applies when the prompt crosses it.
85
89
  */
86
90
  export declare function quote(task: Task): Quote;
87
91
  /**
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 inside `request`.
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
- * Google countTokens on content; 4 per message, not yet measured.
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; the long-context rate applies when the prompt crosses it.
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);
package/dist/report.js CHANGED
@@ -74,7 +74,7 @@ export function printReport(sites, filesScanned, timeMs, cwd) {
74
74
  console.log(`${WHITE} Output: ${B}$${monthlyCostOutput.toFixed(2)}/mo${R}`);
75
75
  console.log(`${WHITE} Total: ${ORANGE}${B}$${monthlyCost.toFixed(2)}/mo${R}`);
76
76
  if (unknownProviderSites > 0) {
77
- console.log(`${GRAY} (${unknownProviderSites} call site${unknownProviderSites === 1 ? '' : 's'} use an assumed price — the detected SDK doesn't reveal the exact provider)${R}`);
77
+ 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
78
  }
79
79
  console.log('');
80
80
  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
- // Reset regex
37
- pattern.regex.lastIndex = 0;
38
- let match;
39
- while ((match = pattern.regex.exec(content)) !== null) {
40
- // Find line number
41
- const beforeMatch = content.substring(0, match.index);
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
- const estimatedModel = SDK_REPRESENTATIVE_MODEL[pattern.name] ?? UNKNOWN_SDK_REPRESENTATIVE_MODEL;
48
- const tokensPerCall = slash(context, estimatedModel);
49
- // Avoid duplicates at same location
50
- if (!sites.some(s => s.line === lineNum && s.sdk === pattern.name)) {
51
- sites.push({
52
- file: filePath,
53
- line: lineNum,
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.7.0",
3
+ "version": "1.7.1",
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",