mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-28 06:59:37 +00:00
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:
parent
b61e7cd32a
commit
a4d261a536
6 changed files with 117 additions and 6 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, readFile, rm } from 'fs/promises'
|
||||
import { mkdtemp, readFile, readdir, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
|
|
@ -134,4 +134,26 @@ describe('exportCsv', () => {
|
|||
expect(content).toContain("'+danger-model")
|
||||
expect(content).toContain("'@malicious")
|
||||
})
|
||||
|
||||
it('escapes tab and carriage-return prefixes in CSV cells', async () => {
|
||||
const periods: PeriodExport[] = [
|
||||
{
|
||||
label: '30 Days',
|
||||
projects: [makeProject('\tcmd'), makeProject('\rcmd')],
|
||||
},
|
||||
]
|
||||
|
||||
const outputPath = join(tmpDir, 'tab-cr.csv')
|
||||
const folder = await exportCsv(periods, outputPath)
|
||||
const projects = await readFile(join(folder, 'projects.csv'), 'utf-8')
|
||||
expect(projects).toContain("'\tcmd")
|
||||
expect(projects).toContain("'\rcmd")
|
||||
})
|
||||
|
||||
it('does not crash when periods array is empty', async () => {
|
||||
const outputPath = join(tmpDir, 'empty.csv')
|
||||
const folder = await exportCsv([], outputPath)
|
||||
const entries = await readdir(folder)
|
||||
expect(entries.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
MAX_SESSION_FILE_BYTES,
|
||||
STREAM_THRESHOLD_BYTES,
|
||||
readSessionFile,
|
||||
readSessionLines,
|
||||
} from '../src/fs-utils.js'
|
||||
|
||||
describe('readSessionFile', () => {
|
||||
|
|
@ -61,3 +62,37 @@ describe('readSessionFile', () => {
|
|||
expect(await readSessionFile('/nonexistent/path/x.jsonl')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readSessionLines', () => {
|
||||
const tmpDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
while (tmpDirs.length > 0) {
|
||||
const d = tmpDirs.pop()
|
||||
if (d) await rm(d, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function tmpPath(content: string): Promise<string> {
|
||||
const base = await mkdtemp(join(tmpdir(), 'codeburn-lines-'))
|
||||
tmpDirs.push(base)
|
||||
const p = join(base, 'session.jsonl')
|
||||
await writeFile(p, content)
|
||||
return p
|
||||
}
|
||||
|
||||
it('yields all lines from a file', async () => {
|
||||
const p = await tmpPath('line1\nline2\nline3\n')
|
||||
const lines: string[] = []
|
||||
for await (const line of readSessionLines(p)) lines.push(line)
|
||||
expect(lines).toEqual(['line1', 'line2', 'line3'])
|
||||
})
|
||||
|
||||
it('does not leak file descriptors when generator is abandoned early', async () => {
|
||||
const content = Array.from({ length: 1000 }, (_, i) => `line-${i}`).join('\n')
|
||||
const p = await tmpPath(content)
|
||||
const gen = readSessionLines(p)
|
||||
await gen.next()
|
||||
await gen.return(undefined)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
52
tests/models.test.ts
Normal file
52
tests/models.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
|
||||
import { getModelCosts, getShortModelName, loadPricing } from '../src/models.js'
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadPricing()
|
||||
})
|
||||
|
||||
describe('getModelCosts', () => {
|
||||
it('does not match short canonical against longer pricing key', () => {
|
||||
const costs = getModelCosts('gpt-4')
|
||||
if (costs) {
|
||||
expect(costs.inputCostPerToken).not.toBe(2.5e-6)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns correct pricing for gpt-4o vs gpt-4o-mini', () => {
|
||||
const mini = getModelCosts('gpt-4o-mini')
|
||||
const full = getModelCosts('gpt-4o')
|
||||
expect(mini).not.toBeNull()
|
||||
expect(full).not.toBeNull()
|
||||
expect(mini!.inputCostPerToken).toBeLessThan(full!.inputCostPerToken)
|
||||
})
|
||||
|
||||
it('returns fallback pricing for known Claude models', () => {
|
||||
const costs = getModelCosts('claude-opus-4-6-20260205')
|
||||
expect(costs).not.toBeNull()
|
||||
expect(costs!.inputCostPerToken).toBe(5e-6)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getShortModelName', () => {
|
||||
it('maps gpt-4o-mini correctly (not gpt-4o)', () => {
|
||||
expect(getShortModelName('gpt-4o-mini-2024-07-18')).toBe('GPT-4o Mini')
|
||||
})
|
||||
|
||||
it('maps gpt-4o correctly', () => {
|
||||
expect(getShortModelName('gpt-4o-2024-08-06')).toBe('GPT-4o')
|
||||
})
|
||||
|
||||
it('maps gpt-4.1-mini correctly (not gpt-4.1)', () => {
|
||||
expect(getShortModelName('gpt-4.1-mini-2025-04-14')).toBe('GPT-4.1 Mini')
|
||||
})
|
||||
|
||||
it('maps gpt-5.4-mini correctly (not gpt-5.4)', () => {
|
||||
expect(getShortModelName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
|
||||
})
|
||||
|
||||
it('maps claude-opus-4-6 with date suffix', () => {
|
||||
expect(getShortModelName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue