fix: pricing accuracy, stream leak, CSV injection hardening

- Remove bidirectional fuzzy match in getModelCosts that could return
  wrong pricing when a short canonical name prefix-matched a longer key
- Use explicit undefined check in parseLiteLLMEntry so free models with
  zero cost are not silently dropped from the LiteLLM pricing database
- Destroy read stream in finally block of readSessionLines to prevent
  file descriptor leaks when the generator is abandoned early
- Extend CSV injection escaping to cover tab and carriage-return prefixes
- Add optional chaining fallback for empty periods in exportCsv/exportJson
- Add regression tests for all fixes (models, export, fs-utils)
This commit is contained in:
iamtoruk 2026-04-20 14:49:32 -07:00 committed by AgentSeal
parent b61e7cd32a
commit a4d261a536
6 changed files with 117 additions and 6 deletions

View file

@ -6,7 +6,7 @@ import { getCurrency, convertCost } from './currency.js'
import { dateKey } from './day-aggregator.js'
function escCsv(s: string): string {
const sanitized = /^[=+\-@]/.test(s) ? `'${s}` : s
const sanitized = /^[\t\r=+\-@]/.test(s) ? `'${s}` : s
if (sanitized.includes(',') || sanitized.includes('"') || sanitized.includes('\n')) {
return `"${sanitized.replace(/"/g, '""')}"`
}
@ -283,7 +283,7 @@ async function clearCodeburnExportFolder(path: string): Promise<void> {
/// wipe a sensitive file (prior versions did `rm(path, { force: true })` unconditionally).
export async function exportCsv(periods: PeriodExport[], outputPath: string): Promise<string> {
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? []
let folder = resolve(outputPath)
if (folder.toLowerCase().endsWith('.csv')) {
@ -325,7 +325,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr
export async function exportJson(periods: PeriodExport[], outputPath: string): Promise<string> {
const thirtyDays = periods.find(p => p.label === '30 Days')
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1].projects
const thirtyDayProjects = thirtyDays?.projects ?? periods[periods.length - 1]?.projects ?? []
const { code, rate, symbol } = getCurrency()
const data = {

View file

@ -89,5 +89,7 @@ export async function* readSessionLines(filePath: string): AsyncGenerator<string
for await (const line of rl) yield line
} catch (err) {
warn(`stream read failed for ${filePath}: ${(err as NodeJS.ErrnoException).code ?? 'unknown'}`)
} finally {
stream.destroy()
}
}

View file

@ -62,7 +62,7 @@ function getCachePath(): string {
}
function parseLiteLLMEntry(entry: LiteLLMEntry): ModelCosts | null {
if (!entry.input_cost_per_token || !entry.output_cost_per_token) return null
if (entry.input_cost_per_token === undefined || entry.output_cost_per_token === undefined) return null
return {
inputCostPerToken: entry.input_cost_per_token,
outputCostPerToken: entry.output_cost_per_token,
@ -140,7 +140,7 @@ export function getModelCosts(model: string): ModelCosts | null {
}
for (const [key, costs] of pricingCache ?? new Map()) {
if (canonical.startsWith(key) || key.startsWith(canonical)) return costs
if (canonical.startsWith(key)) return costs
}
for (const [key, costs] of Object.entries(FALLBACK_PRICING)) {