tokentrace 0.8.1 → 0.8.3

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
@@ -2,6 +2,25 @@
2
2
 
3
3
  All notable changes to TokenTrace are documented here.
4
4
 
5
+ ## [0.8.3] - 2026-05-13
6
+
7
+ ### Fixed
8
+
9
+ - Codex CLI `token_count` JSON imports now subtract cached input from non-cached input and keep cached input separate, so processed Codex totals count cache exactly once instead of double-counting it.
10
+ - Codex parser provenance is bumped to version 4 so previously imported version-3 Codex session rows are reprocessed on the next scan with corrected token and cost totals.
11
+
12
+ ## [0.8.2] - 2026-05-13
13
+
14
+ ### Fixed
15
+
16
+ - Codex CLI `token_count` imports now preserve non-cached input tokens and add cached input tokens to processed totals, matching Codex's `input (+ cached)` session summary.
17
+ - Codex parser provenance is bumped to version 3 so previously imported Codex session files are reprocessed on the next scan instead of keeping stale undercounted rows.
18
+ - Generic text-log parsing now recognizes Codex `Token usage: total=... input=... (+ ... cached) output=...` summary lines as structured usage instead of weak text estimates.
19
+ - Overview and evidence token totals now switch to billions at large scales instead of displaying multi-thousand million values.
20
+ - Scan, settings, pricing, and pricing-refresh API writes now reject malformed JSON and avoid JavaScript truthiness coercion for boolean flags.
21
+ - Settings and scan custom folders are trimmed and blank entries are discarded before persistence or scan execution.
22
+ - Pricing manifest imports now ignore invalid, boolean, array, blank, and negative price values instead of coercing them into trusted numeric prices.
23
+
5
24
  ## [0.8.1] - 2026-05-13
6
25
 
7
26
  ### Fixed
@@ -1,18 +1,28 @@
1
1
  import { NextResponse } from "next/server";
2
- import { readJsonObject } from "@/src/lib/api-json";
2
+ import { jsonBooleanFlag, readOptionalJsonObject } from "@/src/lib/api-json";
3
3
  import { refreshPricing } from "@/src/lib/pricing-refresh";
4
4
 
5
5
  export const dynamic = "force-dynamic";
6
6
 
7
+ function refreshSource(value: unknown) {
8
+ if (value == null) return "remote";
9
+ if (value === "remote" || value === "bundled") return value;
10
+ return null;
11
+ }
12
+
7
13
  export async function POST(request: Request) {
8
- const parsed = await readJsonObject(request);
14
+ const parsed = await readOptionalJsonObject(request);
9
15
  if (!parsed.ok) {
10
16
  return NextResponse.json({ error: parsed.error }, { status: 400 });
11
17
  }
12
18
  const body = parsed.body;
19
+ const source = refreshSource(body.source);
20
+ if (!source) {
21
+ return NextResponse.json({ error: "source must be remote or bundled" }, { status: 400 });
22
+ }
13
23
  const result = await refreshPricing({
14
- source: body?.source === "bundled" ? "bundled" : "remote",
15
- force: Boolean(body?.force)
24
+ source,
25
+ force: jsonBooleanFlag(body.force)
16
26
  });
17
27
  return NextResponse.json(result);
18
28
  }
