diff --git a/src/router/config/parseRouterConfig.ts b/src/router/config/parseRouterConfig.ts index 00ff1946..5f160b3b 100644 --- a/src/router/config/parseRouterConfig.ts +++ b/src/router/config/parseRouterConfig.ts @@ -535,7 +535,20 @@ function parseStats( modelConfig, diagnostics, ); - return { enabled, modelPricing, baselineModel }; + let filePath: string | undefined; + if (raw.filePath !== undefined) { + if (typeof raw.filePath === "string") { + filePath = raw.filePath.trim().length > 0 ? raw.filePath : undefined; + } else { + diagnostics.push({ + code: "ROUTER_STATS_FILE_PATH_INVALID", + severity: "fatal", + path: "router.stats.filePath", + message: "filePath must be a string.", + }); + } + } + return { enabled, modelPricing, filePath, baselineModel }; } function parseCustomRouter( diff --git a/src/router/config/schema.ts b/src/router/config/schema.ts index bfb814ff..63f6a0dd 100644 --- a/src/router/config/schema.ts +++ b/src/router/config/schema.ts @@ -55,7 +55,7 @@ export const DEFAULT_SUBAGENT_MAX_TOKENS = 48000; export type RouterStatsConfig = { enabled: boolean; modelPricing?: Record; - /** Override the default ~/.pilotdeck/router/stats.json path (useful for tests). */ + /** Override the default ~/.pilotdeck/router/stats.jsonl path (useful for tests). */ filePath?: string; /** Provider/model ref used as the "no-router" baseline for savedCost calculation. */ baselineModel?: { provider: string; model: string }; diff --git a/src/router/stats/TokenStatsCollector.ts b/src/router/stats/TokenStatsCollector.ts index 9213e96b..d736d0c6 100644 --- a/src/router/stats/TokenStatsCollector.ts +++ b/src/router/stats/TokenStatsCollector.ts @@ -68,12 +68,13 @@ export class TokenStatsCollector { this.baselineModel = config?.baselineModel; if (this.enabled) { - const routerDir = config?.filePath - ? path.dirname(config.filePath) + const filePath = config?.filePath?.trim() ? config.filePath : undefined; + const routerDir = filePath + ? path.dirname(filePath) : path.join(resolvePilotHome(), "router"); try { fs.mkdirSync(routerDir, { recursive: true }); } catch { /* ok */ } - this.jsonlPath = path.join(routerDir, "stats.jsonl"); + this.jsonlPath = filePath ?? path.join(routerDir, "stats.jsonl"); // One-time migration: old JSON formats → JSONL migrateJsonToJsonl(routerDir, this.jsonlPath); diff --git a/tests/router-stats-filepath.test.ts b/tests/router-stats-filepath.test.ts new file mode 100644 index 00000000..ffaaa6d4 --- /dev/null +++ b/tests/router-stats-filepath.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; +import type { ModelConfig } from "../src/model/index.js"; +import { parseRouterConfig } from "../src/router/config/parseRouterConfig.js"; +import { TokenStatsCollector } from "../src/router/stats/TokenStatsCollector.js"; + +const modelConfig: ModelConfig = { providers: {} }; + +function observeOneRecord(collector: TokenStatsCollector): void { + collector.observe({ + sessionId: "session-1", + scenarioType: "default", + resolvedFrom: "scenario", + provider: "openai", + model: "gpt-4o-mini", + usage: { + inputTokens: 10, + outputTokens: 5, + }, + startedAt: "2026-06-23T00:00:00.000Z", + endedAt: "2026-06-23T00:00:01.000Z", + }); +} + +test("parseRouterConfig preserves router.stats.filePath", () => { + const filePath = "/tmp/custom-router-stats.jsonl"; + const result = parseRouterConfig( + { + stats: { + enabled: true, + filePath, + }, + }, + modelConfig, + ); + + assert.deepEqual(result.diagnostics, []); + assert.equal(result.config?.stats?.filePath, filePath); +}); + +test("parseRouterConfig treats blank router.stats.filePath as unset", () => { + const result = parseRouterConfig( + { + stats: { + enabled: true, + filePath: "", + }, + }, + modelConfig, + ); + + assert.deepEqual(result.diagnostics, []); + assert.equal(result.config?.stats?.filePath, undefined); +}); + +test("TokenStatsCollector writes to the configured stats file path", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pilotdeck-stats-")); + const customPath = path.join(tempDir, "custom-name.jsonl"); + const defaultPath = path.join(tempDir, "stats.jsonl"); + + try { + const collector = new TokenStatsCollector({ + enabled: true, + filePath: customPath, + }); + + observeOneRecord(collector); + collector.dispose(); + + assert.equal(fs.existsSync(customPath), true); + assert.equal(fs.existsSync(defaultPath), false); + + const [line] = fs.readFileSync(customPath, "utf-8").trim().split("\n"); + const record = JSON.parse(line!); + assert.equal(record.sessionId, "session-1"); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test("TokenStatsCollector falls back to the default path for blank filePath", () => { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "pilotdeck-home-")); + const previousPilotHome = process.env.PILOT_HOME; + + try { + process.env.PILOT_HOME = tempHome; + const collector = new TokenStatsCollector({ + enabled: true, + filePath: "", + }); + + observeOneRecord(collector); + collector.dispose(); + + const defaultPath = path.join(tempHome, "router", "stats.jsonl"); + assert.equal(fs.existsSync(defaultPath), true); + + const [line] = fs.readFileSync(defaultPath, "utf-8").trim().split("\n"); + const record = JSON.parse(line!); + assert.equal(record.sessionId, "session-1"); + } finally { + if (previousPilotHome === undefined) { + delete process.env.PILOT_HOME; + } else { + process.env.PILOT_HOME = previousPilotHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } +});