From f59122dd30e900cda2785d4608a5ca8f40508b73 Mon Sep 17 00:00:00 2001 From: nieyuanhong Date: Thu, 8 Oct 2026 01:15:51 +0800 Subject: [PATCH 1/4] fix(docker): seed missing config defaults and surface pricing load failures Both entrypoints only seeded the config volume when it was completely empty, so an existing volume never received config files added by a newer image. The reported symptom: model-pricing.yaml never reached /app/config, token accounting worked, but every estimated cost stayed 0 (#837). Seeding now runs on every start with `cp -rn`: missing files (including nested new ones) are added, existing files - user edits and older defaults - are never overwritten, the number of seeded files is logged, and a seeding failure warns instead of aborting startup. Pricing load failures were equally invisible: getCatalog() swallowed the error and cached the empty catalog forever, and annotateUsageCost() deliberately suppressed the ENOENT warning, so a deployment problem looked like genuinely zero-cost usage. Failures now warn once with the resolved file path and reason (deduplicated per path+reason with a 5 minute cooldown), the empty catalog is only cached for 60 seconds before a retry, and recovery is logged - dropping the missing file into the config volume restores pricing without a restart. Also narrows the .gitignore `logs/` rule to `/logs/`: the bare pattern also matched source directories, so `git add src/logs/` was refused and new files under src/logs/ or tests/unit/logs/ would be silently ignored. Refs #837 --- .gitignore | 4 +- CHANGELOG.md | 8 +- README.md | 2 + docker-entrypoint.sh | 24 ++- scripts/docker/lite-entrypoint.sh | 29 ++- src/auth/usage-pricing.ts | 29 +++ src/logs/metrics.ts | 29 ++- src/routes/shared/proxy-handler-utils.ts | 12 +- tests/unit/ci/docker-entrypoint-seed.test.ts | 190 +++++++++++++++++++ tests/unit/logs/metrics.test.ts | 93 ++++++++- 10 files changed, 391 insertions(+), 29 deletions(-) create mode 100644 tests/unit/ci/docker-entrypoint-seed.test.ts diff --git a/.gitignore b/.gitignore index e90cd6cd8..db2f0138c 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 f79c94ca2..0325d0d44 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 0c970c6f5..39322e9a8 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 9a344e9d0..e79a9ef3f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -15,12 +15,26 @@ 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. +# -r recursive, -n no-clobber: only files missing from the volume 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. 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}" +if [ -d "$DEFAULTS_DIR" ]; then + before=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + if mkdir -p "$CONFIG_DIR" 2>/dev/null && cp -rn "$DEFAULTS_DIR/." "$CONFIG_DIR/" 2>/dev/null; then + after=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + echo "[Init] Config defaults: $((after - before)) missing file(s) seeded from the image (existing files preserved)" + else + echo "[Init] WARNING: could not seed missing config defaults from $DEFAULTS_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 fc5fceb5d..665370d6c 100644 --- a/scripts/docker/lite-entrypoint.sh +++ b/scripts/docker/lite-entrypoint.sh @@ -1,13 +1,28 @@ #!/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. -r recursive, +# -n no-clobber: only files missing from the volume 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. 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}" +if [ -d "$DEFAULTS_DIR" ]; then + before=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + if mkdir -p "$CONFIG_DIR" 2>/dev/null && cp -rn "$DEFAULTS_DIR/." "$CONFIG_DIR/" 2>/dev/null; then + after=$(find "$CONFIG_DIR" -type f 2>/dev/null | wc -l | tr -d ' ') + echo "[Init] Config defaults: $((after - before)) missing file(s) seeded from the image (existing files preserved)" + else + echo "[Init] WARNING: could not seed missing config defaults from $DEFAULTS_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 7c538bbf1..aad2e6693 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 c0f96c2c6..412bb0d20 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 909881fc3..0a6684ed2 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 000000000..5e10458b7 --- /dev/null +++ b/tests/unit/ci/docker-entrypoint-seed.test.ts @@ -0,0 +1,190 @@ +/** + * 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("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("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 d874cf42f..3d57fc4d0 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); + }); +}); From 5776103fecde7993d84c27862364059572db546b Mon Sep 17 00:00:00 2001 From: nieyuanhong Date: Thu, 8 Oct 2026 01:18:07 +0800 Subject: [PATCH 2/4] fix(docker): make config seeding work under busybox cp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified in an alpine container: busybox `cp -rn` skips an existing destination directory as a whole, so `cp -rn /defaults/. /app/config/` copied nothing at all on the Lite image, and the glob form still missed files nested inside an existing subdirectory (e.g. a new file in prompts/). Replace it with an explicit recursive walk that copies only missing files — same behavior under GNU and busybox cp — and keep per-entry warnings so a partial failure stays visible without aborting startup. Refs #837 --- docker-entrypoint.sh | 38 ++++++++++++++++++++++------- scripts/docker/lite-entrypoint.sh | 40 ++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index e79a9ef3f..0963e3058 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -16,22 +16,44 @@ if [ -z "${CODEX_ARCH}" ]; then fi # Seed config defaults from the image into the mounted config volume. -# -r recursive, -n no-clobber: only files missing from the volume 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. The path overrides exist so -# this block can be exercised outside a container. +# 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 + 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" 2>/dev/null && cp -rn "$DEFAULTS_DIR/." "$CONFIG_DIR/" 2>/dev/null; then + 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 ' ') echo "[Init] Config defaults: $((after - before)) missing file(s) seeded from the image (existing files preserved)" else - echo "[Init] WARNING: could not seed missing config defaults from $DEFAULTS_DIR — continuing with the existing config volume" >&2 + echo "[Init] WARNING: could not create $CONFIG_DIR — continuing with the existing config volume" >&2 fi fi # <<< config-seed diff --git a/scripts/docker/lite-entrypoint.sh b/scripts/docker/lite-entrypoint.sh index 665370d6c..57f74da55 100644 --- a/scripts/docker/lite-entrypoint.sh +++ b/scripts/docker/lite-entrypoint.sh @@ -4,23 +4,45 @@ set -e # 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. -r recursive, -# -n no-clobber: only files missing from the volume 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. The path overrides exist so this -# block can be exercised outside a container. +# 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 + 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" 2>/dev/null && cp -rn "$DEFAULTS_DIR/." "$CONFIG_DIR/" 2>/dev/null; then + 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 ' ') echo "[Init] Config defaults: $((after - before)) missing file(s) seeded from the image (existing files preserved)" else - echo "[Init] WARNING: could not seed missing config defaults from $DEFAULTS_DIR — continuing with the existing config volume" >&2 + echo "[Init] WARNING: could not create $CONFIG_DIR — continuing with the existing config volume" >&2 fi fi # <<< config-seed From 47c01e1b135836015540cbaad64e92da7e0c1817 Mon Sep 17 00:00:00 2001 From: nieyuanhong Date: Thu, 8 Oct 2026 01:27:15 +0800 Subject: [PATCH 3/4] fix(docker): keep the entrypoint quiet when nothing needs seeding The image smoke test runs `node -e "...getProxyInfo().version..."` inside a container without overriding the entrypoint, so it parses the container's stdout for the version. An unconditional "0 missing file(s) seeded" line was prepended to that output and made the version comparison fail. Only report seeding when files were actually copied, and add a regression test asserting the entrypoint prints nothing when the config volume already holds every default file. Refs #837 --- docker-entrypoint.sh | 8 ++++++- scripts/docker/lite-entrypoint.sh | 8 ++++++- tests/unit/ci/docker-entrypoint-seed.test.ts | 22 ++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0963e3058..70add8b1c 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -51,7 +51,13 @@ if [ -d "$DEFAULTS_DIR" ]; then 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 ' ') - echo "[Init] Config defaults: $((after - before)) missing file(s) seeded from the image (existing files preserved)" + 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 diff --git a/scripts/docker/lite-entrypoint.sh b/scripts/docker/lite-entrypoint.sh index 57f74da55..87d90203e 100644 --- a/scripts/docker/lite-entrypoint.sh +++ b/scripts/docker/lite-entrypoint.sh @@ -40,7 +40,13 @@ if [ -d "$DEFAULTS_DIR" ]; then 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 ' ') - echo "[Init] Config defaults: $((after - before)) missing file(s) seeded from the image (existing files preserved)" + 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 diff --git a/tests/unit/ci/docker-entrypoint-seed.test.ts b/tests/unit/ci/docker-entrypoint-seed.test.ts index 5e10458b7..6f2477f69 100644 --- a/tests/unit/ci/docker-entrypoint-seed.test.ts +++ b/tests/unit/ci/docker-entrypoint-seed.test.ts @@ -170,6 +170,28 @@ describeIfShell.each(ENTRYPOINTS)("config seeding in %s", (entrypoint) => { 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" }, From dc9e32298e3eff6826a597552fbf1f641d0a4568 Mon Sep 17 00:00:00 2001 From: SsuJo_ <1049731887@qq.com> Date: Thu, 8 Oct 2026 20:17:23 +0800 Subject: [PATCH 4/4] fix(docker): isolate config seeding recursion variables --- docker-entrypoint.sh | 4 +++- scripts/docker/lite-entrypoint.sh | 4 +++- tests/unit/ci/docker-entrypoint-seed.test.ts | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 70add8b1c..64588929f 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -36,7 +36,9 @@ seed_config_defaults() { name=$(basename "$entry") if [ -d "$entry" ]; then if mkdir -p "$dst_dir/$name" 2>/dev/null; then - seed_config_defaults "$entry" "$dst_dir/$name" + # 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 diff --git a/scripts/docker/lite-entrypoint.sh b/scripts/docker/lite-entrypoint.sh index 87d90203e..273835935 100644 --- a/scripts/docker/lite-entrypoint.sh +++ b/scripts/docker/lite-entrypoint.sh @@ -25,7 +25,9 @@ seed_config_defaults() { name=$(basename "$entry") if [ -d "$entry" ]; then if mkdir -p "$dst_dir/$name" 2>/dev/null; then - seed_config_defaults "$entry" "$dst_dir/$name" + # 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 diff --git a/tests/unit/ci/docker-entrypoint-seed.test.ts b/tests/unit/ci/docker-entrypoint-seed.test.ts index 6f2477f69..62b422140 100644 --- a/tests/unit/ci/docker-entrypoint-seed.test.ts +++ b/tests/unit/ci/docker-entrypoint-seed.test.ts @@ -132,6 +132,23 @@ describeIfShell.each(ENTRYPOINTS)("config seeding in %s", (entrypoint) => { 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" },