Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/*
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 字段。多个主机名用逗号分隔:
Expand Down
54 changes: 49 additions & 5 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
59 changes: 52 additions & 7 deletions scripts/docker/lite-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
29 changes: 29 additions & 0 deletions src/auth/usage-pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();

/**
* 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;
Expand Down
29 changes: 23 additions & 6 deletions src/logs/metrics.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions src/routes/shared/proxy-handler-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof loadPricingCatalog> | null = null;

Expand All @@ -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 };
Expand Down
Loading
Loading