@@ -11,6 +11,9 @@ function requiredText(value: unknown) {
11
11
  function nullablePrice(value: unknown, field: string) {
12
12
  if (value == null) return { ok: true as const, value: null };
13
13
  if (typeof value === "string" && value.trim() === "") return { ok: true as const, value: null };
14
+ if (typeof value !== "number" && typeof value !== "string") {
15
+ return { ok: false as const, error: `${field} must be a non-negative number or empty` };
16
+ }
14
17
  const number = Number(value);
15
18
  if (!Number.isFinite(number) || number < 0) {
16
19
  return { ok: false as const, error: `${field} must be a non-negative number or empty` };
@@ -42,7 +45,7 @@ export async function POST(request: Request) {
42
45
  if (!cachedInputTokenPrice.ok) return NextResponse.json({ error: cachedInputTokenPrice.error }, { status: 400 });
43
46
  if (!cacheWriteTokenPrice.ok) return NextResponse.json({ error: cacheWriteTokenPrice.error }, { status: 400 });
44
47
 
45
- const id = upsertPricing({
48
+ const result = upsertPricing({
46
49
  providerId,
47
50
  providerName: requiredText(body.providerName) || undefined,
48
51
  model,
@@ -53,5 +56,12 @@ export async function POST(request: Request) {
53
56
  currency: requiredText(body.currency) || "USD"
54
57
  });
55
58
 
56
- return NextResponse.json({ id, costsRecalculated: true });
59
+ return NextResponse.json({
60
+ id: result.id,
61
+ costsRecalculated: result.interactionsUpdated,
62
+ interactionsChecked: result.interactionsChecked,
63
+ unknownCostInteractions: result.unknownCostInteractions,
64
+ modelAliasesUpdated: result.modelsUpdated,
65
+ resolvedRepairItems: result.resolvedRepairItems
66
+ });
57
67
  }
@@ -1,20 +1,27 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { runScan } from "@/src/ingestion/scan";
3
- import { readJsonObject } from "@/src/lib/api-json";
3
+ import { jsonBooleanFlag, readOptionalJsonObject } from "@/src/lib/api-json";
4
4
 
5
5
  export const dynamic = "force-dynamic";
6
6
 
7
+ function stringList(value: unknown) {
8
+ return Array.isArray(value)
9
+ ? value
10
+ .filter((folder): folder is string => typeof folder === "string")
11
+ .map((folder) => folder.trim())
12
+ .filter(Boolean)
13
+ : undefined;
14
+ }
15
+
7
16
  export async function POST(request: Request) {
8
- const parsed = await readJsonObject(request);
17
+ const parsed = await readOptionalJsonObject(request);
9
18
  if (!parsed.ok) {
10
19
  return NextResponse.json({ error: parsed.error }, { status: 400 });
11
20
  }
12
21
  const body = parsed.body;
13
22
  const result = await runScan({
14
- folders: Array.isArray(body.folders)
15
- ? body.folders.filter((folder: unknown): folder is string => typeof folder === "string")
16
- : undefined,
17
- force: Boolean(body.force)
23
+ folders: stringList(body.folders),
24
+ force: jsonBooleanFlag(body.force)
18
25
  });
19
26
  return NextResponse.json(result);
20
27
  }
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { getDatabasePath } from "@/src/db/client";
3
3
  import { getAppSettings, normalizeUsageGuardrails, saveAppSettings } from "@/src/db/settings";
4
- import { readJsonObject } from "@/src/lib/api-json";
4
+ import { jsonBooleanFlag, readJsonObject } from "@/src/lib/api-json";
5
5
 
6
6
  export const dynamic = "force-dynamic";
7
7
 
@@ -19,11 +19,14 @@ export async function PUT(request: Request) {
19
19
  }
20
20
  const body = parsed.body;
21
21
  const customFolders = Array.isArray(body.customFolders)
22
- ? body.customFolders.filter((folder: unknown): folder is string => typeof folder === "string")
22
+ ? body.customFolders
23
+ .filter((folder: unknown): folder is string => typeof folder === "string")
24
+ .map((folder) => folder.trim())
25
+ .filter(Boolean)
23
26
  : [];
24
27
  const saved = saveAppSettings({
25
28
  customFolders,
26
- storeRawMessageContent: Boolean(body.storeRawMessageContent),
29
+ storeRawMessageContent: jsonBooleanFlag(body.storeRawMessageContent),
27
30
  usageGuardrails: normalizeUsageGuardrails(body.usageGuardrails)
28
31
  });
29
32
 
@@ -1,12 +1,14 @@
1
1
  import Link from "next/link";
2
2
  import { ArrowLeft, ArrowRight } from "lucide-react";
3
+ import { PeriodFilter } from "@/components/period-filter";
3
4
  import { Badge } from "@/components/ui/badge";
4
5
  import { Button } from "@/components/ui/button";
5
6
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
7
8
  import { DataValue, FieldLabel, MonoText, PageHeader } from "@/components/ui/typography";
9
+ import { dateRangeQueryParams, mergeHrefParams, resolveDateRange } from "@/src/lib/date-range";
8
10
  import { buildEvidenceTrail, parseEvidenceMetric } from "@/src/lib/evidence-trail";
9
- import { formatCurrency, formatTokens, percent } from "@/src/lib/format";
11
+ import { formatCurrency, formatExactTokens, percent } from "@/src/lib/format";
10
12
 
11
13
  export const dynamic = "force-dynamic";
12
14
 
@@ -29,7 +31,14 @@ function parserStatusVariant(value: string | null) {
29
31
 
30
32
  export default async function EvidencePage({ searchParams }: EvidencePageProps) {
31
33
  const params = (await searchParams) ?? {};
32
- const trail = buildEvidenceTrail({ metric: parseEvidenceMetric(params?.metric) });
34
+ const range = resolveDateRange(params);
35
+ const metric = parseEvidenceMetric(params?.metric);
36
+ const trail = buildEvidenceTrail({ metric, filters: range.filters });
37
+ const rangeLinkParams = dateRangeQueryParams(range);
38
+ const overviewHref = mergeHrefParams("/", rangeLinkParams);
39
+ const currentEvidenceHref = mergeHrefParams(`/evidence?metric=${trail.metric}`, rangeLinkParams);
40
+ const pricingReturnParams = { returnTo: currentEvidenceHref };
41
+ const confidenceTotal = Math.max(1, trail.confidence.exact + trail.confidence.estimated + trail.confidence.unknown);
33
42
 
34
43
  return (
35
44
  <div className="space-y-6">
@@ -38,7 +47,7 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
38
47
  description={trail.description}
39
48
  actions={
40
49
  <Button asChild variant="outline" size="sm">
41
- <Link href="/">
50
+ <Link href={overviewHref}>
42
51
  <ArrowLeft className="h-4 w-4" />
43
52
  Overview
44
53
  </Link>
@@ -46,6 +55,8 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
46
55
  }
47
56
  />
48
57
 
58
+ <PeriodFilter range={range} />
59
+
49
60
  <Card>
50
61
  <CardHeader>
51
62
  <CardTitle>Metric Totals</CardTitle>
@@ -57,7 +68,7 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
57
68
  <div className="grid divide-y border-t sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-5">
58
69
  <div className="p-3">
59
70
  <FieldLabel>Tokens</FieldLabel>
60
- <DataValue className="mt-1" size="md">{formatTokens(trail.totals.tokens)}</DataValue>
71
+ <DataValue className="mt-1" size="md">{formatExactTokens(trail.totals.tokens)}</DataValue>
61
72
  </div>
62
73
  <div className="p-3">
63
74
  <FieldLabel>Cost</FieldLabel>
@@ -79,6 +90,84 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
79
90
  </CardContent>
80
91
  </Card>
81
92
 
93
+ <div className="grid gap-4 xl:grid-cols-[minmax(0,0.75fr)_minmax(0,1.25fr)]">
94
+ <Card>
95
+ <CardHeader>
96
+ <CardTitle>Confidence Split</CardTitle>
97
+ <CardDescription>Interaction-level token confidence for this metric and period.</CardDescription>
98
+ </CardHeader>
99
+ <CardContent className="space-y-3">
100
+ {[
101
+ { label: "Exact", value: trail.confidence.exact, variant: "success" as const },
102
+ { label: "Estimated", value: trail.confidence.estimated, variant: "secondary" as const },
103
+ { label: "Unknown", value: trail.confidence.unknown, variant: "warning" as const }
104
+ ].map((item) => (
105
+ <div key={item.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)_4rem] items-center gap-3 text-sm">
106
+ <Badge variant={item.variant}>{item.label}</Badge>
107
+ <div className="h-2 overflow-hidden rounded-full bg-muted">
108
+ <div className="h-full rounded-full bg-primary" style={{ width: `${(item.value / confidenceTotal) * 100}%` }} />
109
+ </div>
110
+ <div className="text-right tabular-nums text-muted-foreground">{item.value.toLocaleString()}</div>
111
+ </div>
112
+ ))}
113
+ </CardContent>
114
+ </Card>
115
+
116
+ <Card>
117
+ <CardHeader>
118
+ <CardTitle>Top Source Files</CardTitle>
119
+ <CardDescription>Largest contributing local files for the same metric definition.</CardDescription>
120
+ </CardHeader>
121
+ <CardContent className="table-scroll">
122
+ <Table>
123
+ <TableHeader>
124
+ <TableRow>
125
+ <TableHead>Source</TableHead>
126
+ <TableHead>Tokens</TableHead>
127
+ <TableHead>Interactions</TableHead>
128
+ <TableHead>Unknown cost</TableHead>
129
+ <TableHead>Actions</TableHead>
130
+ </TableRow>
131
+ </TableHeader>
132
+ <TableBody>
133
+ {trail.sourceFiles.length ? (
134
+ trail.sourceFiles.map((source) => (
135
+ <TableRow key={source.sourceFile}>
136
+ <TableCell className="max-w-96">
137
+ <Link href={mergeHrefParams(source.sourceHref, rangeLinkParams)} title={source.sourceFile}>
138
+ <MonoText className="block truncate text-muted-foreground underline-offset-4 hover:underline">
139
+ {source.sourceFile}
140
+ </MonoText>
141
+ </Link>
142
+ </TableCell>
143
+ <TableCell>{formatExactTokens(source.tokens)}</TableCell>
144
+ <TableCell>{source.interactions.toLocaleString()}</TableCell>
145
+ <TableCell>{source.unknownCostInteractions.toLocaleString()}</TableCell>
146
+ <TableCell>
147
+ <div className="flex flex-wrap gap-2">
148
+ <Link href={mergeHrefParams(source.sourceHref, rangeLinkParams)} className="font-medium text-primary underline-offset-4 hover:underline">
149
+ Sessions
150
+ </Link>
151
+ <Link href={mergeHrefParams(source.parserHref, rangeLinkParams)} className="font-medium text-muted-foreground underline-offset-4 hover:underline">
152
+ Parser
153
+ </Link>
154
+ </div>
155
+ </TableCell>
156
+ </TableRow>
157
+ ))
158
+ ) : (
159
+ <TableRow>
160
+ <TableCell colSpan={5} className="h-20 text-center text-muted-foreground">
161
+ No source-file evidence is available for this metric yet.
162
+ </TableCell>
163
+ </TableRow>
164
+ )}
165
+ </TableBody>
166
+ </Table>
167
+ </CardContent>
168
+ </Card>
169
+ </div>
170
+
82
171
  <Card>
83
172
  <CardHeader>
84
173
  <CardTitle>Session, Source, Parser, And Pricing Evidence</CardTitle>
@@ -104,17 +193,17 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
104
193
  trail.sessions.map((session) => (
105
194
  <TableRow key={session.id}>
106
195
  <TableCell className="min-w-64">
107
- <Link href={session.sessionHref} className="font-medium text-primary underline-offset-4 hover:underline">
196
+ <Link href={mergeHrefParams(session.sessionHref, rangeLinkParams)} className="font-medium text-primary underline-offset-4 hover:underline">
108
197
  {session.title}
109
198
  </Link>
110
199
  <div className="mt-1 text-xs text-muted-foreground">
111
200
  {session.tool} / {session.provider} / {session.project}
112
201
  </div>
113
202
  </TableCell>
114
- <TableCell>{formatTokens(session.totalTokens)}</TableCell>
203
+ <TableCell>{formatExactTokens(session.totalTokens)}</TableCell>
115
204
  <TableCell>{formatCurrency(session.cost)}</TableCell>
116
205
  <TableCell className="max-w-80">
117
- <Link href={session.sourceHref} title={session.sourceFile}>
206
+ <Link href={mergeHrefParams(session.sourceHref, rangeLinkParams)} title={session.sourceFile}>
118
207
  <MonoText className="block truncate text-muted-foreground underline-offset-4 hover:underline">
119
208
  {session.sourceFile}
120
209
  </MonoText>
@@ -125,7 +214,7 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
125
214
  <Badge variant={parserStatusVariant(session.parserStatus)}>
126
215
  {session.parserStatus ?? "not scanned"}
127
216
  </Badge>
128
- <Link href={session.parserHref} className="text-xs font-medium text-primary underline-offset-4 hover:underline">
217
+ <Link href={mergeHrefParams(session.parserHref, rangeLinkParams)} className="text-xs font-medium text-primary underline-offset-4 hover:underline">
129
218
  Parser <ArrowRight className="inline h-3.5 w-3.5" />
130
219
  </Link>
131
220
  </div>
@@ -135,7 +224,7 @@ export default async function EvidencePage({ searchParams }: EvidencePageProps)
135
224
  </TableCell>
136
225
  <TableCell className="min-w-44">
137
226
  {session.pricingHref ? (
138
- <Link href={session.pricingHref} className="font-medium text-primary underline-offset-4 hover:underline">
227
+ <Link href={mergeHrefParams(session.pricingHref, pricingReturnParams)} className="font-medium text-primary underline-offset-4 hover:underline">
139
228
  {session.model}
140
229
  </Link>
141
230
  ) : (