diff --git a/.gitignore b/.gitignore index e90cd6cd..db2f0138 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,9 @@ bin/* .env.* !.env.example *.log -logs/ +# Runtime log output at the repo root only — a bare `logs/` also matched +# source directories such as src/logs/ and tests/unit/logs/. +/logs/ .asar-out/ tmp/ .claude/* diff --git a/CHANGELOG.md b/CHANGELOG.md index f79c94ca..0325d0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,13 @@ ## [Unreleased] -> 暂无已记录的变更。 +### Fixed + +- 修复 Docker 部署下已有配置卷永远拿不到镜像新增默认文件的问题(#837):标准版与 Lite 版 entrypoint 此前只在配置目录**完全为空**时从镜像 `/defaults` 复制一次,配置卷一旦被播种过,后续镜像升级新增的配置文件(如 `model-pricing.yaml`)就再也不会进入运行时配置目录——表现为 token 统计正常但估算成本恒为 0,旧版 `models.yaml` 等默认值同样不会更新。现在每次启动都按文件补种:递归 `cp -rn`(no-clobber)只补齐缺失文件,用户改过的文件与旧默认值一概不覆盖,嵌套新增文件(如 `prompts/` 目录内新增的提示词)同样补齐,并输出本次补种文件数;补种失败(目录不可创建或不可写)只告警、不阻断启动。路径可用 `CODEX_ENTRYPOINT_DEFAULTS_DIR` / `CODEX_ENTRYPOINT_CONFIG_DIR` 覆盖。(`docker-entrypoint.sh`、`scripts/docker/lite-entrypoint.sh`、`tests/unit/ci/docker-entrypoint-seed.test.ts`) + +- 修复价格表加载失败完全静默、且失败产生的空价格表被进程永久缓存的问题(#837):`src/logs/metrics.ts` 的 `getCatalog()` 捕获异常后静默把价格表设为 `{}` 并永久缓存,`annotateUsageCost()` 也特意吞掉 ENOENT 警告,用户容易把「价格表没加载」误认为「成本真的是 0」。现在加载失败会带实际文件路径与原因告警(按路径+原因去重、5 分钟冷却,避免每请求刷屏),空表只缓存 60 秒后重试,把缺失文件补进配置卷后无需重启即可恢复计价并输出恢复日志。(`src/auth/usage-pricing.ts`、`src/logs/metrics.ts`、`src/routes/shared/proxy-handler-utils.ts`、`tests/unit/logs/metrics.test.ts`) + +> 暂无其他已记录的变更。 ## [v2.1.x](https://github.com/icebear0828/codex-proxy/releases?q=2.1) - 2026-09-01 至 2026-09-07 diff --git a/README.md b/README.md index 0c970c6f..39322e9a 100644 --- a/README.md +++ b/README.md @@ -798,6 +798,8 @@ for await (const chunk of stream) { > **重要**:不要直接修改 `config/default.yaml`,该文件会在版本更新时被覆盖。自定义配置请通过 Dashboard 设置面板修改(自动保存到 `data/local.yaml`),或手动创建 `data/local.yaml` 写入需要覆盖的字段。`data/` 目录不受更新影响。 +> **Docker 部署的差异**:容器启动时只从镜像内的默认配置**按文件补种**缺失项(`cp -rn`,只补不覆盖)。因此升级镜像后新增的默认配置文件(如 `model-pricing.yaml`)会自动出现在已有配置卷中,而你改过的文件不会被覆盖;反过来说,`config/` 卷里已存在的旧默认值也不会随镜像更新。无论哪种部署方式,自定义配置都请写入 `data/local.yaml`。 + ### CORS 允许主机 通过环境变量 `CORS_ALLOWED_HOSTS` 可以配置允许跨域访问的主机列表,对应配置文件中的 `server.cors` 字段。多个主机名用逗号分隔: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 9a344e9d..64588929 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -15,12 +15,56 @@ if [ -z "${CODEX_ARCH}" ]; then export CODEX_ARCH fi -# Seed empty config bind mount with defaults from the image -if [ -d /defaults ] && [ -z "$(ls -A /app/config 2>/dev/null)" ]; then - echo "[Init] Config directory is empty — seeding from image defaults" - mkdir -p /app/config - cp -r /defaults/* /app/config/ +# Seed config defaults from the image into the mounted config volume. +# Only files the volume is missing are copied, so user edits and previously +# seeded defaults are never overwritten, while a newer image still delivers +# the config files it added (e.g. model-pricing.yaml) to an existing volume — +# the previous "directory is empty" check skipped every volume that had +# already been seeded once. Implemented as an explicit walk because busybox +# `cp -rn` skips an existing destination directory whole, which would miss +# files added inside an existing subdirectory (e.g. prompts/). The path +# overrides exist so this block can be exercised outside a container. +# >>> config-seed +DEFAULTS_DIR="${CODEX_ENTRYPOINT_DEFAULTS_DIR:-/defaults}" +CONFIG_DIR="${CODEX_ENTRYPOINT_CONFIG_DIR:-/app/config}" + +seed_config_defaults() { + src_dir="$1" + dst_dir="$2" + for entry in "$src_dir"/*; do + [ -e "$entry" ] || continue + name=$(basename "$entry") + if [ -d "$entry" ]; then + if mkdir -p "$dst_dir/$name" 2>/dev/null; then + # Run recursive calls in a subshell: POSIX sh has no portable `local`, + # and the child must not overwrite this frame's loop variables. + ( seed_config_defaults "$entry" "$dst_dir/$name" ) + else + echo "[Init] WARNING: cannot create $dst_dir/$name — skipping that subtree" >&2 + fi + elif [ ! -e "$dst_dir/$name" ]; then + cp "$entry" "$dst_dir/$name" 2>/dev/null || echo "[Init] WARNING: cannot copy $entry to $dst_dir/$name" >&2 + fi + done +} + +if [ -d "$DEFAULTS_DIR" ]; then + before=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + if mkdir -p "$CONFIG_DIR"; then + seed_config_defaults "$DEFAULTS_DIR" "$CONFIG_DIR" + after=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + seeded=$((after - before)) + # Stay quiet when there is nothing to do: tooling reads this container's + # stdout (e.g. the image smoke test parsing a version), so a no-op start + # must not add noise. + if [ "$seeded" -gt 0 ]; then + echo "[Init] Config defaults: $seeded missing file(s) seeded from the image (existing files preserved)" + fi + else + echo "[Init] WARNING: could not create $CONFIG_DIR — continuing with the existing config volume" >&2 + fi fi +# <<< config-seed # Ensure mounted volumes are writable by the node user (UID 1000). # When Docker auto-creates bind-mount directories on the host, diff --git a/scripts/docker/lite-entrypoint.sh b/scripts/docker/lite-entrypoint.sh index fc5fceb5..27383593 100644 --- a/scripts/docker/lite-entrypoint.sh +++ b/scripts/docker/lite-entrypoint.sh @@ -1,13 +1,58 @@ #!/bin/sh set -e -# Seed an empty config bind mount with the image defaults. docker-compose.yml -# mounts ./config onto /app/config; on a fresh install that directory is empty -# and shadows the config files baked into the image — without seeding, the -# server exits on the missing default.yaml. -if [ -d /defaults ] && [ -z "$(ls -A /app/config 2>/dev/null)" ]; then - echo "[Init] Config directory is empty — seeding from image defaults" - cp -r /defaults/* /app/config/ +# Seed config defaults from the image into the mounted config volume. +# docker-compose.yml mounts ./config onto /app/config; on a fresh install that +# directory is empty and shadows the config files baked into the image — +# without seeding, the server exits on the missing default.yaml. Only files +# the volume is missing are copied, so user edits and previously seeded +# defaults are never overwritten, while a newer image still delivers config +# files it added (e.g. model-pricing.yaml) to an existing volume — the +# previous "directory is empty" check skipped every volume that had already +# been seeded once. Implemented as an explicit walk because busybox `cp -rn` +# skips an existing destination directory whole, which would miss files added +# inside an existing subdirectory (e.g. prompts/). The path overrides exist so +# this block can be exercised outside a container. +# >>> config-seed +DEFAULTS_DIR="${CODEX_ENTRYPOINT_DEFAULTS_DIR:-/defaults}" +CONFIG_DIR="${CODEX_ENTRYPOINT_CONFIG_DIR:-/app/config}" + +seed_config_defaults() { + src_dir="$1" + dst_dir="$2" + for entry in "$src_dir"/*; do + [ -e "$entry" ] || continue + name=$(basename "$entry") + if [ -d "$entry" ]; then + if mkdir -p "$dst_dir/$name" 2>/dev/null; then + # Run recursive calls in a subshell: POSIX sh has no portable `local`, + # and the child must not overwrite this frame's loop variables. + ( seed_config_defaults "$entry" "$dst_dir/$name" ) + else + echo "[Init] WARNING: cannot create $dst_dir/$name — skipping that subtree" >&2 + fi + elif [ ! -e "$dst_dir/$name" ]; then + cp "$entry" "$dst_dir/$name" 2>/dev/null || echo "[Init] WARNING: cannot copy $entry to $dst_dir/$name" >&2 + fi + done +} + +if [ -d "$DEFAULTS_DIR" ]; then + before=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + if mkdir -p "$CONFIG_DIR"; then + seed_config_defaults "$DEFAULTS_DIR" "$CONFIG_DIR" + after=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + seeded=$((after - before)) + # Stay quiet when there is nothing to do: tooling reads this container's + # stdout (e.g. the image smoke test parsing a version), so a no-op start + # must not add noise. + if [ "$seeded" -gt 0 ]; then + echo "[Init] Config defaults: $seeded missing file(s) seeded from the image (existing files preserved)" + fi + else + echo "[Init] WARNING: could not create $CONFIG_DIR — continuing with the existing config volume" >&2 + fi fi +# <<< config-seed exec "$@" diff --git a/src/auth/usage-pricing.ts b/src/auth/usage-pricing.ts index 7c538bbf..aad2e669 100644 --- a/src/auth/usage-pricing.ts +++ b/src/auth/usage-pricing.ts @@ -61,6 +61,35 @@ export function loadPricingCatalog(configDir = getConfigDir()): PricingCatalog { return createPricingCatalog(entries); } +const PRICING_WARN_COOLDOWN_MS = 5 * 60_000; +const lastPricingWarnAt = new Map(); + +/** + * Report a pricing-catalog load failure with the resolved file path. + * + * Missing or invalid pricing used to degrade every estimated cost to zero with + * no trace at all (#837); callers that swallow the load error must call this so + * the condition is at least visible to the operator. Warnings are deduplicated + * per (path, reason), so a caller that retries on every request stays quiet. + */ +export function warnPricingLoadFailure(error: unknown, configDir = getConfigDir()): void { + const path = resolve(configDir, PRICE_FILE); + const reason = error instanceof Error ? error.message : String(error); + const signature = `${path}|${reason}`; + const now = Date.now(); + const last = lastPricingWarnAt.get(signature); + if (last !== undefined && now - last < PRICING_WARN_COOLDOWN_MS) return; + lastPricingWarnAt.set(signature, now); + console.warn( + `[pricing] cannot load ${path}: ${reason} — usage is still recorded, but estimated costs stay unavailable until the file loads`, + ); +} + +/** Test hook: clear the pricing-warning dedupe state. */ +export function resetPricingLoadWarnings(): void { + lastPricingWarnAt.clear(); +} + export function resolveModelPricing(model: string, catalog: PricingCatalog): ModelPricing | null { const normalized = model.trim(); if (!normalized) return null; diff --git a/src/logs/metrics.ts b/src/logs/metrics.ts index c0f96c2c..412bb0d2 100644 --- a/src/logs/metrics.ts +++ b/src/logs/metrics.ts @@ -1,4 +1,4 @@ -import { calculateUsageCostUsd, loadPricingCatalog, type PricingCatalog, type UsageCostInput } from "../auth/usage-pricing.js"; +import { calculateUsageCostUsd, loadPricingCatalog, warnPricingLoadFailure, type PricingCatalog, type UsageCostInput } from "../auth/usage-pricing.js"; import type { UsageInfo } from "../translation/codex-event-extractor.js"; export interface LogMetrics { @@ -25,20 +25,37 @@ export interface CalculateLogMetricsOptions { } let cachedCatalog: PricingCatalog | null = null; +/** Epoch ms before which a previously failed load is not retried. */ +let catalogRetryAfter = 0; + +const EMPTY_PRICING_CATALOG: PricingCatalog = {}; +const PRICING_RETRY_MS = 60_000; function getCatalog(): PricingCatalog { - if (!cachedCatalog) { - try { - cachedCatalog = loadPricingCatalog(); - } catch { - cachedCatalog = {}; + if (cachedCatalog) return cachedCatalog; + if (Date.now() < catalogRetryAfter) return EMPTY_PRICING_CATALOG; + try { + cachedCatalog = loadPricingCatalog(); + if (catalogRetryAfter !== 0) { + catalogRetryAfter = 0; + console.info("[pricing] pricing catalog loaded — estimated costs are available again"); } + } catch (err) { + // Surface the failure with the actual file path instead of pricing every + // request at zero without a trace, and retry after a cooldown rather than + // caching the empty fallback for the lifetime of the process: a user can + // drop the missing file into the config volume and costs recover without a + // restart (#837). + warnPricingLoadFailure(err); + catalogRetryAfter = Date.now() + PRICING_RETRY_MS; + return EMPTY_PRICING_CATALOG; } return cachedCatalog; } export function resetPricingCatalogCache(): void { cachedCatalog = null; + catalogRetryAfter = 0; } export function calculateLogMetrics(options: CalculateLogMetricsOptions): LogMetrics { diff --git a/src/routes/shared/proxy-handler-utils.ts b/src/routes/shared/proxy-handler-utils.ts index 909881fc..0a6684ed 100644 --- a/src/routes/shared/proxy-handler-utils.ts +++ b/src/routes/shared/proxy-handler-utils.ts @@ -3,7 +3,7 @@ import type { CookieJar } from "../../proxy/cookie-jar.js"; import type { CodexFingerprintMode } from "../../auth/types.js"; import type { ProxyPool } from "../../proxy/proxy-pool.js"; import type { UsageInfo } from "../../translation/codex-event-extractor.js"; -import { calculateUsageCostUsd, loadPricingCatalog, resolveModelPricing } from "../../auth/usage-pricing.js"; +import { calculateUsageCostUsd, loadPricingCatalog, resolveModelPricing, warnPricingLoadFailure } from "../../auth/usage-pricing.js"; let pricingCatalog: ReturnType | null = null; @@ -22,11 +22,11 @@ export function annotateUsageCost(model: string | undefined, usage: UsageInfo | if (!resolveModelPricing(model, catalog)) return usage; estimatedCost = calculateUsageCostUsd(model, usage, catalog); } catch (err) { - // Test fixtures and minimal deployments may not ship the optional catalog. - // Preserve the legacy release payload until pricing data is available. - if (err instanceof Error && !err.message.includes("ENOENT")) { - console.warn(`[UsagePricing] Failed to calculate cost for model ${model}:`, err.message); - } + // A missing or invalid catalog is a deployment problem worth surfacing — + // it silently turned every estimated cost into zero before (#837). The + // warning is deduplicated inside usage-pricing, so the per-request retry + // stays quiet while the file is absent. + warnPricingLoadFailure(err); return usage; } return { ...usage, model, estimated_cost_usd: estimatedCost }; diff --git a/tests/unit/ci/docker-entrypoint-seed.test.ts b/tests/unit/ci/docker-entrypoint-seed.test.ts new file mode 100644 index 00000000..62b42214 --- /dev/null +++ b/tests/unit/ci/docker-entrypoint-seed.test.ts @@ -0,0 +1,229 @@ +/** + * Tests for the config-seed block shared by docker-entrypoint.sh and + * scripts/docker/lite-entrypoint.sh (#837). + * + * Strategy: extract the block between the `config-seed` markers from each real + * entrypoint and run it under `sh` with the path overrides pointed at temp + * directories — no Docker, /defaults, chown or gosu needed. Running the same + * cases against both entrypoints also catches drift between them. + */ + +import { describe, it, expect, afterAll } from "vitest"; +import { execFileSync, spawnSync } from "child_process"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "fs"; +import { dirname, join } from "path"; +import { tmpdir } from "os"; + +const ENTRYPOINTS = [ + "docker-entrypoint.sh", + "scripts/docker/lite-entrypoint.sh", +]; + +const tmpBase = mkdtempSync(join(tmpdir(), "entrypoint-seed-")); + +/** + * A shell that can actually run a script file. The plain `sh -c exit 0` probe + * used by other entrypoint tests is not enough on Windows, where `sh` may be + * resolvable but unusable — these tests need real script execution. + */ +function findShell(): string | null { + const probe = join(tmpBase, "probe.sh"); + writeFileSync(probe, "#!/bin/sh\necho ok\n"); + for (const candidate of ["/bin/sh", "/usr/bin/sh", "sh"]) { + if (candidate.includes("/") && !existsSync(candidate)) continue; + try { + const out = execFileSync(candidate, [probe], { encoding: "utf-8", timeout: 5000 }).trim(); + if (out === "ok") return candidate; + } catch { + // try next candidate + } + } + return null; +} + +const shell = findShell(); +const describeIfShell = shell ? describe : describe.skip; + +function extractSeedBlock(file: string): string { + const source = readFileSync(file, "utf8"); + const match = source.match(/# >>> config-seed\r?\n([\s\S]*?)# <<< config-seed/); + if (!match) throw new Error(`config-seed block not found in ${file}`); + return match[1]; +} + +interface SeedFixture { + defaultsDir: string; + configDir: string; +} + +function makeFixture(options: { + defaults: Record; + config: Record; + configDirExists?: boolean; +}): SeedFixture { + const root = mkdtempSync(join(tmpBase, "case-")); + const defaultsDir = join(root, "defaults"); + const configDir = join(root, "config"); + mkdirSync(defaultsDir, { recursive: true }); + if (options.configDirExists !== false) mkdirSync(configDir, { recursive: true }); + for (const [rel, content] of Object.entries(options.defaults)) { + const target = join(defaultsDir, rel); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + } + for (const [rel, content] of Object.entries(options.config)) { + const target = join(configDir, rel); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + } + return { defaultsDir, configDir }; +} + +function runSeedBlock(entrypoint: string, fixture: SeedFixture): string { + if (!shell) throw new Error("sh is not available"); + const scriptPath = join(tmpBase, `seed-block-${entrypoint.replace(/[\\/]/g, "_")}.sh`); + // `set -e` mirrors the entrypoints' own shell options, so a block that would + // abort the real entrypoint on a no-op or a failure also fails here. + writeFileSync(scriptPath, `#!/bin/sh\nset -e\n${extractSeedBlock(entrypoint)}\n`); + const result = spawnSync(shell, [scriptPath], { + env: { + ...process.env, + CODEX_ENTRYPOINT_DEFAULTS_DIR: fixture.defaultsDir, + CODEX_ENTRYPOINT_CONFIG_DIR: fixture.configDir, + }, + encoding: "utf-8", + timeout: 5000, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`seed block exited ${result.status}: ${result.stderr ?? ""}`); + } + // Warnings go to stderr; return both streams so assertions can see them. + return `${result.stdout ?? ""}${result.stderr ?? ""}`; +} + +afterAll(() => { + rmSync(tmpBase, { recursive: true, force: true }); +}); + +describeIfShell.each(ENTRYPOINTS)("config seeding in %s", (entrypoint) => { + it("seeds files missing from an existing volume and preserves user-edited files", () => { + const fixture = makeFixture({ + defaults: { + "default.yaml": "image: default\n", + "model-pricing.yaml": "models: {}\n", + "prompts/p.md": "new prompt\n", + }, + config: { + "default.yaml": "user-edited: true\n", + "prompts/old.md": "old prompt\n", + }, + }); + + const out = runSeedBlock(entrypoint, fixture); + + // A file the newer image added reaches the existing volume... + expect(readFileSync(join(fixture.configDir, "model-pricing.yaml"), "utf8")).toBe("models: {}\n"); + // ...nested new files are picked up as well... + expect(readFileSync(join(fixture.configDir, "prompts", "p.md"), "utf8")).toBe("new prompt\n"); + // ...while existing files stay untouched, whether user-edited or an older default. + expect(readFileSync(join(fixture.configDir, "default.yaml"), "utf8")).toBe("user-edited: true\n"); + expect(readFileSync(join(fixture.configDir, "prompts", "old.md"), "utf8")).toBe("old prompt\n"); + expect(out).toContain("2 missing file(s) seeded"); + }); + + it("keeps root-level files in the config root after a nested directory", () => { + const fixture = makeFixture({ + // The sorted directory is visited before the root-level file. + defaults: { + "a-prompts/p.md": "nested prompt\n", + "z-default.yaml": "root default\n", + }, + config: {}, + }); + + runSeedBlock(entrypoint, fixture); + + expect(readFileSync(join(fixture.configDir, "z-default.yaml"), "utf8")).toBe("root default\n"); + expect(readFileSync(join(fixture.configDir, "a-prompts", "p.md"), "utf8")).toBe("nested prompt\n"); + expect(existsSync(join(fixture.configDir, "a-prompts", "z-default.yaml"))).toBe(false); + }); + + it("seeds a fully empty volume", () => { + const fixture = makeFixture({ + defaults: { "default.yaml": "d\n", "model-pricing.yaml": "p\n" }, + config: {}, + }); + + const out = runSeedBlock(entrypoint, fixture); + + expect(readFileSync(join(fixture.configDir, "default.yaml"), "utf8")).toBe("d\n"); + expect(readFileSync(join(fixture.configDir, "model-pricing.yaml"), "utf8")).toBe("p\n"); + expect(out).toContain("2 missing file(s) seeded"); + }); + + it("creates the config directory when the mount target does not exist", () => { + const fixture = makeFixture({ + defaults: { "default.yaml": "d\n" }, + config: {}, + configDirExists: false, + }); + + runSeedBlock(entrypoint, fixture); + + expect(readFileSync(join(fixture.configDir, "default.yaml"), "utf8")).toBe("d\n"); + }); + + it("is a no-op when the image defaults are absent", () => { + const fixture = makeFixture({ + defaults: {}, + config: { "default.yaml": "user\n" }, + }); + rmSync(fixture.defaultsDir, { recursive: true, force: true }); + + const out = runSeedBlock(entrypoint, fixture); + + expect(out).toBe(""); + expect(readFileSync(join(fixture.configDir, "default.yaml"), "utf8")).toBe("user\n"); + }); + + it("stays quiet when the volume already has every default file", () => { + const fixture = makeFixture({ + defaults: { + "default.yaml": "image: default\n", + "model-pricing.yaml": "models: {}\n", + "prompts/p.md": "new prompt\n", + }, + config: { + "default.yaml": "user-edited: true\n", + "model-pricing.yaml": "models: {}\n", + "prompts/p.md": "new prompt\n", + }, + }); + + const out = runSeedBlock(entrypoint, fixture); + + // Tooling reads this container's stdout (the image smoke test parses a + // version out of it), so a no-op start must not print anything. + expect(out).toBe(""); + expect(readFileSync(join(fixture.configDir, "default.yaml"), "utf8")).toBe("user-edited: true\n"); + }); + + it("warns instead of failing when the config volume cannot be created", () => { + const fixture = makeFixture({ + defaults: { "default.yaml": "d\n" }, + config: {}, + }); + // A regular file where the config directory should be: `mkdir -p` fails the + // same way on every platform, unlike directory permissions. + const blockedRoot = mkdtempSync(join(tmpBase, "blocked-")); + const blockerFile = join(blockedRoot, "config-as-a-file"); + writeFileSync(blockerFile, "file\n"); + fixture.configDir = blockerFile; + + const out = runSeedBlock(entrypoint, fixture); + + expect(out).toContain("WARNING"); + expect(readFileSync(blockerFile, "utf8")).toBe("file\n"); + }); +}); diff --git a/tests/unit/logs/metrics.test.ts b/tests/unit/logs/metrics.test.ts index d874cf42..3d57fc4d 100644 --- a/tests/unit/logs/metrics.test.ts +++ b/tests/unit/logs/metrics.test.ts @@ -1,6 +1,15 @@ -import { describe, it, expect } from "vitest"; -import { calculateLogMetrics } from "../../../src/logs/metrics.js"; -import { createPricingCatalog } from "../../../src/auth/usage-pricing.js"; +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; +import { calculateLogMetrics, resetPricingCatalogCache } from "../../../src/logs/metrics.js"; +import { + createPricingCatalog, + loadPricingCatalog, + resetPricingLoadWarnings, +} from "../../../src/auth/usage-pricing.js"; + +vi.mock("../../../src/auth/usage-pricing.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadPricingCatalog: vi.fn(actual.loadPricingCatalog) }; +}); describe("calculateLogMetrics", () => { const catalog = createPricingCatalog({ @@ -91,3 +100,81 @@ describe("calculateLogMetrics", () => { expect(metrics.costUsd).toBe(0); }); }); + +describe("pricing catalog diagnostics (#837)", () => { + const usage = { input_tokens: 1000, output_tokens: 10 }; + const realCatalog = createPricingCatalog({ + "gpt-5.5": { + input_usd_per_million: 3.0, + cached_input_usd_per_million: 0.75, + output_usd_per_million: 15.0, + }, + }); + const mockedLoad = vi.mocked(loadPricingCatalog); + let warnSpy: MockInstance; + let infoSpy: MockInstance; + + const missingPricingError = () => + new Error("ENOENT: no such file or directory, open '/app/config/model-pricing.yaml'"); + + function pricingWarnings(): string[] { + return warnSpy.mock.calls + .map(([message]) => String(message)) + .filter((message) => message.includes("[pricing]")); + } + + beforeEach(() => { + vi.useFakeTimers(); + resetPricingCatalogCache(); + resetPricingLoadWarnings(); + mockedLoad.mockReset(); + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + infoSpy = vi.spyOn(console, "info").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.useRealTimers(); + warnSpy.mockRestore(); + infoSpy.mockRestore(); + }); + + it("warns once with the file path when the catalog is missing, and still reports zero cost", () => { + mockedLoad.mockImplementation(() => { + throw missingPricingError(); + }); + + const metrics = calculateLogMetrics({ startMs: 0, endMs: 1000, model: "gpt-5.5", usage }); + + expect(metrics.costUsd).toBe(0); + expect(pricingWarnings()).toHaveLength(1); + expect(pricingWarnings()[0]).toContain("model-pricing.yaml"); + }); + + it("does not re-read within the retry cooldown, then retries and recovers without a restart", () => { + mockedLoad.mockImplementation(() => { + throw missingPricingError(); + }); + + calculateLogMetrics({ startMs: 0, endMs: 1000, model: "gpt-5.5", usage }); + expect(mockedLoad).toHaveBeenCalledTimes(1); + + // Still failing and still inside the cooldown: no new read, no new warning. + calculateLogMetrics({ startMs: 0, endMs: 1000, model: "gpt-5.5", usage }); + expect(mockedLoad).toHaveBeenCalledTimes(1); + expect(pricingWarnings()).toHaveLength(1); + + // The operator drops the missing file into the config volume; the first + // attempt after the cooldown picks it up without a process restart. + vi.setSystemTime(Date.now() + 61_000); + mockedLoad.mockImplementation(() => realCatalog); + const recovered = calculateLogMetrics({ startMs: 0, endMs: 1000, model: "gpt-5.5", usage }); + + expect(mockedLoad).toHaveBeenCalledTimes(2); + expect(recovered.costUsd).toBeGreaterThan(0); + expect(infoSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("[pricing]"); + + // Once loaded, the catalog is cached again — no further reads. + calculateLogMetrics({ startMs: 0, endMs: 1000, model: "gpt-5.5", usage }); + expect(mockedLoad).toHaveBeenCalledTimes(2); + }); +});