turntax 0.0.0-stage → 0.2.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.
@@ -0,0 +1,179 @@
1
+ export const METHOD_LABEL = {
2
+ 'offline-exact': 'exact (offline tokenizer)',
3
+ 'offline-proxy': 'estimate (o200k_base proxy)',
4
+ api: 'vendor API count',
5
+ unavailable: 'not counted',
6
+ 'none-loaded': 'nothing always loaded',
7
+ };
8
+ export function fmtTokens(n) {
9
+ return n.toLocaleString('en-US');
10
+ }
11
+ export function fmtUsd(n) {
12
+ if (n === null)
13
+ return 'n/a';
14
+ if (n === 0)
15
+ return '$0';
16
+ if (n < 0.01)
17
+ return `$${n.toFixed(4)}`;
18
+ return `$${n.toFixed(2)}`;
19
+ }
20
+ function table(rows, indent = '') {
21
+ const widths = rows[0].map((_, c) => Math.max(...rows.map((r) => (r[c] ?? '').length)));
22
+ return rows.map((r) => indent + r.map((cell, c) => cell.padEnd(widths[c])).join(' ').trimEnd()).join('\n');
23
+ }
24
+ /**
25
+ * Agents that load exactly the same files with the same results share one row,
26
+ * so a repository with only AGENTS.md does not print a dozen identical lines.
27
+ */
28
+ export function groupTools(report) {
29
+ const groups = new Map();
30
+ for (const t of report.tools) {
31
+ const files = report.files
32
+ .filter((f) => f.tool === t.tool && f.mode !== 'ignored')
33
+ .map((f) => [f.path, f.part, f.mode, f.o200k])
34
+ .sort();
35
+ const key = JSON.stringify([files, t.models.map(({ model, perTurn, method, reason }) => [model, perTurn, method, reason])]);
36
+ const g = groups.get(key);
37
+ if (g)
38
+ g.names.push(t.name);
39
+ else
40
+ groups.set(key, { names: [t.name], tool: t });
41
+ }
42
+ return [...groups.values()];
43
+ }
44
+ /** File rows that are identical across agents, merged with the list of agents. */
45
+ export function groupFiles(report) {
46
+ const groups = new Map();
47
+ for (const f of report.files) {
48
+ const key = JSON.stringify([f.path, f.part, f.mode, f.trigger, f.o200k, f.importedFrom ?? '']);
49
+ const g = groups.get(key);
50
+ if (g)
51
+ g.tools.push(f.tool);
52
+ else
53
+ groups.set(key, { file: f, tools: [f.tool] });
54
+ }
55
+ return [...groups.values()];
56
+ }
57
+ /** Highest always-loaded o200k total across the reported tools. */
58
+ export function headlineTokens(report) {
59
+ return Math.max(0, ...report.tools.map((t) => t.perTurnO200k));
60
+ }
61
+ export function renderText(report, root) {
62
+ const out = [];
63
+ out.push(`turntax: per-turn context tax${root ? ` for ${root}` : ''}`);
64
+ out.push('');
65
+ if (!report.tools.length) {
66
+ out.push('No agent instruction files found.');
67
+ return out.join('\n');
68
+ }
69
+ const rows = [
70
+ ['always', 'o200k/turn', 'on-demand', 'model', 'tokens/turn', 'counted by', `${report.turns} turns`, 'cached', 'agents'],
71
+ ];
72
+ for (const { names, tool: t } of groupTools(report)) {
73
+ const head = [String(t.alwaysFiles), fmtTokens(t.perTurnO200k), fmtTokens(t.onDemandO200k)];
74
+ const blank = head.map(() => '');
75
+ const agents = names.join(', ');
76
+ if (!t.models.length)
77
+ rows.push([...head, '(no selected model)', '', '', '', '', agents]);
78
+ t.models.forEach((m, i) => rows.push([
79
+ ...(i === 0 ? head : blank),
80
+ m.label,
81
+ ...(m.perTurn === null
82
+ ? ['-', m.reason ?? METHOD_LABEL[m.method], '-', '-']
83
+ : [fmtTokens(m.perTurn), METHOD_LABEL[m.method], fmtUsd(m.uncached), fmtUsd(m.cached)]),
84
+ i === 0 ? agents : '',
85
+ ]));
86
+ }
87
+ out.push(table(rows));
88
+ out.push('');
89
+ out.push('Files');
90
+ const fileRows = [['mode', 'file', 'o200k', 'loads', 'agents']];
91
+ for (const { file: f, tools } of groupFiles(report)) {
92
+ const name = f.part === 'full' ? f.path : `${f.path} (${f.part})`;
93
+ fileRows.push([f.mode, name + (f.importedFrom ? ` <- @${f.importedFrom}` : ''), fmtTokens(f.o200k), f.trigger, tools.join(', ')]);
94
+ }
95
+ out.push(table(fileRows, ' '));
96
+ const notes = [...new Set(report.files.flatMap((f) => f.notes.map((n) => `${f.path}: ${n}`)))];
97
+ if (notes.length) {
98
+ out.push('');
99
+ out.push('Notes');
100
+ for (const n of notes)
101
+ out.push(` - ${n}`);
102
+ }
103
+ out.push('');
104
+ out.push('Advice (savings are estimates in o200k tokens per turn)');
105
+ if (!report.advice.length)
106
+ out.push(' No issues found.');
107
+ for (const a of report.advice) {
108
+ const saving = a.savingTokens === null ? '' : ` [saves ~${fmtTokens(a.savingTokens)} tokens/turn]`;
109
+ const where = a.paths.join(', ') + (a.line ? `:${a.line}` : '');
110
+ out.push(` ${a.severity.toUpperCase()} ${a.kind} ${where}${saving}`);
111
+ out.push(` ${a.message}${a.savingNote ? ` (${a.savingNote})` : ''}`);
112
+ }
113
+ out.push('');
114
+ out.push(footer(report));
115
+ return out.join('\n');
116
+ }
117
+ function footer(report) {
118
+ const lines = [
119
+ 'o200k = OpenAI o200k_base, counted offline. It is a reference for every file, not a Claude or Gemini count.',
120
+ 'Claude and Gemini tokenizers are not public: their counts come only from the vendor APIs (--claude-api, --gemini-api).',
121
+ `Cost = tokens/turn x ${report.turns} turns x input price. "Cached" assumes every turn reads the context from the prompt cache.`,
122
+ 'Prices (USD per 1M input tokens):',
123
+ ];
124
+ for (const m of report.models) {
125
+ lines.push(` ${m.label}: ${m.inputPerMTok ?? 'n/a'} input, ${m.cachedInputPerMTok ?? 'n/a'} cached` +
126
+ `${m.priceNote ? ` (${m.priceNote})` : ''}; ${m.source}, checked ${m.checkedAt}`);
127
+ }
128
+ return lines.join('\n');
129
+ }
130
+ export function renderMarkdown(report) {
131
+ const out = ['# Agent context tax report', ''];
132
+ out.push(`Every agent re-sends its always-loaded instruction files with each turn. Costs are for ${report.turns} turns.`, '');
133
+ out.push('## Per agent', '');
134
+ out.push('| Agents | Model | Tokens / turn | Counted by | Uncached | Cached |', '| --- | --- | ---: | --- | ---: | ---: |');
135
+ for (const { names, tool: t } of groupTools(report)) {
136
+ const agents = names.join(', ');
137
+ if (!t.models.length)
138
+ out.push(`| ${agents} | - | ${fmtTokens(t.perTurnO200k)} (o200k) | reference | - | - |`);
139
+ for (const m of t.models)
140
+ out.push(m.perTurn === null
141
+ ? `| ${agents} | ${m.label} | - | ${m.reason ?? ''} | - | - |`
142
+ : `| ${agents} | ${m.label} | ${fmtTokens(m.perTurn)} | ${METHOD_LABEL[m.method]} | ${fmtUsd(m.uncached)} | ${fmtUsd(m.cached)} |`);
143
+ }
144
+ out.push('', '## Files', '', '| Mode | File | o200k tokens | Loads | Agents |', '| --- | --- | ---: | --- | --- |');
145
+ for (const { file: f, tools } of groupFiles(report)) {
146
+ const name = f.part === 'full' ? f.path : `${f.path} (${f.part})`;
147
+ out.push(`| ${f.mode} | \`${name}\` | ${fmtTokens(f.o200k)} | ${escapeCell(f.trigger)} | ${tools.join(', ')} |`);
148
+ }
149
+ out.push('', '## Advice', '');
150
+ if (!report.advice.length)
151
+ out.push('No issues found.');
152
+ for (const a of report.advice) {
153
+ const saving = a.savingTokens === null ? '' : ` Saves about ${fmtTokens(a.savingTokens)} tokens per turn.`;
154
+ out.push(`- **${a.severity}** \`${a.kind}\` ${a.paths.map((p) => `\`${p}\``).join(', ')}${a.line ? ` line ${a.line}` : ''}: ${a.message}${saving}${a.savingNote ? ` _${a.savingNote}_` : ''}`);
155
+ }
156
+ out.push('', '## Method', '');
157
+ out.push('- o200k = OpenAI `o200k_base`, counted offline; a reference, not a Claude or Gemini count.');
158
+ out.push('- Claude counts come only from Anthropic `count_tokens`; Gemini counts only from Google `countTokens`.');
159
+ out.push('- "Cached" assumes every turn reads the context from the prompt cache.');
160
+ for (const m of report.models)
161
+ out.push(`- ${m.label}: $${m.inputPerMTok ?? 'n/a'} input / $${m.cachedInputPerMTok ?? 'n/a'} cached per 1M tokens ([source](${m.source}), checked ${m.checkedAt}).`);
162
+ out.push('', badgeMarkdown(report));
163
+ return out.join('\n') + '\n';
164
+ }
165
+ function escapeCell(s) {
166
+ return s.replace(/\|/g, '\\|');
167
+ }
168
+ function shieldsEscape(s) {
169
+ return encodeURIComponent(s.replace(/-/g, '--').replace(/_/g, '__'));
170
+ }
171
+ export function badgeUrl(report) {
172
+ const n = headlineTokens(report);
173
+ const value = n >= 1000 ? `${(n / 1000).toFixed(1)}k tokens/turn` : `${n} tokens/turn`;
174
+ const color = n < 2000 ? 'brightgreen' : n < 5000 ? 'yellow' : n < 10000 ? 'orange' : 'red';
175
+ return `https://img.shields.io/badge/${shieldsEscape('context tax (o200k)')}-${shieldsEscape(value)}-${color}`;
176
+ }
177
+ export function badgeMarkdown(report) {
178
+ return `[![context tax (o200k)](${badgeUrl(report)})](https://github.com/TorutDev/turntax)`;
179
+ }
@@ -0,0 +1,21 @@
1
+ import { countTokens } from 'gpt-tokenizer/encoding/o200k_base';
2
+ const PLAIN_TEXT = { disallowedSpecial: new Set() };
3
+ /** OpenAI o200k_base token count. Special-token strings are counted as ordinary text. */
4
+ export function countO200k(text) {
5
+ return text ? countTokens(text, PLAIN_TEXT) : 0;
6
+ }
7
+ /** Counts offline where a public tokenizer exists; reports the rest as unavailable. */
8
+ export const offlineCounter = {
9
+ async count(model, texts) {
10
+ if (model.offline === 'none') {
11
+ const reason = model.family === 'claude'
12
+ ? 'needs --claude-api and ANTHROPIC_API_KEY'
13
+ : model.family === 'gemini'
14
+ ? 'needs --gemini-api and GEMINI_API_KEY'
15
+ : 'no offline tokenizer';
16
+ return texts.map(() => ({ tokens: null, method: 'unavailable', reason }));
17
+ }
18
+ const method = model.offline === 'o200k-exact' ? 'offline-exact' : 'offline-proxy';
19
+ return texts.map((t) => ({ tokens: countO200k(t), method }));
20
+ },
21
+ };