diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f18cbc726..d0d017f1da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,11 +235,11 @@ jobs: # # `bun test --shard=i/N` sorts test files by path and deals them round-robin, # so the split is deterministic for the files that remain in this lane. - # Storage-policy API tests are deliberately excluded here and run in the - # dedicated `storage-policy` job below. Bun 1.3.14 can corrupt the Linux - # isolate/epoll state around that Worker-heavy harness; keeping it out of the - # general shards prevents one runtime failure from wedging ~150 unrelated - # files while preserving the same coverage in a fresh Bun process. + # Storage-policy API tests and api-usage are deliberately excluded here and run + # in dedicated jobs below. Bun 1.3.14 can corrupt the Linux isolate/epoll state + # around those Worker-heavy harnesses; keeping them out of the general shards + # prevents one runtime failure from wedging ~150 unrelated files while preserving + # the same coverage in fresh Bun processes. # # Only the suite lives here. Typecheck, lint, build, and the scans run once in # `gates` rather than four times — they are fixed cost, and paying it per shard @@ -294,7 +294,7 @@ jobs: bun run build - name: Test - run: bun test --isolate tests --path-ignore-patterns 'tests/api-storage-policy*.test.ts' --shard=${{ matrix.shard }}/4 + run: bun test --isolate tests --path-ignore-patterns 'tests/api-storage-policy*.test.ts' --path-ignore-patterns 'tests/api-usage.test.ts' --shard=${{ matrix.shard }}/4 # Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy # harness. Keep the entire five-file family in one fresh process so a runtime @@ -336,6 +336,40 @@ jobs: ./tests/api-storage-policy-run.test.ts \ ./tests/api-storage-policy.test.ts + # Bun 1.3.14 has shown a Linux isolate wedge around startServer() plus the user + # cost overlay reconciler. Keep api-usage in one fresh process so a runtime + # failure is bounded to this job instead of poisoning a general test shard. + api-usage: + name: api usage + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: | + bun install --frozen-lockfile + cd gui + bun install --frozen-lockfile + + - name: Build GUI + run: | + cd gui + bun run build + + - name: Test api usage API + run: bun test --isolate ./tests/api-usage.test.ts + # Everything that is not the suite: type safety, privacy, lint, build, smoke. # One runner, once per push. Splitting these across the shards would repeat a # fixed couple of minutes four times to save nothing. @@ -667,7 +701,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped` — which the gate is required to read as a deliberate skip. - needs: [changes, select-windows-runner, test, storage-policy, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, platform-windows, keyring-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a99df988c3..e3a1583293 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -73,6 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。そのプロバイダーの正確なアップストリーム モデル ID をキーにします(プロバイダー識別子やルーティングされた `provider/model` ラベルではありません)。値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないモデル ID も、任意の OpenAI 互換エンドポイントを対象とするカスタムプロバイダーや、ローカル・内部プロバイダーで有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 | | `headers?` | `Record` |追加の上流ヘッダー。認証、Cookie、API キー ヘッダー、埋め込まれた改行、および無効な名前は拒否されます。 | | `openRouterRouting?` | `OpenRouterProviderRouting` |デフォルトの OpenRouter `order`、`only`、および `allowFallbacks` 設定。 `openai-chat` を持つ正規 OpenRouter に対してのみ有効です。 | | `modelOpenRouterRouting?` | `Record` |プロバイダー全体の OpenRouter 設定を置き換える正確なモデル ID のオーバーライド。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index b3a7cdb62b..c69156db13 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -73,6 +73,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `modelMaxInputTokens?` | `Record` | 카탈로그 자동 압축 힌트에 쓰는 양수 모델별 최대 입력 한도입니다. | | `defaultMaxOutputTokens?` | `number` | 클라이언트가 `max_output_tokens`를 생략했을 때 쓰는 공급자 전반의 `openai-chat` 폴백입니다. | | `modelMaxOutputTokens?` | `Record` | 양수 모델별 `openai-chat` 폴백 예산입니다. 정확한 일치와 패턴 일치가 공급자 기본값보다 우선합니다. | +| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 해당 공급자의 정확한 업스트림 모델 ID를 키로 사용하며(공급자 식별자나 라우팅된 `provider/model` 레이블이 아님) 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 커스텀 공급자는 `openai-chat` 어댑터로 임의의 OpenAI 호환 엔드포인트를 대상으로 할 수 있으며, 내장 카탈로그에 없는 로컬·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | | `headers?` | `Record` | 추가 상위 헤더입니다. Authorization, cookies, API-key 헤더, 내장 개행, 잘못된 이름은 허용하지 않습니다. | | `openRouterRouting?` | `OpenRouterProviderRouting` | 기본 OpenRouter `order`, `only`, `allowFallbacks` 선호도입니다. 정식 OpenRouter와 `openai-chat`에서만 유효합니다. | | `modelOpenRouterRouting?` | `Record` | 공급자 전반의 OpenRouter 선호도를 덮어쓰는 정확한 모델 id별 재정의입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index dd5ca0b52b..8b9724937d 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -83,6 +83,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `modelMaxInputTokens?` | `Record` | Positive per-model max input limits used for catalog auto-compaction hints. | | `defaultMaxOutputTokens?` | `number` | Provider-wide `openai-chat` fallback when the client omits `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Positive per-model `openai-chat` fallback budgets; exact/pattern matches beat the provider default. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by that provider's exact upstream model id — not a provider identifier or a routed `provider/model` label, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom providers may target any OpenAI-compatible endpoint through the `openai-chat` adapter, and local or internal provider ids work even when they are absent from the built-in catalogs. User-configured prices win over the built-in catalogs in the Logs `~$` and Usage estimates; historical entries are repriced from the current overlay, so editing a price can move past totals. The fallback order is user `modelCosts` → jawcode catalog → expected-price overlay → model-level vendor fallback, and an all-zero entry falls through to the next source in that sequence. Each rate must be a non-negative finite number at most 1,000,000 (USD per 1M tokens); out-of-range rows are rejected by the management boundary and dropped on load. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | | `headers?` | `Record` | Extra upstream headers. Authorization, cookies, API-key headers, embedded newlines, and invalid names are rejected. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Default OpenRouter `order`, `only`, and `allowFallbacks` preferences; valid only for canonical OpenRouter with `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact model-id overrides that replace the provider-wide OpenRouter preference. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 7e55625e15..0a258cf995 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -86,6 +86,7 @@ cross-route credential fallback не существует. Строки API GPT- | `modelMaxInputTokens?` | `Record` | Положительные лимиты max input по моделям, используемые для подсказок auto-compaction в каталоге. | | `defaultMaxOutputTokens?` | `number` | Provider-wide fallback для `openai-chat`, когда клиент не передал `max_output_tokens`. | | `modelMaxOutputTokens?` | `Record` | Положительные fallback-budget'ы `openai-chat` по моделям; exact/pattern-match имеет приоритет над provider-default. | +| `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный upstream id модели этого провайдера (не идентификатор провайдера и не маршрутизируемая метка `provider/model`), значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомный провайдер может указывать на любой OpenAI-совместимый endpoint через адаптер `openai-chat`, а локальные и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Каждая ставка должна быть неотрицательным конечным числом не более 1 000 000 (USD за 1M токенов); строки вне диапазона отклоняются на управляющей границе и отбрасываются при загрузке. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | | `headers?` | `Record` | Дополнительные upstream-header'ы. Заголовки авторизации, cookie, API-key-header'ы, встроенные переводы строк и невалидные имена отклоняются. | | `openRouterRouting?` | `OpenRouterProviderRouting` | Предпочтения по умолчанию для OpenRouter (`order`, `only`, `allowFallbacks`); валидно только для канонического OpenRouter с `openai-chat`. | | `modelOpenRouterRouting?` | `Record` | Exact override по model id, которые полностью заменяют provider-wide preference для OpenRouter. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index a8855ebdb2..11c7826860 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -73,6 +73,7 @@ selector,而不是分配一个新名称。 | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | +| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以该提供者的精确上游模型 ID 为键(不是提供者标识符或路由后的 `provider/model` 标签),值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——自定义提供者可以通过 `openai-chat` 适配器指向任意 OpenAI 兼容端点,即使不存在于内置目录中,本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。每个费率必须是大于等于 0 的有限数字,且不超过 1,000,000(每 100 万 token 的美元数);超出范围的条目会在管理边界被拒绝,并在加载时被丢弃。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | | `headers?` | `Record` | 额外的上游请求头。会拒绝 Authorization、cookie、API key 头、嵌入换行符以及无效名称。 | | `openRouterRouting?` | `OpenRouterProviderRouting` | 默认的 OpenRouter `order`、`only` 和 `allowFallbacks` 偏好;仅对使用 `openai-chat` 的规范 OpenRouter 有效。 | | `modelOpenRouterRouting?` | `Record` | 精确模型 id 级别的覆盖项,会替换提供者级 OpenRouter 偏好。 | diff --git a/docs/screenshots/provider-cost-overlay-logs-detail.png b/docs/screenshots/provider-cost-overlay-logs-detail.png new file mode 100644 index 0000000000..1b7be458e0 Binary files /dev/null and b/docs/screenshots/provider-cost-overlay-logs-detail.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a53882bef9..917a92f563 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -617,13 +617,14 @@ export const de: Record = { "logs.detail.ttft": "TTFT", "logs.detail.costTotal": "Listenpreis-Äquivalent", "logs.detail.totalTokens": "Tokens gesamt", - "logs.detail.matchedKey": "Zugeordneter jawcode-Schlüssel", + "logs.detail.matchedKey": "Zugeordneter Preisschlüssel", "logs.detail.priceSource": "Preisquelle", "logs.detail.unavailableReason": "Grund der Nichtverfügbarkeit", "logs.detail.copyRequestId": "Anfrage-ID kopieren", "logs.detail.copied": "Kopiert", "logs.detail.source.jawcode": "jawcode-Katalog", "logs.detail.source.expected": "Expected-Preis-Overlay", + "logs.detail.source.user": "Anbieter-konfiguriertes Preis-Overlay", "logs.detail.verification.verified": "Verifiziert", "logs.detail.verification.derived": "Vom Basismodell abgeleitet", "logs.detail.attempt.target": "Anbieter / Modell", @@ -642,13 +643,14 @@ export const de: Record = { "logs.detail.reason.usage_unsupported": "Dieser Anbieter meldet keine Nutzung.", "logs.detail.reason.output_missing": "Es wurden keine positiven Ausgabe-Tokens gemeldet.", "logs.detail.reason.invalid_duration": "Die Anfragedauer ist ungültig.", - "logs.detail.reason.price_unmatched": "Kein passender jawcode-Preis gefunden.", + "logs.detail.reason.price_unmatched": "Kein passender Preis gefunden.", "logs.detail.reason.invalid_cache_breakdown": "Cache-Token-Details widersprechen den Eingabe-Tokens.", "logs.detail.reason.invalid_usage": "Die Nutzung enthält einen ungültigen Token-Wert.", "logs.detail.reason.combo_attempt_unavailable": "Mindestens ein Combo-Versuch konnte nicht bepreist werden.", "logs.detail.estimate.usage_estimated": "Die Anbieternutzung ist geschätzt.", "logs.detail.estimate.cache_detail_missing": "Cache-Details fehlen; Eingabe ist als Obergrenze geschätzt.", "logs.detail.estimate.expected_price_overlay": "Ein verifizierter Expected-Listenpreis wurde verwendet.", + "logs.detail.estimate.provider_cost_overlay": "Ein vom Anbieter konfiguriertes Preis-Overlay wurde verwendet.", "logs.col.error": "Fehler", "logs.col.upstreamReason": "Upstream-Grund", "logs.col.duration": "Dauer", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6d4347316f..7bdebfeb07 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -644,13 +644,14 @@ export const en = { "logs.detail.ttft": "TTFT", "logs.detail.costTotal": "List-price equivalent", "logs.detail.totalTokens": "Total tokens", - "logs.detail.matchedKey": "Matched jawcode key", + "logs.detail.matchedKey": "Matched price key", "logs.detail.priceSource": "Price source", "logs.detail.unavailableReason": "Unavailable reason", "logs.detail.copyRequestId": "Copy request ID", "logs.detail.copied": "Copied", "logs.detail.source.jawcode": "jawcode catalog", "logs.detail.source.expected": "Expected price overlay", + "logs.detail.source.user": "Provider-configured price overlay", "logs.detail.verification.verified": "Verified", "logs.detail.verification.derived": "Derived from base model", "logs.detail.attempt.target": "Provider / model", @@ -669,13 +670,14 @@ export const en = { "logs.detail.reason.usage_unsupported": "This provider does not report usage.", "logs.detail.reason.output_missing": "No positive output token count was reported.", "logs.detail.reason.invalid_duration": "The request duration is not valid.", - "logs.detail.reason.price_unmatched": "No matching jawcode price was found.", + "logs.detail.reason.price_unmatched": "No matching price was found.", "logs.detail.reason.invalid_cache_breakdown": "Cache token details conflict with total input tokens.", "logs.detail.reason.invalid_usage": "Usage contains an invalid token value.", "logs.detail.reason.combo_attempt_unavailable": "At least one combo attempt could not be priced.", "logs.detail.estimate.usage_estimated": "Provider usage is estimated.", "logs.detail.estimate.cache_detail_missing": "Cache details were unavailable; input is an upper-bound estimate.", "logs.detail.estimate.expected_price_overlay": "A verified expected list price was used.", + "logs.detail.estimate.provider_cost_overlay": "A provider-configured price overlay was used.", "logs.col.error": "Error", "logs.col.upstreamReason": "Upstream reason", "logs.col.duration": "Duration", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3770c07224..4d57ca6701 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -602,13 +602,14 @@ export const ja: Record = { "logs.detail.ttft": "TTFT", "logs.detail.costTotal": "定価相当額", "logs.detail.totalTokens": "合計トークン", - "logs.detail.matchedKey": "一致した jawcode キー", + "logs.detail.matchedKey": "一致した価格キー", "logs.detail.priceSource": "価格ソース", "logs.detail.unavailableReason": "利用不可の理由", "logs.detail.copyRequestId": "リクエスト ID をコピー", "logs.detail.copied": "コピーしました", "logs.detail.source.jawcode": "jawcode カタログ", "logs.detail.source.expected": "予想価格オーバーレイ", + "logs.detail.source.user": "プロバイダー設定の価格オーバーレイ", "logs.detail.verification.verified": "検証済み", "logs.detail.verification.derived": "ベースモデルから派生", "logs.detail.attempt.target": "プロバイダー / モデル", @@ -627,13 +628,14 @@ export const ja: Record = { "logs.detail.reason.usage_unsupported": "このプロバイダーは使用量を報告しません。", "logs.detail.reason.output_missing": "正の出力トークン数が報告されませんでした。", "logs.detail.reason.invalid_duration": "リクエストの所要時間が有効ではありません。", - "logs.detail.reason.price_unmatched": "一致する jawcode 価格が見つかりませんでした。", + "logs.detail.reason.price_unmatched": "一致する価格が見つかりませんでした。", "logs.detail.reason.invalid_cache_breakdown": "キャッシュトークンの詳細が合計入力トークンと矛盾しています。", "logs.detail.reason.invalid_usage": "使用量に無効なトークン値が含まれています。", "logs.detail.reason.combo_attempt_unavailable": "少なくとも 1 つのコンボ試行に価格を設定できませんでした。", "logs.detail.estimate.usage_estimated": "プロバイダーの使用量は推定です。", "logs.detail.estimate.cache_detail_missing": "キャッシュの詳細が利用できませんでした; 入力は上限の推定です。", "logs.detail.estimate.expected_price_overlay": "検証済みの予想定価が使用されました。", + "logs.detail.estimate.provider_cost_overlay": "プロバイダー設定の価格オーバーレイが使用されました。", "logs.col.error": "エラー", "logs.col.upstreamReason": "上流の理由", "logs.col.duration": "所要時間", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1021288915..bf842b922f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -636,13 +636,14 @@ export const ko: Record = { "logs.detail.ttft": "TTFT", "logs.detail.costTotal": "정가 환산치", "logs.detail.totalTokens": "전체 토큰", - "logs.detail.matchedKey": "매칭된 jawcode 키", + "logs.detail.matchedKey": "매칭된 가격 키", "logs.detail.priceSource": "가격 출처", "logs.detail.unavailableReason": "표시 불가 사유", "logs.detail.copyRequestId": "요청 ID 복사", "logs.detail.copied": "복사됨", "logs.detail.source.jawcode": "jawcode 카탈로그", "logs.detail.source.expected": "expected 가격 오버레이", + "logs.detail.source.user": "프로바이더 구성 가격 오버레이", "logs.detail.verification.verified": "검증됨", "logs.detail.verification.derived": "기반 모델 유도", "logs.detail.attempt.target": "프로바이더 / 모델", @@ -661,13 +662,14 @@ export const ko: Record = { "logs.detail.reason.usage_unsupported": "이 프로바이더는 usage 보고를 지원하지 않습니다.", "logs.detail.reason.output_missing": "양수 출력 토큰 수가 보고되지 않았습니다.", "logs.detail.reason.invalid_duration": "요청 소요 시간이 유효하지 않습니다.", - "logs.detail.reason.price_unmatched": "매칭되는 jawcode 가격을 찾지 못했습니다.", + "logs.detail.reason.price_unmatched": "매칭되는 가격을 찾지 못했습니다.", "logs.detail.reason.invalid_cache_breakdown": "캐시 토큰 상세가 전체 입력 토큰과 모순됩니다.", "logs.detail.reason.invalid_usage": "usage에 유효하지 않은 토큰 값이 있습니다.", "logs.detail.reason.combo_attempt_unavailable": "하나 이상의 combo 시도 비용을 계산할 수 없습니다.", "logs.detail.estimate.usage_estimated": "프로바이더 usage가 추정치입니다.", "logs.detail.estimate.cache_detail_missing": "캐시 상세가 없어 입력 전액을 상한으로 추정했습니다.", "logs.detail.estimate.expected_price_overlay": "검증된 expected 정가를 사용했습니다.", + "logs.detail.estimate.provider_cost_overlay": "프로바이더 구성 가격 오버레이를 사용했습니다.", "logs.col.error": "오류", "logs.col.upstreamReason": "업스트림 원인", "logs.col.duration": "소요 시간", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2f9fc958d1..48a584e5e2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -634,13 +634,14 @@ export const ru: Record = { "logs.detail.ttft": "TTFT", "logs.detail.costTotal": "Эквивалент по прайс-листу", "logs.detail.totalTokens": "Всего токенов", - "logs.detail.matchedKey": "Совпавший ключ jawcode", + "logs.detail.matchedKey": "Совпавший ключ цены", "logs.detail.priceSource": "Источник цены", "logs.detail.unavailableReason": "Причина недоступности", "logs.detail.copyRequestId": "Копировать ID запроса", "logs.detail.copied": "Скопировано", "logs.detail.source.jawcode": "каталог jawcode", "logs.detail.source.expected": "Оверлей ожидаемых цен", + "logs.detail.source.user": "Ценовой оверлей провайдера", "logs.detail.verification.verified": "Подтверждено", "logs.detail.verification.derived": "Выведено из базовой модели", "logs.detail.attempt.target": "Провайдер / модель", @@ -659,13 +660,14 @@ export const ru: Record = { "logs.detail.reason.usage_unsupported": "Этот провайдер не сообщает данные об использовании.", "logs.detail.reason.output_missing": "Положительное число выходных токенов не было сообщено.", "logs.detail.reason.invalid_duration": "Длительность запроса некорректна.", - "logs.detail.reason.price_unmatched": "Подходящая цена в каталоге jawcode не найдена.", + "logs.detail.reason.price_unmatched": "Подходящая цена не найдена.", "logs.detail.reason.invalid_cache_breakdown": "Детализация кэш-токенов противоречит общему числу входных токенов.", "logs.detail.reason.invalid_usage": "В данных использования есть некорректное значение токенов.", "logs.detail.reason.combo_attempt_unavailable": "Не удалось рассчитать стоимость как минимум одной попытки комбо.", "logs.detail.estimate.usage_estimated": "Данные об использовании от провайдера — оценочные.", "logs.detail.estimate.cache_detail_missing": "Детализация кэша недоступна; входные токены оценены по верхней границе.", "logs.detail.estimate.expected_price_overlay": "Использована подтверждённая ожидаемая цена из прайс-листа.", + "logs.detail.estimate.provider_cost_overlay": "Использован ценовой оверлей провайдера.", "logs.col.error": "Ошибка", "logs.col.upstreamReason": "Причина от провайдера", "logs.col.duration": "Длительность", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6fd435e533..494fa07211 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -648,6 +648,7 @@ export const tr: Record = { "logs.detail.copied": "Kopyalandı", "logs.detail.source.jawcode": "katalog", "logs.detail.source.expected": "Beklenen fiyat", + "logs.detail.source.user": "Kullanıcı tarafından yapılandırılan sağlayıcı fiyat katmanı", "logs.detail.verification.verified": "Doğrulandı", "logs.detail.verification.derived": "Taban modelden türetildi", "logs.detail.attempt.target": "Sağlayıcı / model", @@ -673,6 +674,7 @@ export const tr: Record = { "logs.detail.estimate.usage_estimated": "Sağlayıcı kullanımı tahminidir.", "logs.detail.estimate.cache_detail_missing": "Önbellek detayları eksik.", "logs.detail.estimate.expected_price_overlay": "Doğrulanmış liste fiyatı kullanıldı.", + "logs.detail.estimate.provider_cost_overlay": "Kullanıcı tarafından yapılandırılan bir sağlayıcı fiyat katmanı kullanıldı.", "logs.col.error": "Hata", "logs.col.upstreamReason": "Yukarı akış nedeni", "logs.col.duration": "Süre", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 2ae8b6ac2c..f1f0eb62b3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -629,13 +629,14 @@ export const zh: Record = { "logs.detail.ttft": "TTFT", "logs.detail.costTotal": "标价折算", "logs.detail.totalTokens": "Token 总数", - "logs.detail.matchedKey": "匹配的 jawcode 键", + "logs.detail.matchedKey": "匹配的价格键", "logs.detail.priceSource": "价格来源", "logs.detail.unavailableReason": "不可用原因", "logs.detail.copyRequestId": "复制请求 ID", "logs.detail.copied": "已复制", "logs.detail.source.jawcode": "jawcode 目录", "logs.detail.source.expected": "Expected 价格覆盖", + "logs.detail.source.user": "用户配置的提供方价格覆盖", "logs.detail.verification.verified": "已验证", "logs.detail.verification.derived": "由基础模型推导", "logs.detail.attempt.target": "提供方 / 模型", @@ -654,13 +655,14 @@ export const zh: Record = { "logs.detail.reason.usage_unsupported": "该提供方不支持上报 usage。", "logs.detail.reason.output_missing": "未上报正数输出 token。", "logs.detail.reason.invalid_duration": "请求耗时无效。", - "logs.detail.reason.price_unmatched": "未找到匹配的 jawcode 价格。", + "logs.detail.reason.price_unmatched": "未找到匹配的价格。", "logs.detail.reason.invalid_cache_breakdown": "缓存 token 明细与输入 token 总数冲突。", "logs.detail.reason.invalid_usage": "Usage 包含无效的 token 值。", "logs.detail.reason.combo_attempt_unavailable": "至少一次 Combo 尝试无法计价。", "logs.detail.estimate.usage_estimated": "提供方 usage 为估算值。", "logs.detail.estimate.cache_detail_missing": "缺少缓存明细;输入费用按上限估算。", "logs.detail.estimate.expected_price_overlay": "使用了已验证的 Expected 标价。", + "logs.detail.estimate.provider_cost_overlay": "使用了用户配置的提供方价格覆盖。", "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index b1aa6b8676..4d7ff2adc5 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -47,7 +47,11 @@ type MetricUnavailableReason = | "price_unmatched" | "invalid_cache_breakdown" | "invalid_usage" | "combo_attempt_unavailable"; -type CostEstimateReason = "usage_estimated" | "cache_detail_missing" | "expected_price_overlay"; +type CostEstimateReason = + | "usage_estimated" + | "cache_detail_missing" + | "expected_price_overlay" + | "provider_cost_overlay"; type TokPerSecondResult = | { kind: "value"; value: number; estimated: boolean } @@ -57,7 +61,7 @@ interface MatchedPriceInfo { provider: string; modelId: string; jawcodeProvider?: string; - source: "jawcode" | "expected"; + source: "jawcode" | "expected" | "user"; sourceRef?: string; verifiedAt?: string; status: "verified" | "verified-derived"; @@ -302,6 +306,7 @@ const ESTIMATE_REASON_KEYS = { usage_estimated: "logs.detail.estimate.usage_estimated", cache_detail_missing: "logs.detail.estimate.cache_detail_missing", expected_price_overlay: "logs.detail.estimate.expected_price_overlay", + provider_cost_overlay: "logs.detail.estimate.provider_cost_overlay", } as const satisfies Record; /** @@ -318,10 +323,12 @@ const RECOVERY_KIND_KEYS = { "image-413": "logs.detail.attempt.recovery.image413", } as const satisfies Record; +/** Map a metric-unavailable reason to its i18n key. */ function metricReasonKey(reason: MetricUnavailableReason) { return METRIC_REASON_KEYS[reason]; } +/** Map a cost-estimate reason to its i18n key. */ function estimateReasonKey(reason: CostEstimateReason) { return ESTIMATE_REASON_KEYS[reason]; } diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index c926bec0af..d7c9b4cb71 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from "node:fs"; import { clearCodexAccountPin } from "../codex/account-priority"; -import { getConfigPath, readConfigDiagnostics, saveConfig, validateConfigCandidate } from "../config"; +import { getConfigPath, readConfigDiagnostics, sanitizeModelCostsForDisplay, saveConfig, validateConfigCandidate } from "../config"; import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort"; import type { OcxConfig } from "../types"; import { normalizeVisionReasoningForModel } from "../vision/reasoning"; @@ -20,6 +20,9 @@ const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); function redact(value: unknown, key = ""): unknown { if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value; + // modelCosts rows are keyed by model id; a pasted API key in a key position + // must not be echoed back by config show/get (values are already redacted). + if (key === "modelCosts") return sanitizeModelCostsForDisplay(value); if (Array.isArray(value)) return value.map(item => redact(item)); if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value as Record).map(([childKey, child]) => [childKey, redact(child, childKey)])); diff --git a/src/cli/provider.ts b/src/cli/provider.ts index a418a1d589..725db2963a 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -8,7 +8,7 @@ * show Show provider config details (secrets masked) * set-default Change the default provider */ -import { apiKeyTransportConfigError, hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config"; +import { apiKeyTransportConfigError, hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config"; import { hasHelpFlag } from "./help"; import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; @@ -210,7 +210,14 @@ async function handleAdd(args: string[]): Promise { provConfig.apiKeyTransport = apiKeyTransport; } + const existingProvider = config.providers[name]; config.providers[name] = provConfig; + // A --force overwrite rotates the key/endpoint but must not drop a + // user-configured price overlay (same rule as the /api/providers path and + // the login paths); there is no explicit clear/replace flag yet. + if (existingProvider?.modelCosts !== undefined && provConfig.modelCosts === undefined) { + provConfig.modelCosts = existingProvider.modelCosts; + } if (allowPrivateNetwork) provConfig.allowPrivateNetwork = true; if (setDefault) config.defaultProvider = name; @@ -349,6 +356,7 @@ function handleShow(args: string[]): void { const prov = config.providers[name]; const display = { ...prov, + ...(prov.modelCosts !== undefined ? { modelCosts: sanitizeModelCostsForDisplay(prov.modelCosts) } : {}), ...(prov.apiKey ? { apiKey: maskSecret(prov.apiKey) } : {}), ...(prov.apiKeyPool ? { apiKeyPool: prov.apiKeyPool.map(e => ({ ...e, key: maskSecret(e.key) })) } : {}), }; diff --git a/src/config.ts b/src/config.ts index 12d855fa07..6c0e97ab1d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -64,6 +64,7 @@ import { type OcxConfig, type OcxApiKeyEntry, type OcxProviderConfig, + type ProviderCostOverlay, } from "./types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { @@ -74,6 +75,13 @@ import { import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; +import { + COST4_RATE_KEYS, + isValidCost4Rate, + refreshUserCostOverlays, + withPreservedDiskOnlyProviders, +} from "./usage/user-cost-overlays"; +import { MAX_COST4_RATE } from "./usage/expected-prices"; import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, @@ -704,6 +712,75 @@ export function providerHeadersConfigError(headers: unknown): string | null { return null; } +/** + * Validate `providers..modelCosts`: a plain object keyed by exact model + * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates. + * Returns null when valid/absent, else a human-readable error. + */ +export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return `${field} must be a plain object keyed by model id`; + } + for (const [modelId, entry] of Object.entries(value)) { + if (!modelId.trim()) return `${field} keys must be nonblank model ids`; + // Redact secret-shaped model ids and JSON-escape control characters so a + // malformed write cannot echo a pasted key/secret back through the + // management API response. + const safeModelId = JSON.stringify(redactSecretString(modelId)); + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`; + } + const rates = entry as Record; + for (const key of COST4_RATE_KEYS) { + const rate = rates[key]; + if (!isValidCost4Rate(rate)) { + return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`; + } + } + // Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row + // would otherwise be persisted and echoed verbatim by display paths that + // mask only top-level provider secrets. + const extraKeys = Object.keys(rates) + .filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key)); + if (extraKeys.length > 0) { + return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`; + } + } + return null; +} + +/** + * Serialize `providers..modelCosts` for display: copy ONLY the four + * numeric rate fields per model and DROP secret-shaped model ids, so a pasted + * API key in a key position cannot be echoed back by CLI/DTO display paths. + * The result uses a null prototype so "__proto__" remains an own row. + */ +export function sanitizeModelCostsForDisplay(costs: unknown): Record | undefined { + if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined; + const out = Object.create(null) as Record; + for (const [modelId, entry] of Object.entries(costs)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const rates = entry as Record; + const input = rates.input; + const output = rates.output; + const cacheRead = rates.cacheRead; + const cacheWrite = rates.cacheWrite; + if ( + isValidCost4Rate(input) + && isValidCost4Rate(output) + && isValidCost4Rate(cacheRead) + && isValidCost4Rate(cacheWrite) + ) { + // Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so + // distinct rows cannot collapse into one placeholder key. + if (redactSecretString(modelId) !== modelId) continue; + out[modelId] = { input, output, cacheRead, cacheWrite }; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */ export function apiKeyTransportConfigError( provider: Pick, @@ -1173,7 +1250,7 @@ const configSchema = z.object({ if (!isValidProviderName(name)) { ctx.addIssue({ code: "custom", - path: ["providers", name], + path: ["providers", redactSecretString(name)], message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)", }); } @@ -1184,7 +1261,7 @@ const configSchema = z.object({ code: "custom", path: [ "providers", - name, + redactSecretString(name), openRouterRoutingError.startsWith("modelOpenRouterRouting") ? "modelOpenRouterRouting" : "openRouterRouting", @@ -1195,7 +1272,7 @@ const configSchema = z.object({ if (Object.hasOwn(provider, "virtualModels")) { ctx.addIssue({ code: "custom", - path: ["providers", name, "virtualModels"], + path: ["providers", redactSecretString(name), "virtualModels"], message: "virtualModels is registry-only and must not be persisted", }); } @@ -1203,7 +1280,7 @@ const configSchema = z.object({ if (baseUrlError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "baseUrl"], + path: ["providers", redactSecretString(name), "baseUrl"], message: baseUrlError, }); } else { @@ -1211,7 +1288,7 @@ const configSchema = z.object({ if (destinationError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "baseUrl"], + path: ["providers", redactSecretString(name), "baseUrl"], message: destinationError, }); } @@ -1220,7 +1297,7 @@ const configSchema = z.object({ if (responsesPathError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "responsesPath"], + path: ["providers", redactSecretString(name), "responsesPath"], message: responsesPathError, }); } @@ -1228,15 +1305,25 @@ const configSchema = z.object({ if (headersError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "headers"], + path: ["providers", redactSecretString(name), "headers"], message: headersError, }); } + const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts); + if (modelCostsError) { + ctx.addIssue({ + code: "custom", + // The provider key is caller-controlled and can be token-shaped; redact it + // before schemaDiagnosticsError serializes the path (ocx config validate/import). + path: ["providers", redactSecretString(name), "modelCosts"], + message: modelCostsError, + }); + } const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig); if (apiKeyTransportError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "apiKeyTransport"], + path: ["providers", redactSecretString(name), "apiKeyTransport"], message: apiKeyTransportError, }); } @@ -1249,7 +1336,7 @@ const configSchema = z.object({ if (modelAdaptersError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelAdapters"], + path: ["providers", redactSecretString(name), "modelAdapters"], message: modelAdaptersError, }); } @@ -1262,7 +1349,7 @@ const configSchema = z.object({ if (preferHostedToolsError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelPreferHostedTools"], + path: ["providers", redactSecretString(name), "modelPreferHostedTools"], message: preferHostedToolsError, }); } @@ -1273,7 +1360,7 @@ const configSchema = z.object({ if (maxInputError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelMaxInputTokens"], + path: ["providers", redactSecretString(name), "modelMaxInputTokens"], message: maxInputError, }); } @@ -1284,7 +1371,7 @@ const configSchema = z.object({ if (reasoningSummariesError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelSupportsReasoningSummaries"], + path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"], message: reasoningSummariesError, }); } @@ -1295,7 +1382,7 @@ const configSchema = z.object({ if (reasoningSummaryDeliveryError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelReasoningSummaryDelivery"], + path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"], message: reasoningSummaryDeliveryError, }); } @@ -1306,7 +1393,7 @@ const configSchema = z.object({ if (defaultMaxOutputError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "defaultMaxOutputTokens"], + path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"], message: defaultMaxOutputError, }); } @@ -1317,7 +1404,7 @@ const configSchema = z.object({ if (maxOutputError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelMaxOutputTokens"], + path: ["providers", redactSecretString(name), "modelMaxOutputTokens"], message: maxOutputError, }); } @@ -1332,7 +1419,7 @@ const configSchema = z.object({ if (!canonicalOpenAiShape) { ctx.addIssue({ code: "custom", - path: ["providers", name, "codexAccountMode"], + path: ["providers", redactSecretString(name), "codexAccountMode"], message: "codexAccountMode is valid only on the canonical built-in openai provider", }); } @@ -1550,6 +1637,54 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null { return `retryOn429.${field} is invalid (${first.message})`; } +/** + * Load-time degradation for `providers..modelCosts`, mirroring + * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row + * must not fail the whole config parse — that would back up config.json and + * fall back to defaults, dropping otherwise valid providers and the default + * route for a typo in a non-runtime display field. Invalid rows are dropped + * with a warning; strict rejection stays at the management/write boundary + * (providerManagementConfigError). + */ +function sanitizeModelCostsForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const root = parsed as Record; + const providers = root.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, provider] of Object.entries(providers as Record)) { + // Runs before schema validation, so the provider name is untrusted: redact + // secret-shaped names and JSON-escape control characters for the warning. + const safeProviderName = JSON.stringify(redactSecretString(name)); + if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; + const p = provider as Record; + const costs = p.modelCosts; + if (costs === undefined) continue; + if (!costs || typeof costs !== "object" || Array.isArray(costs)) { + delete p.modelCosts; + console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`); + continue; + } + const costsRecord = costs as Record; + const hadEntries = Object.keys(costsRecord).length > 0; + let kept = 0; + for (const [modelId, entry] of Object.entries(costsRecord)) { + // Reuse the shared per-row shape contract so the load-time sanitizer + // cannot drift from the schema and the write boundary. + if (providerModelCostsConfigError({ [modelId]: entry }) === null) { + kept++; + continue; + } + delete costsRecord[modelId]; + // Redact the model id: a hand-edit can place a secret in a key name. + console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`); + } + if (hadEntries && kept === 0) { + delete p.modelCosts; + console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`); + } + } +} + /** * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind * falls back to loopback, which is the safe direction but not what the file asked for — @@ -1804,6 +1939,13 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig) } } +/** + * Load and validate config.json into an OcxConfig. Missing files reset to + * defaults and clear stale overlays. Broken existing files also fall back to + * default routing (after backup), but keep the last-good cost-overlay registry + * until a valid config or a genuinely missing file is observed. A partially- + * invalid config is merged with defaults so providers and pool accounts survive. + */ export function loadConfig(): OcxConfig { const dir = getConfigDir(); const configPath = getConfigPath(); @@ -1811,12 +1953,13 @@ export function loadConfig(): OcxConfig { hardenExistingSecret(configPath); hardenExistingSecret(join(dir, "auth.json")); if (!existsSync(configPath)) { - return getDefaultConfig(); + return withRefreshedCostOverlays(getDefaultConfig()); } try { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); sanitizeRetryOn429ForLoad(parsed); + sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); @@ -1828,7 +1971,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); - return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Schema validation failed — merge defaults into the raw object instead of // discarding it entirely, so pool accounts and providers survive a missing @@ -1850,7 +1993,7 @@ export function loadConfig(): OcxConfig { warnDegradedNativeSubagentConfig(parsed, config); warnDegradedCodexAccountPicker(parsed); warnDegradedUpstreamHostCircuitThreshold(parsed); - return normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); @@ -1861,6 +2004,12 @@ export function loadConfig(): OcxConfig { } } +/** Refresh the user cost-overlay registry from `config` and return it unchanged. */ +function withRefreshedCostOverlays(config: OcxConfig): OcxConfig { + refreshUserCostOverlays(config); + return config; +} + export type ConfigDiagnostics = { config: OcxConfig; source: "default" | "file" | "fallback"; @@ -2096,6 +2245,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { // schema and send the caller a default-config fallback (the config command could then // persist that fallback over the user's providers/keys). sanitizeRetryOn429ForLoad(parsed); + sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed); @@ -2379,18 +2529,41 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync } }; +/** + * Atomic config.json write WITHOUT the mutation lock; callers must hold + * `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the + * cost-overlay registry from the persisted config so runtime estimates follow + * every save path. + */ function persistConfigUnlocked(config: OcxConfig): boolean { const configPath = getConfigPath(); - const bytes = JSON.stringify(config, null, 2) + "\n"; + // External editors can add provider rows the live config deliberately does + // not route with yet; merge them at the serialization boundary so an + // unrelated in-process save cannot erase the provider or its overlay. + const persisted = withPreservedDiskOnlyProviders(config); + const bytes = JSON.stringify(persisted, null, 2) + "\n"; + let unchanged = false; try { - if (readFileSync(configPath, "utf8") === bytes) return false; + unchanged = readFileSync(configPath, "utf8") === bytes; } catch (error) { if (!isMissingPathError(error)) throw error; } + // Keep the runtime overlay registry in sync with EVERY persist path, + // including byte-identical saves: a cooperating CLI process may have written + // the same bytes (e.g. before a proxy notification), and Logs/Usage must + // adopt the overlay without waiting for a changed save or restart. + if (unchanged) { + refreshUserCostOverlays(persisted); + return false; + } atomicWriteFile(configPath, bytes); + // For changed saves, refresh only AFTER the write succeeded so a failed + // write cannot leave estimates reflecting configuration never persisted. + refreshUserCostOverlays(persisted); return true; } +/** Persist `config` to config.json under the config-mutation lock. */ export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); @@ -2665,6 +2838,10 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline else config.claudeCode = structuredClone(persisted.claudeCode); claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); } + // The reconciliation may have adopted a providers..modelCosts edit made + // by a cooperating process while the OAuth login was pending; keep the overlay + // registry (and the usage-cache overlay version) in sync with the live config. + refreshUserCostOverlays(config); } /** The literal file, with no schema merge or default injection. */ diff --git a/src/oauth/index.ts b/src/oauth/index.ts index c740518f4e..6b6d027f2b 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -888,6 +888,12 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (existing?.commandCodeVersion !== undefined) { next.commandCodeVersion = existing.commandCodeVersion; } + // User-configured price overlays are operator data, not preset state; a + // re-login, add-account, or reauth must not silently drop them from the + // Logs/Usage estimates. + if (existing?.modelCosts !== undefined) { + next.modelCosts = existing.modelCosts; + } if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) { // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes. let storedApiKey = sanitizeApiKeyValue(existing.apiKey); diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 0afecb4281..f9f5798a90 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -4,7 +4,7 @@ import { loadConfig, saveConfig } from "../config"; import { findLiveProxy, probeHostname } from "../server/proxy-liveness"; import { isPublicOAuthProvider, listOAuthProviders, runLogin } from "./index"; import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers"; -import type { OcxProviderConfig } from "../types"; +import type { OcxConfig, OcxProviderConfig } from "../types"; import { configuredAdminToken } from "../lib/admin-secrets"; import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match"; @@ -107,6 +107,41 @@ export function providerConfigFromKeyLoginProvider(def: KeyLoginProvider, key: s }; } +/** + * Merge a freshly built key-login provider row with a previously saved row, + * carrying operator-owned fields the key-provider preset cannot know about. + * Currently that is the user-configured modelCosts overlay: rotating the API + * key must not silently revert Logs/Usage estimates to catalog prices. + */ +export function mergeKeyLoginProviderRow( + provider: OcxProviderConfig, + existing: OcxProviderConfig | undefined, +): OcxProviderConfig { + return { + ...provider, + ...(existing?.modelCosts !== undefined ? { modelCosts: existing.modelCosts } : {}), + }; +} + +/** + * Commit a fresh key-login provider row: merge operator-owned fields from the + * existing row (currently the modelCosts overlay), persist the merged row, and + * push the SAME merged row to a running proxy so its live config cannot diverge + * from disk (e.g. by replacing the overlay with catalog prices until reload). + * Returns the merged row that was persisted and notified. + */ +export async function commitKeyLoginProvider( + config: OcxConfig, + name: string, + provider: OcxProviderConfig, +): Promise { + const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]); + config.providers[name] = mergedProvider; + saveConfig(config); + await notifyRunningProxy(name, mergedProvider); + return mergedProvider; +} + async function handleKeyLogin(name: string): Promise { const def = KEY_LOGIN_PROVIDERS[name]; const preflightConfig = loadConfig(); @@ -149,9 +184,7 @@ async function handleKeyLogin(name: string): Promise { console.error(`Error: ${commitCollision}.`); process.exit(1); } - config.providers[name] = provider; - saveConfig(config); - await notifyRunningProxy(name, provider); + await commitKeyLoginProvider(config, name, provider); console.log(`✅ ${def.label} added. Try: ocx sync`); } diff --git a/src/providers/alibaba-region-migration.ts b/src/providers/alibaba-region-migration.ts index 8d488f246e..2f359059c4 100644 --- a/src/providers/alibaba-region-migration.ts +++ b/src/providers/alibaba-region-migration.ts @@ -24,7 +24,7 @@ const INTL_ID = "alibaba-token-plan-intl"; * the one matching the destination's registry contract. * `defaultModel` and `note` are user-editable too and are handled below. */ -const USER_OWNED_FIELDS = ["apiKey", "apiKeyPool", "disabled", "baseUrl", "allowPrivateNetwork", "liveModels"] as const; +const USER_OWNED_FIELDS = ["apiKey", "apiKeyPool", "disabled", "baseUrl", "allowPrivateNetwork", "liveModels", "modelCosts"] as const; export interface AlibabaRegionMigrationProjection { config: OcxConfig; diff --git a/src/providers/label.ts b/src/providers/label.ts index 98dade8df2..d767d97074 100644 --- a/src/providers/label.ts +++ b/src/providers/label.ts @@ -1,6 +1,6 @@ import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; -function canonicalUsageProviderLabel(provider: string): string { +export function canonicalUsageProviderLabel(provider: string): string { return provider === "chatgpt" || provider === "openai-multi" ? "openai" : provider; } diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 4ad1ba7078..f1156cb447 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -1,5 +1,6 @@ -import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types"; +import type { CodexAccountMode, OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; import { OPENAI_PROVIDER_TIER_VERSION } from "../types"; +import { MAX_COST4_RATE } from "../usage/expected-prices"; export const OPENAI_CODEX_PROVIDER_ID = "openai"; export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi"; @@ -69,11 +70,17 @@ export class OpenAiTierMigrationCollisionError extends Error { } } -function managedLegacyMultiOverlay(provider: OcxProviderConfig): Pick | null { - const allowed = new Set(["adapter", "authMode", "baseUrl", "disabled", "selectedModels"]); +function managedLegacyMultiOverlay( + provider: OcxProviderConfig, +): Pick | null { + const allowed = new Set(["adapter", "authMode", "baseUrl", "disabled", "selectedModels", "modelCosts"]); if (!Object.keys(provider).every(key => allowed.has(key))) return null; if (!isCanonicalOpenAiForwardProvider(provider)) return null; if (provider.disabled !== undefined && typeof provider.disabled !== "boolean") return null; + // Keep the migration self-contained: a malformed overlay is a collision, not + // something to carry into the canonical row (importing config.ts here would + // create an import cycle — config.ts already imports this module). + if (provider.modelCosts !== undefined && !validLegacyOverlayCosts(provider.modelCosts)) return null; if (provider.selectedModels !== undefined && ( !Array.isArray(provider.selectedModels) || provider.selectedModels.some(model => typeof model !== "string") @@ -81,9 +88,26 @@ function managedLegacyMultiOverlay(provider: OcxProviderConfig): Pick).every(entry => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const rates = entry as Record; + return Object.keys(rates).length === fields.length + && fields.every(key => Object.hasOwn(rates, key) + && typeof rates[key] === "number" + && Number.isFinite(rates[key]) + && (rates[key] as number) >= 0 + && (rates[key] as number) <= MAX_COST4_RATE); + }); +} + function rewriteLegacyOpenAiSelectedId(value: string): string { return value.startsWith(LEGACY_OPENAI_MULTI_PREFIX) ? value.slice(LEGACY_OPENAI_MULTI_PREFIX.length) @@ -95,6 +119,28 @@ function rewriteLegacyOpenAiModelList(values: string[] | undefined): string[] | return [...new Set(values.map(rewriteLegacyOpenAiSelectedId))]; } +/** + * Rewrite legacy `openai-multi/` keys in a modelCosts overlay so the + * prices still match after the migration resolves logs as provider `openai` + * with the bare model id. Keys without the legacy prefix pass through. + */ +function rewriteLegacyOpenAiCostKeys(costs: Record | undefined): Record { + // Null-prototype map so prototype-named model ids (e.g. "__proto__") are + // stored as own properties instead of invoking the inherited setter. + const rewritten = Object.create(null) as Record; + if (costs) { + for (const [key, value] of Object.entries(costs)) { + const canonicalKey = rewriteLegacyOpenAiSelectedId(key); + // A bare key always wins over its openai-multi/ equivalent + // inside the same row, regardless of JSON property order. + if (key === canonicalKey || !Object.hasOwn(rewritten, canonicalKey)) { + rewritten[canonicalKey] = value; + } + } + } + return rewritten; +} + function mergeLegacyOpenAiProviderRows( openai: OcxProviderConfig | undefined, legacyMulti: OcxProviderConfig | undefined, @@ -104,12 +150,22 @@ function mergeLegacyOpenAiProviderRows( ...(openai?.selectedModels ?? []), ...(legacyMulti?.selectedModels ?? []), ]); + // Both rows can carry disjoint overlays; merge them (canonical openai wins on + // key conflicts) so legacy Multi prices are not silently dropped. Keys are + // rewritten first so `openai-multi/` entries still resolve after the + // provider is canonicalized to `openai`. + const modelCosts = { + ...rewriteLegacyOpenAiCostKeys(legacyMulti?.modelCosts), + ...rewriteLegacyOpenAiCostKeys(openai?.modelCosts), + }; + const hasModelCosts = Object.keys(modelCosts).length > 0; const formerRows = [openai, legacyMulti].filter((row): row is OcxProviderConfig => row !== undefined); const disabled = formerRows.length > 0 && formerRows.every(row => row.disabled === true); return { ...canonicalCodexForwardProvider(mode), ...(disabled ? { disabled: true } : {}), ...(selectedModels && selectedModels.length > 0 ? { selectedModels } : {}), + ...(hasModelCosts ? { modelCosts } : {}), }; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a59bfba5f1..a3e9bac79a 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -10,8 +10,10 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerModelCostsConfigError, reasoningSummaryDeliveryRecordConfigError, retryOn429PolicyConfigError, + sanitizeModelCostsForDisplay, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; @@ -447,6 +449,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (seed) seed.codexAccountMode = raw.codexAccountMode; const canonicalCandidate = { ...raw }; delete canonicalCandidate.responsesSnapshotRepair; + // modelCosts is a user-owned display overlay, not part of the canonical + // forward seed; it is validated separately below (providerModelCostsConfigError). + delete canonicalCandidate.modelCosts; const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed); if (!canonical) { return `provider ${name} must equal the canonical built-in provider seed`; @@ -471,6 +476,12 @@ export function providerManagementConfigError(name: unknown, provider: unknown): // it before it reaches the management API response. return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`; } + const modelCostsError = providerModelCostsConfigError(raw.modelCosts); + if (modelCostsError) { + // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape + // it before it reaches the management API response (same rule as retryOn429 above). + return `provider ${JSON.stringify(redactSecretString(name))} ${modelCostsError}`; + } const apiKeyTransportError = apiKeyTransportConfigError(typed); if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`; const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens"); @@ -544,6 +555,7 @@ export function copyIfDefined( if (value !== undefined) out[key as string] = value as unknown; } +/** Public dashboard DTO for config.json: provider entries with secrets stripped and documented fields exposed (including `modelCosts`). */ export function safeConfigDTO(config: OcxConfig): unknown { const providers: Record> = {}; for (const [name, provider] of Object.entries(config.providers)) { @@ -584,6 +596,8 @@ export function safeConfigDTO(config: OcxConfig): unknown { ] as const) { copyIfDefined(dto, provider, key); } + const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts); + if (modelCosts) dto.modelCosts = modelCosts; // Resolve the note by DESTINATION, not by name. A preset saved under a custom name is // still pointed at the same vendor route, and a usage restriction the user needs to see // must not disappear because the row was renamed. Prefer the same-name entry so an diff --git a/src/server/index.ts b/src/server/index.ts index 5b15f1e8c7..60b27973c0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -29,6 +29,7 @@ import { reconcileLiveStateStores, setLiveStateStoreConfig, } from "../lib/state-store-registrations"; +import { startUserCostOverlayReconciler } from "../usage/user-cost-overlay-reconciler"; import { configureAppOwnedMemoryBudget, enforceAppOwnedMemoryBudget, @@ -460,6 +461,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; try { backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy); + // External `ocx config set` / direct config.json edits run in other + // processes; poll the file so Logs/Usage display prices follow them live. + // Started inside the guarded startup transaction so the catch below can + // release the owner-scoped lease on any listener failure. + userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config }); const serveOptions = { idleTimeout: 255, async fetch(req: Request, requestServer: Server): Promise { @@ -1566,6 +1573,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + async () => { + userCostOverlayReconciler?.stop(); + }, ], async () => { await backgroundLifecycle.release(); diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 45efdac824..5cbe9fd42b 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -447,6 +447,12 @@ export async function drainAndShutdown( // then drain leftovers; failures must not prevent `server.stop`. stopStorageCleanupScheduler(); stopStateStoreSweeper(); + // The overlay reconciler is owner-scoped: the startServer stop override + // releases THIS server's lease through runListenerShutdown → + // userCostOverlayReconciler.stop(), which also recomputes disk-only + // preservation for any remaining owners. A process-wide stop here would + // kill reconciliation for every other server in the process, so drain + // must not call stopUserCostOverlayReconciler(). cancelQueuedStorageWorkerSpawns(); const shutdownJoins = await Promise.allSettled([ abortStorageCleanupPolicyJobAsync(), diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 6501e5636c..50a66ac908 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -70,6 +70,7 @@ import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; +import { userCostOverlayVersion } from "../../usage/user-cost-overlays"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; @@ -193,10 +194,19 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise>; estimateReasons: CostEstimateReason[] } @@ -123,6 +127,7 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea return "price_unmatched"; } +/** Display-time cost estimate for one log entry (or its attempt list), including the reasons that qualify the estimate. */ export function costResult(entry: MetricSource): CostResult { const tier = serviceTierContext(entry); const estimate = entry.attempts?.length @@ -136,6 +141,8 @@ export function costResult(entry: MetricSource): CostResult { && entry.usage.cacheCreationInputTokens === undefined ? "cache_detail_missing" as const : undefined, estimate.price?.source === "expected" || estimate.attempts?.some(a => a.price.source === "expected") ? "expected_price_overlay" as const : undefined, + estimate.price?.source === "user" || estimate.attempts?.some(a => a.price.source === "user") + ? "provider_cost_overlay" as const : undefined, ].filter((reason): reason is CostEstimateReason => reason !== undefined); return { kind: "value", estimate, estimateReasons }; } diff --git a/src/server/management/usage-summary-cache.ts b/src/server/management/usage-summary-cache.ts index 7b8f0e7489..4faac4acf8 100644 --- a/src/server/management/usage-summary-cache.ts +++ b/src/server/management/usage-summary-cache.ts @@ -10,6 +10,8 @@ export type CachedUsageSummary = UsageSummary & { interface UsageSummaryCacheEntry { revisionKey: string; + /** userCostOverlayVersion() when the summary was computed; overlay edits invalidate the entry. */ + overlayVersion: number; expiresAt: number; summary: CachedUsageSummary; revisionReadAt: number; diff --git a/src/types.ts b/src/types.ts index fb45843a4d..49040ed6bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1141,6 +1141,18 @@ export interface RateLimitRetryPolicy { respectRetryAfter?: boolean; } +/** + * User-configured display price for one model (USD per 1M tokens). + * Mirrors the `Cost4` shape used by the usage cost estimator; structurally + * compatible so config rows can be lifted directly into price overlays. + */ +export interface ProviderCostOverlay { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + /** * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. @@ -1263,6 +1275,15 @@ export interface OcxProviderConfig { defaultMaxOutputTokens?: number; /** Model-specific fallback output token budgets. Exact/model-pattern entries beat the provider default. */ modelMaxOutputTokens?: Record; + /** + * Per-model display prices (USD per 1M tokens) keyed by exact model id — + * opencode-style per-model pricing in ocx's flat `modelXxx` convention: + * `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. + * User-configured prices win over the built-in jawcode/expected catalogs in + * the Logs `~$` estimate. Display-time estimation only; never billing. An + * all-zero entry means "not billable here" and falls through to the catalogs. + */ + modelCosts?: Record; headers?: Record; /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */ openRouterRouting?: OpenRouterProviderRouting; diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 266cb4eb44..615419f324 100644 Binary files a/src/usage/cost.ts and b/src/usage/cost.ts differ diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index ebba51aa12..1f4967b9db 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -18,6 +18,15 @@ export interface Cost4 { cacheWrite: number; } +/** + * Upper bound for a user-configured USD-per-1M-token rate. Real prices are far + * below this (the most expensive published models cost a few hundred USD/M); + * the bound keeps `rate * tokens / 1e6` finite for any token count a usage log + * can plausibly hold, so an overlay cannot overflow the estimate to Infinity + * and serialize cost fields as null. + */ +export const MAX_COST4_RATE = 1_000_000; + export type ExpectedPriceStatus = "verified" | "verified-derived" | "unverified"; export interface ExpectedPriceOverlay { diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts new file mode 100644 index 0000000000..5b09d7334e --- /dev/null +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -0,0 +1,313 @@ +/** + * Cross-process reconciliation for user cost overlays. + * + * `refreshUserCostOverlays` is process-local by design: `ocx config set` + * executes `saveConfig()` in the CLI process, and a direct `config.json` edit + * runs no in-process code at all. A running proxy therefore keeps its previous + * `activeUserCostOverlays()`/version until restart or another in-process + * refresh. + * + * This module gives the long-lived server a lightweight stat-based poller: + * when `config.json` changes on disk, it re-reads the persisted config, mirrors + * the disk `modelCosts` rows into the live provider rows (so a later in-process + * save cannot erase the external edit), and refreshes the overlay registry so + * Logs/Usage estimates follow the edit without a restart. + * + * The reconciler is owner-scoped: every `startServer` acquires its own lease + * (with its own live config), and the shared timer is torn down only when the + * LAST owner stops. This mirrors `acquireServerBackgroundLifecycle` so stopping + * one of several servers cannot kill reconciliation for the others. + */ +import { statSync } from "node:fs"; + +import { getConfigPath, readConfigDiagnostics } from "../config"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { + refreshPreservedProviderOwner, + refreshUserCostOverlays, + registerPreservedProviderOwner, + setPreservedDiskOnlyProviders, + unregisterPreservedProviderOwner, +} from "./user-cost-overlays"; + +/** Default poll cadence for external config edits. */ +export const USER_COST_OVERLAY_RECONCILE_INTERVAL_MS = 5_000; + +let reconcileTimer: ReturnType | null = null; +let reconcileTimerMs = 0; +const owners = new Map(); +const ownerIntervals = new Map(); +let lastStamp: { mtimeMs: number; size: number; ctimeMs: number; ino: number } | null = null; +let invalidReconcileCount = 0; + +function configStamp(): { mtimeMs: number; size: number; ctimeMs: number; ino: number } | null { + try { + const stat = statSync(getConfigPath()); + return { + mtimeMs: stat.mtimeMs, + size: stat.size, + ctimeMs: stat.ctimeMs, + ino: Number(stat.ino), + }; + } catch { + return null; + } +} + +/** Registered live configs plus an optional one-shot config, deduped by object identity. */ +function liveConfigsForPreservation(extra?: OcxConfig | null): OcxConfig[] { + const liveConfigs: OcxConfig[] = []; + const seen = new Set(); + if (extra) { + seen.add(extra); + liveConfigs.push(extra); + } + for (const config of owners.values()) { + if (!config || seen.has(config)) continue; + seen.add(config); + liveConfigs.push(config); + } + return liveConfigs; +} + +/** + * Mirror disk `modelCosts` rows into provider rows the live config already + * knows. Providers added by the external edit are left out of the live config + * (they would change the routing surface); their overlays still become active + * because the registry below is refreshed from the disk config. + */ +function adoptDiskModelCosts(live: OcxConfig, disk: OcxConfig): void { + if (!live.providers || !disk.providers) return; + for (const [name, diskProvider] of Object.entries(disk.providers)) { + const liveProvider = live.providers[name]; + if (!liveProvider) continue; + if (diskProvider?.modelCosts === undefined) { + delete liveProvider.modelCosts; + } else { + liveProvider.modelCosts = structuredClone(diskProvider.modelCosts); + } + } +} + +/** + * Remember provider rows present on disk that at least one live routing + * config does NOT own. They stay out of the live configs that lack them + * (adding them would change the routing surface), but + * `persistConfigUnlocked` merges them back at serialization so ANY owner's + * unrelated save preserves a provider row that another active owner owns. + * + * A row is preserved when it is missing from at least one live config: the + * writer that lacks it would otherwise erase it. Rows every live config owns + * need no protection because every writer carries them from its own config. + */ +function rememberDiskOnlyProviders(liveConfigs: readonly OcxConfig[], disk: OcxConfig): void { + const preserved: Record = {}; + if (disk.providers && liveConfigs.length > 0) { + for (const [name, provider] of Object.entries(disk.providers)) { + if (!provider) continue; + const ownedByAll = liveConfigs.every(live => Object.hasOwn(live.providers ?? {}, name)); + if (!ownedByAll) preserved[name] = structuredClone(provider); + } + } + setPreservedDiskOnlyProviders(Object.keys(preserved).length > 0 ? preserved : null); + // Update only registered server owners. One-shot callers without a lease do + // not become deletion authorities merely by asking for a reconciliation. + for (const live of liveConfigs) refreshPreservedProviderOwner(live, disk); +} + +/** + * Re-read the persisted config and recompute the disk-only provider + * preservation registry against the CURRENT owner set. Used when an owner + * stops but others remain: a newer owner may have owned a provider that an + * older owner still treats as disk-only, and the registry must reflect the + * remaining owners rather than the stale pre-stop snapshot. + * + * A missing or transiently invalid file leaves the registry untouched so a + * bad write cannot erase preservation state. + */ +function recomputePreservedDiskOnlyProviders(): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source !== "file") return; + const liveConfigs = liveConfigsForPreservation(); + if (liveConfigs.length > 0) { + rememberDiskOnlyProviders(liveConfigs, diagnostics.config); + } else { + // No live routing configuration remains to protect disk-only providers; + // clear the cache so a later save cannot resurrect externally deleted rows. + setPreservedDiskOnlyProviders(null); + } +} + +/** + * Re-read the persisted config and make external overlay edits live. + * + * Returns `false` (and leaves the registry untouched) when the file is missing + * or invalid, so a transient bad write cannot wipe display-only prices. + */ +export function reconcileUserCostOverlaysFromDisk(liveConfig?: OcxConfig | null): boolean { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source !== "file") return false; + const disk = diagnostics.config; + if (liveConfig) { + adoptDiskModelCosts(liveConfig, disk); + // A one-shot caller is not the whole process. Existing server owners still + // participate in global preservation or this call can drop protection for + // a provider absent from an older live projection. + rememberDiskOnlyProviders(liveConfigsForPreservation(liveConfig), disk); + } else { + // No live routing config was supplied: mirror the owners path so a stale + // preservation registry cannot resurrect externally deleted providers on + // the next saveConfig. Registered owners still protect disk-only rows; + // without any, preservation is cleared. + const liveConfigs = liveConfigsForPreservation(); + if (liveConfigs.length > 0) { + for (const live of liveConfigs) adoptDiskModelCosts(live, disk); + rememberDiskOnlyProviders(liveConfigs, disk); + } else { + setPreservedDiskOnlyProviders(null); + } + } + // Refresh from the DISK config: overlays for providers only added by the + // external edit are display-only and must still resolve for historical rows. + refreshUserCostOverlays(disk); + return true; +} + +function reconcileForOwners(): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source !== "file") { + if (diagnostics.source === "fallback") invalidReconcileCount += 1; + return; + } + const disk = diagnostics.config; + const liveConfigs = liveConfigsForPreservation(); + if (liveConfigs.length > 0) { + for (const live of liveConfigs) adoptDiskModelCosts(live, disk); + rememberDiskOnlyProviders(liveConfigs, disk); + } else { + // Overlay-only refresh with no live routing configuration: do not treat + // every disk provider as protected, and do not let a stale preservation + // cache resurrect providers that were intentionally removed. + setPreservedDiskOnlyProviders(null); + } + refreshUserCostOverlays(disk); +} + +/** Test-only observation that proves the timer actually read an invalid config fallback. */ +export function userCostOverlayInvalidReconcileCountForTests(): number { + return invalidReconcileCount; +} + +/** Smallest poll interval across all active owners (the effective cadence). */ +function effectiveIntervalMs(): number { + let min = Number.POSITIVE_INFINITY; + for (const interval of ownerIntervals.values()) { + if (interval < min) min = interval; + } + return min === Number.POSITIVE_INFINITY ? USER_COST_OVERLAY_RECONCILE_INTERVAL_MS : min; +} + +/** + * Keep the shared timer on the effective minimum cadence across owners. A + * later owner may request a smaller interval, which restarts the timer; when + * an owner stops, the cadence relaxes back up to the smallest still-active + * owner's request. The timer therefore never outlives or ignores an owner's + * faster polling need, and never polls faster than the active set requires. + */ +function syncReconcileTimer(): void { + const intervalMs = effectiveIntervalMs(); + if (reconcileTimer && reconcileTimerMs === intervalMs) return; + if (reconcileTimer) clearInterval(reconcileTimer); + reconcileTimer = setInterval(() => { + const stamp = configStamp(); + if (!stamp) return; + if ( + lastStamp + && lastStamp.mtimeMs === stamp.mtimeMs + && lastStamp.size === stamp.size + && lastStamp.ctimeMs === stamp.ctimeMs + && lastStamp.ino === stamp.ino + ) return; + lastStamp = stamp; + try { + reconcileForOwners(); + } catch { + // Display-only reconciliation must never take the proxy down. + } + }, intervalMs); + reconcileTimer.unref?.(); + reconcileTimerMs = intervalMs; +} + +/** + * Start the stat-based reconciler. Each call registers an owner lease; the + * shared timer starts with the first owner and stops when the last owner's + * `stop()` releases it. + */ +export function startUserCostOverlayReconciler( + options: { intervalMs?: number; liveConfig?: OcxConfig | null } = {}, +): { stop(): void } { + const token = Symbol("user-cost-overlay-reconciler"); + const liveConfig = options.liveConfig ?? null; + owners.set(token, liveConfig); + ownerIntervals.set(token, options.intervalMs ?? USER_COST_OVERLAY_RECONCILE_INTERVAL_MS); + if (liveConfig) registerPreservedProviderOwner(liveConfig); + syncReconcileTimer(); + return { + stop() { + const ownedConfig = owners.get(token) ?? null; + owners.delete(token); + ownerIntervals.delete(token); + if (ownedConfig) unregisterPreservedProviderOwner(ownedConfig); + if (owners.size === 0) { + if (reconcileTimer) clearInterval(reconcileTimer); + reconcileTimer = null; + reconcileTimerMs = 0; + lastStamp = null; + // No live routing config remains, so nothing can keep disk-only + // providers alive: clear the cache to prevent a later save from + // resurrecting an externally deleted provider. + setPreservedDiskOnlyProviders(null); + return; + } + syncReconcileTimer(); + // Other owners remain: recompute preservation against their live + // configs. A newer owner may have owned providers that an older owner + // still sees as disk-only; without this recompute the older owner's next + // unrelated save could erase them. + try { + recomputePreservedDiskOnlyProviders(); + } catch { + // Preservation is display-only; a failed recompute must not break stop. + } + }, + }; +} + +/** + * Process-wide stop: releases EVERY owner's lease and the shared timer. + * Intended for tests and full process teardown. A per-server shutdown must + * call its own owner-scoped `stop()` handle instead — this function would + * otherwise tear down reconciliation (and disk-only preservation) for every + * other server still running in the same process. + */ +export function stopUserCostOverlayReconciler(): void { + for (const config of owners.values()) { + if (config) unregisterPreservedProviderOwner(config); + } + owners.clear(); + ownerIntervals.clear(); + if (reconcileTimer) clearInterval(reconcileTimer); + reconcileTimer = null; + reconcileTimerMs = 0; + lastStamp = null; + // No live routing config remains; drop preservation so a later save cannot + // resurrect externally deleted providers. + setPreservedDiskOnlyProviders(null); +} + +/** Test-only reset for module-global reconciler state. */ +export function resetUserCostOverlayReconcilerForTests(): void { + stopUserCostOverlayReconciler(); + invalidReconcileCount = 0; +} diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts new file mode 100644 index 0000000000..22af57e87a --- /dev/null +++ b/src/usage/user-cost-overlays.ts @@ -0,0 +1,314 @@ +/** + * Runtime registry for user-configured provider cost overlays + * (`providers..modelCosts` in config.json — per-model prices in ocx's + * flat `modelXxx` convention, mirroring opencode's per-model pricing). + * + * The usage cost estimator stays pure: it receives overlays as parameters and + * defaults to this registry, which is refreshed at the config chokepoints + * (loadConfig and every persist path). A refresh that actually changes the + * rows replaces the active array with a NEW identity and bumps a version + * counter, so the estimator's memo and the /api/usage summary cache skip stale + * rows without cross-module invalidation. Refreshes with byte-identical rows + * are no-ops: config is reloaded at many chokepoints and an unchanged reload + * must not churn the version (see refreshUserCostOverlays). The configured + * provider-name set is part of the change identity: adding or removing a + * provider changes which names may collapse to a label base in the resolver, + * so it bumps the version even when no overlay row changed. + * + * Display-time estimation only — these rows never affect billing. + */ +import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; +import { MAX_COST4_RATE, type ExpectedPriceOverlay } from "./expected-prices"; +import { redactSecretString } from "../lib/redact"; + +const EMPTY: readonly ExpectedPriceOverlay[] = []; + +let active: readonly ExpectedPriceOverlay[] = EMPTY; +let activeSignature = ""; +let activeConfigured = new Set(); +let version = 0; +let preservedDiskOnlyProviders: Record | null = null; + +/** + * Preservation owner metadata rides through the shallow config projections in + * config.ts via an enumerable SYMBOL key. JSON.stringify ignores symbol keys, + * so the tag is process-local only and can never reach config.json or a DTO. + */ +const PRESERVATION_OWNER_STATE = Symbol("opencodex.user-cost-overlay-preservation-owner"); +const PERSISTED_PROVIDER_DELETIONS = Symbol("opencodex.persisted-provider-deletions"); + +type PreservationOwnerState = { + refs: number; + config: OcxConfig; + ownedProviders: Set; +}; + +type PreservationTaggedConfig = OcxConfig & { + [PRESERVATION_OWNER_STATE]?: PreservationOwnerState; + [PERSISTED_PROVIDER_DELETIONS]?: readonly string[]; +}; + +const preservationOwnerStates = new Set(); + +function providerNames(config: OcxConfig): Set { + return new Set(Object.keys(config.providers ?? {})); +} + +/** Register one active live-config owner. Multiple server leases may share one config object. */ +export function registerPreservedProviderOwner(config: OcxConfig): void { + const tagged = config as PreservationTaggedConfig; + const existing = tagged[PRESERVATION_OWNER_STATE]; + if (existing && preservationOwnerStates.has(existing)) { + existing.refs += 1; + existing.ownedProviders = providerNames(config); + return; + } + const state: PreservationOwnerState = { + refs: 1, + config, + ownedProviders: providerNames(config), + }; + // Enumerable is deliberate: projectCustomModelCatalogMigration and the live + // binding guard use object spread, which must carry this process-local tag to + // the serialization view. Symbol keys are still omitted by JSON.stringify. + Object.defineProperty(tagged, PRESERVATION_OWNER_STATE, { + value: state, + enumerable: true, + configurable: true, + }); + preservationOwnerStates.add(state); +} + +/** + * Refresh a registered owner's provider snapshot from a successful disk read. + * + * A provider still present on disk remains owned even if the live object + * temporarily omits it: management DELETE has an async-import gap between + * mutating the live config and committing the write, and a reconciler tick in + * that gap must not erase the deletion authority. Conversely, providers that + * disappeared from disk are no longer considered owned, and newly present + * providers are adopted only when this live config actually has the row. + */ +export function refreshPreservedProviderOwner(config: OcxConfig, disk: OcxConfig): void { + const state = (config as PreservationTaggedConfig)[PRESERVATION_OWNER_STATE]; + if (!state || !preservationOwnerStates.has(state)) return; + const diskNames = providerNames(disk); + for (const name of [...state.ownedProviders]) { + if (!diskNames.has(name)) state.ownedProviders.delete(name); + } + for (const name of Object.keys(config.providers ?? {})) { + if (diskNames.has(name)) state.ownedProviders.add(name); + } +} + +/** Release one active live-config owner lease. */ +export function unregisterPreservedProviderOwner(config: OcxConfig): void { + const tagged = config as PreservationTaggedConfig; + const state = tagged[PRESERVATION_OWNER_STATE]; + if (!state || !preservationOwnerStates.has(state)) return; + state.refs -= 1; + if (state.refs > 0) return; + preservationOwnerStates.delete(state); + if (tagged[PRESERVATION_OWNER_STATE] === state) { + delete tagged[PRESERVATION_OWNER_STATE]; + } +} + +/** + * Remember provider rows that exist on disk but are intentionally absent from + * the live routing config (added by an external editor after the proxy booted). + * They are merged back at the config serialization boundary so an unrelated + * in-process save cannot erase the external provider and its overlay. + */ +export function setPreservedDiskOnlyProviders( + providers: Record | null, +): void { + preservedDiskOnlyProviders = providers; +} + +/** + * Commit an explicit provider deletion only after the persisted serialization + * view has been accepted by the config write path. This clears the stale + * preservation row and removes the provider from every active live projection, + * so a second owner cannot resurrect it on a later unrelated save. + */ +function commitPersistedProviderDeletions(config: OcxConfig): void { + const tagged = config as PreservationTaggedConfig; + const deletions = tagged[PERSISTED_PROVIDER_DELETIONS]; + if (!deletions || deletions.length === 0) return; + + const deleted = new Set(deletions); + if (preservedDiskOnlyProviders) { + const next = { ...preservedDiskOnlyProviders }; + for (const name of deleted) delete next[name]; + preservedDiskOnlyProviders = Object.keys(next).length > 0 ? next : null; + } + + for (const state of preservationOwnerStates) { + for (const name of deleted) { + state.ownedProviders.delete(name); + if (state.config.providers) delete state.config.providers[name]; + } + } + delete tagged[PERSISTED_PROVIDER_DELETIONS]; +} + +/** + * A serialization view of `config` that keeps externally added providers on + * disk without adding them to live routing state. Live providers win when a + * name exists in both maps. + * + * A registered owner also carries the provider names it previously owned. If + * that owner now omits one of those providers, the omission is an intentional + * in-process deletion rather than an old projection that never knew the row. + * Suppress only those names from preservation for this write. The suppression + * is committed globally only after refreshUserCostOverlays receives the + * successfully persisted view, so a failed atomic write cannot destroy the + * preservation safety net. + */ +export function withPreservedDiskOnlyProviders(config: OcxConfig): OcxConfig { + const tagged = config as PreservationTaggedConfig; + const owner = tagged[PRESERVATION_OWNER_STATE]; + const deletedProviders = owner && preservationOwnerStates.has(owner) + ? [...owner.ownedProviders].filter(name => !Object.hasOwn(config.providers ?? {}, name)) + : []; + const deletedSet = new Set(deletedProviders); + + let preserved: Record | null = null; + if (preservedDiskOnlyProviders) { + const filtered = Object.entries(preservedDiskOnlyProviders) + .filter(([name]) => !deletedSet.has(name)); + if (filtered.length > 0) preserved = Object.fromEntries(filtered); + } + + let persisted: OcxConfig; + if (preserved && Object.keys(preserved).length > 0) { + persisted = { + ...config, + providers: { + ...preserved, + ...config.providers, + }, + }; + } else if (deletedProviders.length > 0) { + // Return a distinct object so the persisted-deletion marker remains scoped + // to this serialization attempt rather than the long-lived live config. + persisted = { ...config }; + } else { + return config; + } + + if (deletedProviders.length > 0) { + Object.defineProperty(persisted as PreservationTaggedConfig, PERSISTED_PROVIDER_DELETIONS, { + value: deletedProviders, + enumerable: false, + configurable: true, + }); + } + return persisted; +} + +/** Test-only reset for the preserved disk-only provider registry. */ +export function resetPreservedDiskOnlyProvidersForTests(): void { + preservedDiskOnlyProviders = null; + preservationOwnerStates.clear(); +} + +/** Exact four fields accepted for a user-configured cost tuple. */ +export const COST4_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const; + +/** Shared per-rate predicate used by config validation, display sanitization, and runtime lifting. */ +export function isValidCost4Rate(rate: unknown): rate is number { + return typeof rate === "number" + && Number.isFinite(rate) + && rate >= 0 + && rate <= MAX_COST4_RATE; +} + +/** True when `value` is a complete cost entry: all four rates are non-negative finite numbers. */ +function validCost4(value: unknown): value is ProviderCostOverlay { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const entry = value as Record; + return COST4_RATE_KEYS.every(key => isValidCost4Rate(entry[key])); +} + +/** + * Rebuild the active user-overlay rows from the current config. Malformed rows + * are skipped (config validation reports them separately); a provider with no + * overlay contributes nothing. + */ +export function refreshUserCostOverlays(config: OcxConfig): void { + // Only persisted serialization views carry PERSISTED_PROVIDER_DELETIONS. + // Committing here means changed writes update owner state only after the + // atomic write succeeds, while byte-identical successful saves also converge. + commitPersistedProviderDeletions(config); + + const rows: ExpectedPriceOverlay[] = []; + const providers = config.providers; + if (providers) { + for (const [providerName, provider] of Object.entries(providers)) { + const costs = provider?.modelCosts; + if (!costs || typeof costs !== "object" || Array.isArray(costs)) continue; + for (const [modelId, cost4] of Object.entries(costs)) { + if (!modelId.trim() || !validCost4(cost4)) continue; + rows.push({ + provider: providerName, + modelId, + // Copy ONLY the four validated rate fields: a hand-edited row may + // carry extra properties (e.g. a misplaced apiKey) that must never + // reach display estimates or /api/logs through the registry. + cost4: { + input: cost4.input, + output: cost4.output, + cacheRead: cost4.cacheRead, + cacheWrite: cost4.cacheWrite, + }, + // Display provenance only: redact token-shaped provider/model ids so + // the source string can never echo a pasted key. Matching still uses + // the raw fields, and the change-detection signature below MUST keep + // them raw — distinct ids would otherwise collapse to "[REDACTED]" + // and skip the version bump. + source: `config:providers.${redactSecretString(providerName)}.modelCosts[${redactSecretString(modelId)}]`, + verifiedAt: "user-configured", + status: "verified", + }); + } + } + } + // Config is re-loaded at many chokepoints (server start, migrations, persist + // paths). Bumping the version on every load — even when nothing changed — + // would invalidate the /api/usage summary cache on unrelated reloads and + // churn the cost memo. Only a real overlay change bumps the version, so the + // cache survives reloads of an unchanged config. + // The signature MUST compare the raw matching fields: two different + // secret-shaped ids would both redact to "[REDACTED]" and falsely look + // unchanged, skipping the version bump and serving stale estimates. The + // signature is process-local state and is never serialized to a response; + // only the display `source` above is redacted. + // The configured provider-name set is part of the identity too: adding or + // removing a provider (even one without an overlay) changes which names are + // allowed to collapse to a label base, so the resolver memo and the + // /api/usage summary cache must be invalidated on that change as well. + const configuredNames = Object.keys(providers ?? {}).sort(); + const signature = `${JSON.stringify(configuredNames)}\u0000${JSON.stringify(rows)}`; + if (signature === activeSignature) return; + activeSignature = signature; + active = rows; + activeConfigured = new Set(configuredNames); + version++; +} + +/** Active user-configured overlay rows (stable identity until the next refresh). */ +export function activeUserCostOverlays(): readonly ExpectedPriceOverlay[] { + return active; +} + +/** Monotonic version bumped on every refresh; used by the estimator memo key. */ +export function userCostOverlayVersion(): number { + return version; +} + +/** Configured provider names from the last refresh (pricing-namespace identity). */ +export function activeConfiguredProviders(): ReadonlySet { + return activeConfigured; +} diff --git a/tests/alibaba-region-migration.test.ts b/tests/alibaba-region-migration.test.ts index 96d4ee4a4d..6847eee1ee 100644 --- a/tests/alibaba-region-migration.test.ts +++ b/tests/alibaba-region-migration.test.ts @@ -165,12 +165,14 @@ test("is idempotent across repeated startups", () => { expect(second.config).toEqual(first.config); }); -test("carries liveModels and a user-authored note, but not the Beijing catalog", () => { +test("carries liveModels, modelCosts, and a user-authored note, but not the Beijing catalog", () => { const config = migratableConfig(); - Object.assign(config.providers["alibaba-token-plan"]!, { liveModels: true, note: "my own note" }); + const costs = { "kimi-k3": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 } }; + Object.assign(config.providers["alibaba-token-plan"]!, { liveModels: true, note: "my own note", modelCosts: costs }); const moved = projectAlibabaRegionMigration(config).config.providers["alibaba-token-plan-intl"]!; expect(moved.liveModels).toBe(true); + expect(moved.modelCosts).toEqual(costs); expect(moved.note).toBe("my own note"); expect(moved.models).toContain("kimi-k2.7-code"); }); diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index ae7e32cd70..33e12d0869 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { managementFetch as fetch } from "./helpers/management-auth"; import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -6,8 +6,12 @@ import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; +import { refreshUserCostOverlays, userCostOverlayVersion } from "../src/usage/user-cost-overlays"; +import { stopUserCostOverlayReconciler } from "../src/usage/user-cost-overlay-reconciler"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import { resetUsageReadCacheForTests, usageReadCacheStatsForTests } from "../src/usage/log"; +import * as usageLogModule from "../src/usage/log"; +import { getUsageSummaryCacheEntry, resetUsageSummaryCacheForTests } from "../src/server/management/usage-summary-cache"; let testDir = ""; let previousHome: string | undefined; @@ -76,6 +80,10 @@ beforeEach(() => { }); afterEach(() => { + // Belt-and-suspenders: server.stop should release the reconciler lease, but a + // wedged shutdown on Linux CI must not leave the 5s poll timer keeping the + // isolate worker alive for later shard files (e.g. cli-restore-back). + stopUserCostOverlayReconciler(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); @@ -154,6 +162,96 @@ describe("GET /api/usage", () => { } }); + test("usage route cache invalidates when the user cost overlay version changes", async () => { + writeFixture(Date.now()); + // Start from a known overlay version so a leftover entry from an earlier + // test cannot satisfy the first request. This must run BEFORE startServer: + // the server boot loads the config and refreshes the overlay registry, and + // the version has to be settled by the time the first request caches. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + const server = startServer(0); + try { + const first = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + const second = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(second.summary).toEqual(first.summary); + expect(usageReadCacheStatsForTests().fullReads).toBe(1); + // A modelCosts save refreshes the overlay registry and bumps its version; + // the cached summary must not be reused even though the usage log is unchanged. + refreshUserCostOverlays({ + providers: { + blsc: { + modelCosts: { + "deepseek-v4-flash": { input: 0.5, output: 2, cacheRead: 0.1, cacheWrite: 0.25 }, + }, + }, + }, + } as unknown as OcxConfig); + const changed = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(changed.summary.requests).toBe(first.summary.requests); + expect(usageReadCacheStatsForTests().fullReads).toBe(2); + } finally { + // This test installs a module-level blsc overlay; clear it even when an + // assertion or shutdown fails so later tests cannot resolve + // user-configured prices unexpectedly. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + await server.stop(true); + } + }); + + test("usage route does not cache a summary whose overlay version changed mid-read", async () => { + writeFixture(Date.now()); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + resetUsageSummaryCacheForTests(); + const versionBefore = userCostOverlayVersion(); + // Deterministically bump the overlay version DURING the snapshot read, so + // the summary is computed under a version that is stale before the cache + // stamp — the interleaving that previously stamped an old-price summary as + // current. The spy must be installed before the first /api/usage request: + // a warm request would be served from the summary cache and never reach + // the read. + const originalRead = usageLogModule.readUsageSnapshotForManagement; + let bumped = false; + const spy = spyOn(usageLogModule, "readUsageSnapshotForManagement").mockImplementation(async (maxReadBytes?: number) => { + const snapshot = await originalRead(maxReadBytes); + if (!bumped) { + bumped = true; + refreshUserCostOverlays({ + providers: { + blsc: { + modelCosts: { + "deepseek-v4-flash": { input: 0.5, output: 2, cacheRead: 0.1, cacheWrite: 0.25 }, + }, + }, + }, + } as unknown as OcxConfig); + } + return snapshot; + }); + const server = startServer(0); + try { + const raced = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(bumped).toBe(true); + expect(userCostOverlayVersion()).toBeGreaterThan(versionBefore); + // The mid-read change must NOT leave a cache entry: the mixed-price + // summary is served uncached so the next request recomputes. + expect(getUsageSummaryCacheEntry("30d:all")).toBeUndefined(); + + spy.mockRestore(); + // Once the overlay is settled, the next request recomputes and caches + // under the new version. + const settled = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(settled.summary.requests).toBe(raced.summary.requests); + expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion).toBe(userCostOverlayVersion()); + } finally { + spy.mockRestore(); + // Clear the module-level overlay and summary cache even when an + // assertion or shutdown fails so later tests start clean. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + resetUsageSummaryCacheForTests(); + await server.stop(true); + } + }); + test("range=7d drops entries older than 7 days", async () => { writeFixture(Date.now()); const server = startServer(0); diff --git a/tests/cli-config-command.test.ts b/tests/cli-config-command.test.ts new file mode 100644 index 0000000000..d052758022 --- /dev/null +++ b/tests/cli-config-command.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; + +const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); +const cliPath = join(repoRoot, "src", "cli", "index.ts"); +const isolatedCodexHome = mkdtempSync(join(tmpdir(), "ocx-config-codex-home-")); + +setDefaultTimeout(SPAWN_BUDGET_MS); + +function runCli(args: string[], env: Record = {}) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: isolatedCodexHome, ...env }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); +} + +function freshConfig() { + const dir = mkdtempSync(join(tmpdir(), "ocx-config-")); + const config = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: { + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + "sk-abcdef1234567890": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, + }, + }, + }, + defaultProvider: "openai", + }; + writeFileSync(join(dir, "config.json"), JSON.stringify(config), "utf8"); + return dir; +} + +describe("ocx config display redaction", () => { + test("config show --json never prints secret-shaped modelCosts keys", () => { + const dir = freshConfig(); + try { + const result = runCli(["config", "show", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("sk-abcdef1234567890"); + const parsed = JSON.parse(result.stdout); + expect(parsed.providers.blsc.modelCosts).toEqual({ + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("config get providers..modelCosts --json drops secret-shaped keys", () => { + const dir = freshConfig(); + try { + const result = runCli(["config", "get", "providers.blsc.modelCosts", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("sk-abcdef1234567890"); + const parsed = JSON.parse(result.stdout); + expect(parsed).toEqual({ + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli-provider.test.ts b/tests/cli-provider.test.ts index e7b8e1e6aa..77edcc8d30 100644 --- a/tests/cli-provider.test.ts +++ b/tests/cli-provider.test.ts @@ -149,6 +149,74 @@ describe("ocx provider", () => { } }); + test("provider show --json never prints secret-shaped modelCosts keys", () => { + const { dir } = freshConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: { + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + "sk-abcdef1234567890": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, + }, + }, + }, + }); + try { + const result = runCli(["provider", "show", "blsc", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("sk-abcdef1234567890"); + const parsed = JSON.parse(result.stdout); + expect(parsed.modelCosts).toEqual({ + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("provider add --force preserves an existing modelCosts overlay", () => { + const { dir } = freshConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + apiKey: "sk-old", + modelCosts: { + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }, + }, + }, + }); + try { + const result = runCli([ + "provider", "add", "blsc", + "--adapter", "openai-chat", + "--base-url", "https://llmapi.blsc.cn", + "--api-key", "sk-rotated", + "--force", + ], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const config = readConfig(dir); + expect(config.providers.blsc.apiKey).toBe("sk-rotated"); + expect(config.providers.blsc.modelCosts).toEqual({ + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("provider add custom provider with full flags", () => { const { dir } = freshConfig(); try { diff --git a/tests/cli-restore-back.test.ts b/tests/cli-restore-back.test.ts index a82700290b..93d958388a 100644 --- a/tests/cli-restore-back.test.ts +++ b/tests/cli-restore-back.test.ts @@ -1,18 +1,32 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, setDefaultTimeout, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { claimOwnedServiceHome } from "./helpers/owned-service-home"; +import { SPAWN_BUDGET_MS } from "./helpers/test-budget"; const repoRoot = join(import.meta.dir, ".."); +// Every case spawns the real CLI; match cli-provider.test.ts budgets so a wedged +// child fails fast instead of burning the whole shard timeout on Linux CI. +setDefaultTimeout(SPAWN_BUDGET_MS); + function ownedEnvironment(codexHome: string, ocxHome: string): Record { const home = join(ocxHome, "home"); mkdirSync(home, { recursive: true }); return { HOME: home, USERPROFILE: home, ...claimOwnedServiceHome(codexHome, ocxHome, home).env }; } +function runCli(args: string[], env: Record) { + return spawnSync(process.execPath, ["run", "src/cli/index.ts", ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: "utf8", + timeout: SPAWN_BUDGET_MS - 5_000, + }); +} + describe("ocx restore back", () => { test("restore durably disables Codex in an isolated home", () => { const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-restore-codex-")); @@ -20,10 +34,11 @@ describe("ocx restore back", () => { try { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", checkForUpdates: false }), "utf8"); - const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "restore"], { - cwd: repoRoot, - env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, - encoding: "utf8", + const result = runCli(["restore"], { + ...ownedEnvironment(codexHome, ocxHome), + CODEX_HOME: codexHome, + OPENCODEX_HOME: ocxHome, + CI: "1", }); expect(result.status).toBe(0); expect(JSON.parse(readFileSync(join(ocxHome, "config.json"), "utf8")).clientIntegrations.codex).toBe(false); @@ -43,10 +58,10 @@ describe("ocx restore back", () => { providers: {}, defaultProvider: "openai", checkForUpdates: false, clientIntegrations: { codex: false }, }), "utf8"); - const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "restore", "--json"], { - cwd: repoRoot, - env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, - encoding: "utf8", + const result = runCli(["restore", "--json"], { + ...ownedEnvironment(codexHome, ocxHome), + CODEX_HOME: codexHome, + OPENCODEX_HOME: ocxHome, }); expect(result.status).toBe(0); const envelope = JSON.parse(result.stdout) as { @@ -77,10 +92,11 @@ describe("ocx restore back", () => { writeFileSync(configPath, 'model = "gpt-5"\n', "utf8"); writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", clientIntegrations: { codex: false }, checkForUpdates: false }), "utf8"); const before = statSync(configPath).mtimeMs; - const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "sync"], { - cwd: repoRoot, - env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, - encoding: "utf8", + const result = runCli(["sync"], { + ...ownedEnvironment(codexHome, ocxHome), + CODEX_HOME: codexHome, + OPENCODEX_HOME: ocxHome, + CI: "1", }); expect(result.status).toBe(0); expect(`${result.stdout}\n${result.stderr}`).toContain("Codex integration is OFF; sync skipped and no Codex files changed."); @@ -117,10 +133,11 @@ describe("ocx restore back", () => { checkForUpdates: false, }), "utf8"); - const result = spawnSync(process.execPath, ["run", "src/cli/index.ts", "sync"], { - cwd: repoRoot, - env: { ...process.env, ...ownedEnvironment(codexHome, ocxHome), CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome, CI: "1" }, - encoding: "utf8", + const result = runCli(["sync"], { + ...ownedEnvironment(codexHome, ocxHome), + CODEX_HOME: codexHome, + OPENCODEX_HOME: ocxHome, + CI: "1", }); expect(result.status).toBe(1); @@ -133,23 +150,27 @@ describe("ocx restore back", () => { }); test("help documents both directions of the switch", () => { + const codexHome = mkdtempSync(join(tmpdir(), "ocx-cli-help-codex-")); const ocxHome = mkdtempSync(join(tmpdir(), "ocx-cli-help-home-")); try { + writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5"\n', "utf8"); writeFileSync(join(ocxHome, "config.json"), JSON.stringify({ providers: {}, defaultProvider: "openai", checkForUpdates: false, }), "utf8"); - const run = (...cliArgs: string[]) => spawnSync(process.execPath, ["run", "src/cli/index.ts", ...cliArgs], { - cwd: repoRoot, - env: { ...process.env, OPENCODEX_HOME: ocxHome, CI: "1" }, - encoding: "utf8", - }); - const usage = run("help"); + const env = { + ...ownedEnvironment(codexHome, ocxHome), + CODEX_HOME: codexHome, + OPENCODEX_HOME: ocxHome, + CI: "1", + }; + const usage = runCli(["help"], env); expect(usage.status).toBe(0); expect(`${usage.stdout}\n${usage.stderr}`).toContain("ocx restore back"); - const restoreHelp = run("help", "restore"); + const restoreHelp = runCli(["help", "restore"], env); expect(restoreHelp.status).toBe(0); expect(`${restoreHelp.stdout}\n${restoreHelp.stderr}`).toContain("ocx restore [back]"); } finally { + rmSync(codexHome, { recursive: true, force: true }); rmSync(ocxHome, { recursive: true, force: true }); } }); diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 32ef000705..ded0912299 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -16,6 +16,7 @@ import { } from "../src/config"; import { legacyCustomModelCatalogSlugs } from "../src/codex/custom-model-catalog-migration"; import { rateLimitRetryPolicyFor } from "../src/providers/key-failover"; +import { activeUserCostOverlays, refreshUserCostOverlays } from "../src/usage/user-cost-overlays"; import type { OcxConfig } from "../src/types"; /** @@ -62,6 +63,10 @@ beforeEach(() => { }); afterEach(() => { + // The overlay registry is module-level; reset it so rows adopted by + // reconcileLiveConfigFromDisk cannot leak into later tests in a + // shared-process run. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; rmSync(home, { recursive: true, force: true }); @@ -549,6 +554,36 @@ test("OAuth reconciliation adopts a guarded Claude edit that predates its disk s expect(diskConfig().claudeCode).toEqual({ authMode: "proxy" }); }); +test("OAuth reconciliation adopts a modelCosts edit and refreshes the overlay registry", () => { + const live = loadConfig(); + const persistedBaseline = loadConfig(); + const costs = { "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 } }; + // A cooperating process hand-edits config.json while the login is pending. + writeDiskConfig({ + providers: { + test: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "k", + allowPrivateNetwork: true, + modelCosts: costs, + }, + }, + }); + + reconcileLiveConfigFromDisk(live, persistedBaseline); + + expect(live.providers.test.modelCosts).toEqual(costs); + // The overlay registry must follow the reconciled live config immediately, + // not after the next changed save or restart. + expect(activeUserCostOverlays()).toHaveLength(1); + expect(activeUserCostOverlays()[0]).toMatchObject({ + provider: "test", + modelId: "deepseek-v4-flash", + cost4: costs["deepseek-v4-flash"], + }); +}); + // Structural compare, not JSON.stringify: key order must not fake an external edit. test("a key-order-only difference is not treated as an external edit", () => { const live = loadConfig(); diff --git a/tests/key-login-live-update.test.ts b/tests/key-login-live-update.test.ts new file mode 100644 index 0000000000..6728e952c4 --- /dev/null +++ b/tests/key-login-live-update.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../src/config"; +import { commitKeyLoginProvider, providerConfigFromKeyLoginProvider } from "../src/oauth/login-cli"; +import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { refreshUserCostOverlays } from "../src/usage/user-cost-overlays"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { managementFetch as fetch } from "./helpers/management-auth"; + +/** + * Regression: `ocx login ` used to POST the unmerged preset row + * into a running proxy. The proxy then saved the replacement without the + * preserved modelCosts overlay, undoing the just-written disk state until a + * restart (the live row had no existingCosts to carry forward). + */ +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function umansKeyConfig(port = 0): OcxConfig { + return { + port, + hostname: "127.0.0.1", + defaultProvider: "umans", + providers: { + umans: { + adapter: "anthropic", + baseUrl: "https://api.code.umans.ai", + apiKey: "sk-old", + }, + }, + } as OcxConfig; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-key-login-live-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-key-login-live-")); + process.env.OPENCODEX_HOME = testDir; + saveConfig(umansKeyConfig()); +}); + +afterEach(() => { + // The overlay registry is module-level; reset it so rows added through the + // live provider update path cannot leak into later tests in a shared run. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +describe("CLI key-login live-update overlay preservation", () => { + test("notify after key login pushes the merged row and keeps modelCosts on live and disk", async () => { + const server = startServer(0); + try { + const port = server.port!; + const boot = loadConfig(); + boot.port = port; + saveConfig(boot); + + // The proxy booted before the overlay existed; a hand-edit then adds + // modelCosts to disk only, so the live in-memory row has no overlay yet. + const edited = loadConfig(); + edited.providers.umans!.modelCosts = { + "umans-coder": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, + }; + saveConfig(edited); + + const config = loadConfig(); + const replacement = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-rotated"); + const merged = await commitKeyLoginProvider(config, "umans", replacement); + expect(merged.modelCosts).toEqual(edited.providers.umans!.modelCosts); + + // The proxy's POST /api/providers handler saves its config; it must keep + // the overlay (the merged row was notified), not strip it and undo the + // just-written disk state. + const disk = JSON.parse(readFileSync(join(testDir, "config.json"), "utf-8")) as OcxConfig; + expect(disk.providers.umans!.modelCosts).toEqual(edited.providers.umans!.modelCosts); + expect(disk.providers.umans!.apiKey).toBe("sk-rotated"); + + // The running proxy must also carry the overlay in its live config: + // notifyRunningProxy posted the merged row to POST /api/providers, so a + // silent early return or failed POST would leave the in-memory DTO stale + // even though disk is correct. + const live = (await fetch(new URL("/api/config", server.url)).then(r => r.json())) as { + providers: Record }>; + }; + expect(live.providers.umans?.modelCosts).toEqual(edited.providers.umans!.modelCosts); + } finally { + await server.stop(true); + } + }, 15_000); +}); diff --git a/tests/key-login-preserves-model-costs.test.ts b/tests/key-login-preserves-model-costs.test.ts new file mode 100644 index 0000000000..d653faa99b --- /dev/null +++ b/tests/key-login-preserves-model-costs.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { mergeKeyLoginProviderRow, providerConfigFromKeyLoginProvider } from "../src/oauth/login-cli"; +import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers"; +import type { OcxProviderConfig } from "../src/types"; + +describe("key login preserves user-configured price overlays", () => { + test("rotating the API key carries modelCosts onto the replacement row", () => { + const replacement = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-rotated"); + const existing: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.code.umans.ai", + apiKey: "sk-old", + modelCosts: { + "umans-coder": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, + }, + }; + const merged = mergeKeyLoginProviderRow(replacement, existing); + expect(merged.apiKey).toBe("sk-rotated"); + expect(merged.modelCosts).toEqual(existing.modelCosts); + }); + + test("a provider without an overlay does not gain the modelCosts key", () => { + const replacement = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-new"); + const merged = mergeKeyLoginProviderRow(replacement, undefined); + expect(merged.modelCosts).toBeUndefined(); + }); + + test("an explicit empty overlay is preserved instead of being dropped", () => { + const replacement = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-another"); + const existing: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.code.umans.ai", + apiKey: "sk-old", + modelCosts: {}, + }; + const merged = mergeKeyLoginProviderRow(replacement, existing); + expect(merged.modelCosts).toEqual({}); + }); + + test("the merge returns a fresh row so the proxy notify cannot diverge from disk", () => { + const replacement = providerConfigFromKeyLoginProvider(KEY_LOGIN_PROVIDERS.umans, "sk-fresh"); + const existing: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.code.umans.ai", + apiKey: "sk-old", + modelCosts: { "umans-coder": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }, + }; + const merged = mergeKeyLoginProviderRow(replacement, existing); + // handleKeyLogin assigns the merged row and passes the same object to + // notifyRunningProxy; if the helper mutated the preset row instead, the + // notification would silently use catalog prices until a config reload. + expect(merged).not.toBe(replacement); + expect(merged.modelCosts).toEqual(existing.modelCosts); + expect(replacement.modelCosts).toBeUndefined(); + }); +}); diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 81ab8133be..ea990d91cc 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -125,6 +125,25 @@ afterEach(() => { }); describe("provider management validation", () => { + test("provider management rejects modelCosts rows with extra fields", () => { + const error = providerManagementConfigError("blsc", { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: { + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0, apiKey: "sk-leak" }, + }, + }); + expect(error).toContain("unexpected fields"); + expect(error).not.toContain("sk-leak"); + expect(providerManagementConfigError("blsc", { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: { + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }, + })).toBeNull(); + }); + test("provider management validates model hosted-tool preferences", () => { const provider = { adapter: "openai-responses", @@ -325,6 +344,66 @@ describe("provider management validation", () => { } }); + test("provider POST overwrite preserves modelCosts when the payload omits it", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const costs = { "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 } }; + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-costs", + provider: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1", modelCosts: costs }, + }), + }); + expect(create.status).toBe(200); + + // The dashboard's add/edit form does not send modelCosts; overwriting the + // provider must not silently erase the hand-edited price overlay. + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-costs", + provider: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1" }, + }), + }); + expect(overwrite.status).toBe(200); + expect(loadConfig().providers["custom-costs"]?.modelCosts).toEqual(costs); + } finally { + await server.stop(true); + } + }); + + test("provider management accepts modelCosts on the canonical openai provider", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const costs = { "gpt-5.6": { input: 1.2, output: 3.2, cacheRead: 0.12, cacheWrite: 0 } }; + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "openai", + provider: { ...canonicalDirect, codexAccountMode: "pool", modelCosts: costs }, + }), + }); + expect(response.status).toBe(200); + expect(loadConfig().providers.openai?.modelCosts).toEqual(costs); + } finally { + await server.stop(true); + } + }); + test("provider management rejects runtime metadata and accepts only canonical OpenAI option seeds", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/oauth-upsert-preserves-api-key.test.ts b/tests/oauth-upsert-preserves-api-key.test.ts index 6ef07220bd..a15e4d20fa 100644 --- a/tests/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth-upsert-preserves-api-key.test.ts @@ -52,6 +52,14 @@ describe("upsertOAuthProvider credential preservation", () => { expect(provider.authMode).toBe("key"); }); + test("carries user-configured modelCosts across a re-login upsert", () => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + const costs = { "grok-4": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }; + config.providers.xai!.modelCosts = costs; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.modelCosts).toEqual(costs); + }); + test("carries the key over without changing oauth billing when the user did not pick key mode", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.authMode = "oauth"; diff --git a/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index cb10642ae3..436b78912f 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -4,7 +4,7 @@ import { OpenAiTierMigrationCollisionError, projectOpenAiTierMigration, } from "../src/providers/openai-tiers"; -import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../src/types"; const forward: OcxProviderConfig = { adapter: "openai-responses", @@ -36,6 +36,9 @@ describe("OpenAI provider option migration matrix", () => { const result = projectOpenAiTierMigration(input); expectCanonical(result, "pool"); expect(Object.keys(result.config.providers)).toEqual(["openai"]); + // No overlays on either legacy row: the merge must not attach a spurious + // empty modelCosts: {} to the canonical row. + expect(result.config.providers.openai).not.toHaveProperty("modelCosts"); expect(result.changed).toBe(true); expect(input).toEqual(before); }); @@ -169,6 +172,154 @@ describe("OpenAI provider option migration matrix", () => { expect(result.config.providers.openai.selectedModels).toEqual(["gpt-a", "gpt-b", "gpt-c"]); }); + test("carries modelCosts from openai and openai-multi into the merged row", () => { + const costs = { "gpt-5.6": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }; + const multiCosts = { "gpt-4.1": { input: 3, output: 4, cacheRead: 0.2, cacheWrite: 0 } }; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + openai: { ...forward, modelCosts: costs }, + "openai-multi": { ...forward, modelCosts: multiCosts }, + }, + })); + // Disjoint overlays from both legacy rows survive the merge. + expect(result.config.providers.openai.modelCosts).toEqual({ ...multiCosts, ...costs }); + }); + + test("canonical openai modelCosts wins on key conflicts over the legacy multi map", () => { + const costs = { "gpt-5.6": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }; + const multiCosts = { "gpt-5.6": { input: 9, output: 9, cacheRead: 0.9, cacheWrite: 0.9 } }; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + openai: { ...forward, modelCosts: costs }, + "openai-multi": { ...forward, modelCosts: multiCosts }, + }, + })); + expect(result.config.providers.openai.modelCosts).toEqual(costs); + }); + + test("modelCosts-bearing canonical Multi is a managed overlay, not a collision", () => { + const multiCosts = { "gpt-5.6": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + "openai-multi": { ...forward, modelCosts: multiCosts }, + }, + })); + expect(result.config.providers.openai.modelCosts).toEqual(multiCosts); + }); + + test("rewrites legacy openai-multi/ modelCosts keys into the merged row", () => { + const multiCosts = { + "openai-multi/gpt-5.6-sol": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }, + }; + const costs = { "gpt-4.1": { input: 3, output: 4, cacheRead: 0.2, cacheWrite: 0 } }; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + openai: { ...forward, modelCosts: costs }, + "openai-multi": { ...forward, modelCosts: multiCosts }, + }, + })); + // The prefixed legacy key resolves to the bare model id after canonicalization. + expect(result.config.providers.openai.modelCosts).toEqual({ + ...{ "gpt-5.6-sol": multiCosts["openai-multi/gpt-5.6-sol"] }, + ...costs, + }); + }); + + test("canonical openai modelCosts still wins after legacy key rewrite collision", () => { + const costs = { "gpt-5.6": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 } }; + const multiCosts = { + "openai-multi/gpt-5.6": { input: 9, output: 9, cacheRead: 0.9, cacheWrite: 0.9 }, + }; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + openai: { ...forward, modelCosts: costs }, + "openai-multi": { ...forward, modelCosts: multiCosts }, + }, + })); + expect(result.config.providers.openai.modelCosts).toEqual(costs); + }); + + test.each([ + ["bare first", { "gpt-5.6": 1, "openai-multi/gpt-5.6": 9 }], + ["prefixed first", { "openai-multi/gpt-5.6": 9, "gpt-5.6": 1 }], + ] as const)("bare modelCosts key wins inside one legacy row regardless of property order (%s)", (_label, raw) => { + const bare = { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }; + const prefixed = { input: 9, output: 9, cacheRead: 0.9, cacheWrite: 0.9 }; + const modelCosts = Object.fromEntries( + Object.entries(raw).map(([key, marker]) => [key, marker === 1 ? bare : prefixed]), + ) as Record; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + "openai-multi": { ...forward, modelCosts }, + }, + })); + expect(result.config.providers.openai.modelCosts).toEqual({ "gpt-5.6": bare }); + }); + + test("prototype-named modelCosts keys survive migration as own properties", () => { + // JSON.parse creates an own "__proto__" data property (an object literal + // would route through the inherited setter and not create one). + const overlay = JSON.parse( + '{"__proto__": {"input": 1, "output": 2, "cacheRead": 0.1, "cacheWrite": 0}}', + ) as Record; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + "openai-multi": { ...forward, modelCosts: overlay }, + }, + })); + const merged = result.config.providers.openai.modelCosts!; + expect(Object.hasOwn(merged, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(merged, "__proto__")?.value).toEqual(overlay["__proto__"]); + }); + + test("openai-multi/__proto__ key rewrites to an own __proto__ entry", () => { + const overlay = JSON.parse( + '{"openai-multi/__proto__": {"input": 3, "output": 4, "cacheRead": 0.2, "cacheWrite": 0}}', + ) as Record; + const result = projectOpenAiTierMigration(cfg({ + openaiProviderTierVersion: 1, + providers: { + "openai-multi": { ...forward, modelCosts: overlay }, + }, + })); + const merged = result.config.providers.openai.modelCosts!; + expect(Object.hasOwn(merged, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(merged, "__proto__")?.value).toEqual(overlay["openai-multi/__proto__"]); + }); + + test("legacy multi with an out-of-bound overlay rate collides", () => { + const input = cfg({ + openaiProviderTierVersion: 1, + providers: { + "openai-multi": { + ...forward, + modelCosts: { "gpt-5.6": { input: 1e308, output: 1, cacheRead: 0.1, cacheWrite: 0 } }, + }, + }, + }); + expect(() => projectOpenAiTierMigration(input)).toThrow(OpenAiTierMigrationCollisionError); + }); + + test("legacy multi with an extra field in a modelCosts row collides", () => { + const input = cfg({ + openaiProviderTierVersion: 1, + providers: { + "openai-multi": { + ...forward, + modelCosts: { "gpt-5.6": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0, apiKey: "sk-extra" } }, + }, + }, + }); + expect(() => projectOpenAiTierMigration(input)).toThrow(OpenAiTierMigrationCollisionError); + }); + test("merges provider context caps to the lower positive cap with path-only warning", () => { const result = projectOpenAiTierMigration(cfg({ openaiProviderTierVersion: 1, diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts new file mode 100644 index 0000000000..9a370c66b7 --- /dev/null +++ b/tests/provider-cost-overlay-config.test.ts @@ -0,0 +1,440 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getConfigPath, + loadConfig, + providerModelCostsConfigError, + saveConfig, + validateConfigCandidate, +} from "../src/config"; +import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; +import { activeUserCostOverlays, refreshUserCostOverlays, userCostOverlayVersion } from "../src/usage/user-cost-overlays"; +import type { OcxConfig } from "../src/types"; + +const VALID_COSTS = { + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 }, +}; + +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-model-costs-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + // The overlay registry is module-level; reset it so rows loaded by DTO tests + // cannot leak into other test files in a shared-process run. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("providerModelCostsConfigError", () => { + test("absent and valid modelCosts pass", () => { + expect(providerModelCostsConfigError(undefined)).toBeNull(); + expect(providerModelCostsConfigError(VALID_COSTS)).toBeNull(); + }); + + test("non-object or array value is rejected", () => { + expect(providerModelCostsConfigError("nope")).toContain("plain object"); + expect(providerModelCostsConfigError([{ input: 1 }])).toContain("plain object"); + }); + + test("blank model keys are rejected", () => { + expect(providerModelCostsConfigError({ "": { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 } })) + .toContain("nonblank"); + }); + + test("malformed entries are rejected with a field path", () => { + expect(providerModelCostsConfigError({ m: "not-an-object" })).toContain('modelCosts."m"'); + expect(providerModelCostsConfigError({ m: { input: 1, output: 1, cacheRead: 0 } })) + .toContain('modelCosts."m".cacheWrite'); + expect(providerModelCostsConfigError({ m: { input: -1, output: 1, cacheRead: 0, cacheWrite: 0 } })) + .toContain('modelCosts."m".input'); + expect(providerModelCostsConfigError({ m: { input: 1, output: Infinity, cacheRead: 0, cacheWrite: 0 } })) + .toContain('modelCosts."m".output'); + expect(providerModelCostsConfigError({ m: { input: 1, output: 1, cacheRead: 0, cacheWrite: "0" } })) + .toContain('modelCosts."m".cacheWrite'); + }); + + test("rates above the safe bound are rejected", () => { + const error = providerModelCostsConfigError({ + m: { input: 1e308, output: 1, cacheRead: 0, cacheWrite: 0 }, + }); + expect(error).toContain('modelCosts."m".input'); + expect(error).toContain("at most 1000000"); + // The boundary itself is accepted. + expect(providerModelCostsConfigError({ + m: { input: 1_000_000, output: 1, cacheRead: 0, cacheWrite: 0 }, + })).toBeNull(); + }); + + test("modelCosts validation errors redact secret-shaped model ids", () => { + const error = providerModelCostsConfigError({ + "sk-abcdef1234567890": { input: 1, output: 1, cacheRead: 0, cacheWrite: "0" }, + }); + expect(error).not.toContain("sk-abcdef1234567890"); + expect(error).toContain("[REDACTED]"); + }); + + test("modelCosts rows with extra fields are rejected by the validator", () => { + const error = providerModelCostsConfigError({ + m: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0, apiKey: "sk-leak" }, + }); + expect(error).toContain('modelCosts."m"'); + expect(error).toContain("unexpected fields"); + expect(error).toContain("apiKey"); + expect(error).not.toContain("sk-leak"); + expect(providerModelCostsConfigError(VALID_COSTS)).toBeNull(); + }); + + test("loadConfig drops modelCosts rows with extra fields instead of persisting them", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: { + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0, apiKey: "sk-leak" }, + }, + }, + }, + })); + const config = loadConfig(); + expect(config.providers.blsc.modelCosts).toEqual({ + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }); + expect(activeUserCostOverlays()).toHaveLength(1); + }); + + test("validateConfigCandidate redacts a token-shaped provider name in modelCosts schema errors", () => { + const result = validateConfigCandidate({ + port: 12345, + providers: { + "sk-abcdef1234567890": { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + modelCosts: { m: { input: -1, output: 1, cacheRead: 0, cacheWrite: 0 } }, + }, + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).not.toContain("sk-abcdef1234567890"); + expect(result.error).toContain("[REDACTED]"); + expect(result.error).toContain("modelCosts"); + } + }); +}); + +describe("modelCosts config persistence and registry refresh", () => { + test("loadConfig preserves modelCosts and refreshes the overlay registry", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: VALID_COSTS, + }, + }, + })); + const versionBefore = userCostOverlayVersion(); + const config = loadConfig(); + expect(config.providers.blsc.modelCosts).toEqual(VALID_COSTS); + expect(userCostOverlayVersion()).toBe(versionBefore + 1); + const rows = activeUserCostOverlays(); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + provider: "blsc", + modelId: "deepseek-v4-flash", + cost4: VALID_COSTS["deepseek-v4-flash"], + status: "verified", + }); + expect(rows[0].source).toBe("config:providers.blsc.modelCosts[deepseek-v4-flash]"); + }); + + test("saveConfig round-trips modelCosts and refreshes the registry", () => { + const config = loadConfig(); + config.providers.blsc = { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: VALID_COSTS, + }; + saveConfig(config); + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf-8")); + expect(onDisk.providers.blsc.modelCosts).toEqual(VALID_COSTS); + const reloaded = loadConfig(); + expect(reloaded.providers.blsc.modelCosts).toEqual(VALID_COSTS); + expect(activeUserCostOverlays()).toHaveLength(2); + // Removing the overlay clears the registry rows. + delete reloaded.providers.blsc.modelCosts; + saveConfig(reloaded); + expect(activeUserCostOverlays()).toHaveLength(0); + }); + + test("an unchanged save still refreshes the overlay registry (cooperating CLI write)", () => { + // Simulate a cooperating CLI process that wrote the overlay to disk without + // this process ever seeing it (the ocx login key-provider notify scenario): + // the bytes match, so persistConfigUnlocked's early-return path must still + // refresh the registry, otherwise Logs/Usage keep catalog prices. + const bytes = JSON.stringify({ + port: 12345, + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: VALID_COSTS, + }, + }, + }, null, 2) + "\n"; + writeFileSync(getConfigPath(), bytes); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + expect(activeUserCostOverlays()).toHaveLength(0); + + const config = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + const versionBefore = userCostOverlayVersion(); + saveConfig(config); + expect(activeUserCostOverlays()).toHaveLength(2); + expect(userCostOverlayVersion()).toBeGreaterThan(versionBefore); + }); + + test("reloading an unchanged config does not bump the overlay version", () => { + const config = { + port: 12345, + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: VALID_COSTS, + }, + }, + }; + writeFileSync(getConfigPath(), JSON.stringify(config)); + loadConfig(); + const versionAfterFirstLoad = userCostOverlayVersion(); + // Same bytes on disk: the load-time refresh must be a no-op for the version. + writeFileSync(getConfigPath(), JSON.stringify(config)); + loadConfig(); + expect(userCostOverlayVersion()).toBe(versionAfterFirstLoad); + expect(activeUserCostOverlays()).toHaveLength(2); + }); + + test("loadConfig degrades a malformed modelCosts row instead of falling back to defaults", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + defaultProvider: "blsc", + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: { + "deepseek-v4-flash": VALID_COSTS["deepseek-v4-flash"], + "broken-model": { input: "0.14", output: 0.28, cacheRead: 0, cacheWrite: 0 }, + }, + }, + }, + })); + + const config = loadConfig(); + // The provider and the valid row survive; only the malformed row is dropped. + expect(config.providers.blsc).toBeDefined(); + expect(config.providers.blsc.modelCosts).toEqual({ + "deepseek-v4-flash": VALID_COSTS["deepseek-v4-flash"], + }); + const rows = activeUserCostOverlays().map(row => row.modelId); + expect(rows).toContain("deepseek-v4-flash"); + expect(rows).not.toContain("broken-model"); + }); + + test("loadConfig drops a non-object modelCosts field without failing the parse", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + defaultProvider: "blsc", + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: "oops", + }, + }, + })); + + const config = loadConfig(); + expect(config.providers.blsc).toBeDefined(); + expect(config.providers.blsc.modelCosts).toBeUndefined(); + expect(activeUserCostOverlays()).toHaveLength(0); + }); + + test("overlay registry keeps only the four rate fields of a modelCosts row", () => { + refreshUserCostOverlays({ + providers: { + blsc: { + modelCosts: { + "deepseek-v4-flash": { + ...VALID_COSTS["deepseek-v4-flash"], + apiKey: "sekret-value", + }, + }, + }, + }, + } as unknown as OcxConfig); + + const rows = activeUserCostOverlays(); + expect(rows).toHaveLength(1); + expect(rows[0].cost4).toEqual(VALID_COSTS["deepseek-v4-flash"]); + expect(Object.keys(rows[0].cost4).sort()).toEqual(["cacheRead", "cacheWrite", "input", "output"]); + }); + + test("overlay registry skips rows whose rates exceed the safe bound", () => { + refreshUserCostOverlays({ + providers: { + blsc: { + modelCosts: { + "overflow-model": { input: 1e308, output: 1, cacheRead: 0, cacheWrite: 0 }, + "deepseek-v4-flash": VALID_COSTS["deepseek-v4-flash"], + }, + }, + }, + } as unknown as OcxConfig); + const rows = activeUserCostOverlays().map(row => row.modelId); + expect(rows).toContain("deepseek-v4-flash"); + expect(rows).not.toContain("overflow-model"); + }); +}); + +describe("modelCosts management validation and DTO", () => { + const providerBase = { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + }; + + test("providerManagementConfigError accepts valid modelCosts and rejects malformed ones", () => { + expect(providerManagementConfigError("blsc", { ...providerBase, modelCosts: VALID_COSTS })).toBeNull(); + const error = providerManagementConfigError("blsc", { + ...providerBase, + modelCosts: { "deepseek-v4-flash": { input: -0.5, output: 1, cacheRead: 0, cacheWrite: 0 } }, + }); + expect(error).toContain("blsc"); + expect(error).toContain('modelCosts."deepseek-v4-flash".input'); + }); + + test("providerManagementConfigError redacts a token-shaped provider name in modelCosts errors", () => { + const error = providerManagementConfigError("sk-abcdef1234567890", { + ...providerBase, + modelCosts: { m: { input: -1, output: 1, cacheRead: 0, cacheWrite: 0 } }, + }); + expect(error).not.toContain("sk-abcdef1234567890"); + expect(error).toContain("[REDACTED]"); + }); + + test("safeConfigDTO exposes modelCosts for the dashboard", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { blsc: { ...providerBase, modelCosts: VALID_COSTS } }, + })); + const dto = safeConfigDTO(loadConfig()) as { + providers: Record; + }; + expect(dto.providers.blsc.modelCosts).toEqual(VALID_COSTS); + }); + + test("a nested secret under a malformed modelCosts row never reaches the dashboard DTO", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { + blsc: { + ...providerBase, + modelCosts: { + "deepseek-v4-flash": { + input: 0.14, + output: 0.28, + cacheRead: 0.0028, + cacheWrite: 0, + apiKey: "sekret-value", + }, + }, + }, + }, + })); + const dto = safeConfigDTO(loadConfig()) as { + providers: Record> }>; + }; + // The malformed row (extra apiKey field) is rejected at load, so the DTO + // carries no overlay for it and the nested secret never serializes. + expect(dto.providers.blsc.modelCosts).toBeUndefined(); + expect(JSON.stringify(dto)).not.toContain("sekret-value"); + }); + + test("safeConfigDTO keeps a __proto__ model id as an own row", () => { + // JSON text (not an object literal) so "__proto__" is an own row key. + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { blsc: { ...providerBase, modelCosts: JSON.parse('{"__proto__":{"input":0.14,"output":0.28,"cacheRead":0.0028,"cacheWrite":0}}') } }, + })); + const dto = safeConfigDTO(loadConfig()) as { + providers: Record }>; + }; + const rows = dto.providers.blsc.modelCosts; + expect(rows && Object.keys(rows)).toContain("__proto__"); + expect(rows?.["__proto__"]).toEqual({ input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }); + }); + + test("safeConfigDTO drops secret-shaped model ids from modelCosts", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { + blsc: { + ...providerBase, + modelCosts: { + "deepseek-v4-flash": VALID_COSTS["deepseek-v4-flash"], + "sk-abcdef1234567890": VALID_COSTS["deepseek-v4-flash"], + }, + }, + }, + })); + const dto = safeConfigDTO(loadConfig()) as { + providers: Record }>; + }; + const keys = Object.keys(dto.providers.blsc.modelCosts ?? {}); + expect(keys).toContain("deepseek-v4-flash"); + expect(keys).not.toContain("sk-abcdef1234567890"); + // Dropped entirely — distinct secret-shaped rows must not collapse into a + // single placeholder key in the dashboard DTO. + expect(keys).not.toContain("[REDACTED]"); + }); + + test("safeConfigDTO drops modelCosts rows whose rates exceed the safe bound", () => { + // In-memory config, bypassing loadConfig: the DTO gate itself (validRate) + // must drop the out-of-bound row — load-time sanitization would remove it + // before safeConfigDTO ever sees it. + const config = { + port: 12345, + providers: { + blsc: { + ...providerBase, + modelCosts: { + "deepseek-v4-flash": VALID_COSTS["deepseek-v4-flash"], + "overflow-model": { input: 1e308, output: 1, cacheRead: 0, cacheWrite: 0 }, + }, + }, + }, + } as unknown as OcxConfig; + const dto = safeConfigDTO(config) as { + providers: Record }>; + }; + const keys = Object.keys(dto.providers.blsc.modelCosts ?? {}); + expect(keys).toContain("deepseek-v4-flash"); + expect(keys).not.toContain("overflow-model"); + }); +}); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index aad24abe76..d717f536d0 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { calculateCost, estimateAttemptCost, @@ -17,6 +17,12 @@ import { resolvePriorityMultiplier, type ExpectedPriceOverlay, } from "../src/usage/expected-prices"; +import { + activeUserCostOverlays, + refreshUserCostOverlays, + userCostOverlayVersion, +} from "../src/usage/user-cost-overlays"; +import type { OcxConfig } from "../src/types"; const RATE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }; @@ -683,3 +689,289 @@ describe("long-context pricing tiers (#908)", () => { } }); }); + +describe("provider cost overlay (user-configured)", () => { + const USER_PRICE = { input: 0.5, output: 2, cacheRead: 0.1, cacheWrite: 0.25 }; + const USER_ROWS: ExpectedPriceOverlay[] = [{ + provider: "deepseek", + modelId: "deepseek-chat", + cost4: USER_PRICE, + source: "config:providers.deepseek.modelCosts[deepseek-chat]", + verifiedAt: "user-configured", + status: "verified", + }]; + + afterEach(() => { + // The registry is module-level; reset it even when a test fails early so + // rows cannot leak into other files in a shared-process run. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + }); + + test("user overlay beats the jawcode price and reads verified (not estimated)", () => { + const price = resolveMatchedPrice("deepseek", "deepseek-chat", undefined, USER_ROWS); + expect(price).toMatchObject({ + provider: "deepseek", + modelId: "deepseek-chat", + cost4: USER_PRICE, + source: "user", + status: "verified", + }); + expect(price?.sourceRef).toBe("config:providers.deepseek.modelCosts[deepseek-chat]"); + expect(price?.verifiedAt).toBe("user-configured"); + const estimate = estimateRequestCost({ + provider: "deepseek", + model: "deepseek-chat", + usage: { inputTokens: 1_000_000, outputTokens: 500_000 }, + usageStatus: "reported", + }, undefined, USER_ROWS); + expect(estimate?.cost.total).toBeCloseTo(0.5 + 1.0, 9); + expect(estimate?.estimated).toBe(false); + expect(estimate?.price?.source).toBe("user"); + }); + + test("custom provider names resolve only via the user overlay", () => { + // Fabricated model id: absent from the jawcode catalog, so only the user + // overlay can price it (deepseek-v4-flash itself would resolve through the + // model-level vendor fallback). + expect(resolveMatchedPrice("blsc", "blsc-test-model")).toBeNull(); + const rows: ExpectedPriceOverlay[] = [{ + provider: "blsc", + modelId: "blsc-test-model", + cost4: USER_PRICE, + source: "config:providers.blsc.modelCosts[blsc-test-model]", + verifiedAt: "user-configured", + status: "verified", + }]; + const price = resolveMatchedPrice("blsc", "blsc-test-model", undefined, rows); + expect(price).toMatchObject({ provider: "blsc", modelId: "blsc-test-model", source: "user" }); + }); + + test("user overlay matches an exact provider name ending with an account-label suffix", () => { + refreshUserCostOverlays({ + providers: { + "blsc-pabcdef": { + modelCosts: { "custom-model": USER_PRICE }, + }, + }, + } as unknown as OcxConfig); + // "pabcdef" matches the Codex account-log-label pattern, so the base label + // collapses to "blsc"; the exact provider name must still win for its own + // configured overlay. + const price = resolveMatchedPrice("blsc-pabcdef", "custom-model"); + expect(price).toMatchObject({ + provider: "blsc-pabcdef", + modelId: "custom-model", + cost4: USER_PRICE, + source: "user", + status: "verified", + }); + expect(price?.sourceRef).toBe("config:providers.blsc-pabcdef.modelCosts[custom-model]"); + }); + + test("a configured provider with a label-shaped suffix never inherits the base provider's user overlay", () => { + refreshUserCostOverlays({ + providers: { + acme: { modelCosts: { "acme-custom-model": USER_PRICE } }, + "acme-pabcdef": { adapter: "openai-chat", baseUrl: "https://example.invalid" }, + }, + } as unknown as OcxConfig); + // The literal provider exists in config.providers, so its pricing namespace + // stays isolated even though the name matches the account-label pattern: + // it must NOT price through acme's user overlay. + expect(resolveMatchedPrice("acme-pabcdef", "acme-custom-model")).toBeNull(); + // The base provider itself still resolves through its own overlay. + expect(resolveMatchedPrice("acme", "acme-custom-model")).toMatchObject({ + provider: "acme", + modelId: "acme-custom-model", + cost4: USER_PRICE, + source: "user", + status: "verified", + }); + }); + + test("an all-zero overlay on a suffix-shaped configured provider falls through to compiled pricing, not the base provider's overlay", () => { + refreshUserCostOverlays({ + providers: { + acme: { modelCosts: { "claude-opus-4-6": USER_PRICE } }, + "acme-pabcdef": { + modelCosts: { "claude-opus-4-6": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + }, + }, + } as unknown as OcxConfig); + const price = resolveMatchedPrice("acme-pabcdef", "claude-opus-4-6"); + // The all-zero row falls through to compiled/catalog pricing — the + // documented fallback order — and never to acme's user-configured price. + expect(price).not.toBeNull(); + expect(price?.provider).toBe("acme-pabcdef"); + expect(price?.source).toBe("jawcode"); + expect(price?.cost4).not.toEqual(USER_PRICE); + // A real positive catalog price, without pinning the vendor's current + // rate (the catalog lives outside this PR and may change independently). + expect(price?.cost4?.input).toBeGreaterThan(0); + expect(price?.cost4?.output).toBeGreaterThan(0); + }); + + test("a generated account label (not a configured provider) still collapses to the base provider's overlay", () => { + refreshUserCostOverlays({ + providers: { + acme: { modelCosts: { "acme-custom-model": USER_PRICE } }, + }, + } as unknown as OcxConfig); + // acme-pabcdef is NOT in config.providers here — it is a generated log + // label for an acme account, so collapsing to acme's overlay is intended. + const price = resolveMatchedPrice("acme-pabcdef", "acme-custom-model"); + expect(price).toMatchObject({ + provider: "acme", + modelId: "acme-custom-model", + cost4: USER_PRICE, + source: "user", + status: "verified", + }); + }); + + test("configuring a provider invalidates its collapsed memo entry immediately", () => { + refreshUserCostOverlays({ + providers: { + acme: { modelCosts: { "acme-custom-model": USER_PRICE } }, + }, + } as unknown as OcxConfig); + // Not configured yet → generated label → collapses to acme (memoized). + expect(resolveMatchedPrice("acme-pabcdef", "acme-custom-model")?.source).toBe("user"); + // The provider is now configured (without an overlay): namespace isolation + // must apply immediately — the resolver memo cannot keep serving the stale + // collapsed entry, even though no overlay row changed. + refreshUserCostOverlays({ + providers: { + acme: { modelCosts: { "acme-custom-model": USER_PRICE } }, + "acme-pabcdef": { adapter: "openai-chat", baseUrl: "https://example.invalid" }, + }, + } as unknown as OcxConfig); + expect(resolveMatchedPrice("acme-pabcdef", "acme-custom-model")).toBeNull(); + }); + + test("all-zero user overlay falls through to the expected overlay price", () => { + const zero: ExpectedPriceOverlay[] = [{ + provider: "deepseek", + modelId: "deepseek-chat", + cost4: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + source: "config:providers.deepseek.modelCosts[deepseek-chat]", + verifiedAt: "user-configured", + status: "verified", + }]; + const price = resolveMatchedPrice("deepseek", "deepseek-chat", undefined, zero); + expect(price?.source).toBe("expected"); + // A real positive expected-overlay price, without pinning the current + // rate (the overlay table may change independently of this feature). + expect(price?.cost4.input).toBeGreaterThan(0); + }); + + test("combo fails closed when a user-priced attempt shares a combo with an unpriced one", () => { + const attempts = [ + { ordinal: 1, provider: "deepseek", model: "deepseek-chat", usageStatus: "reported" as const, usage: { inputTokens: 100, outputTokens: 10 } }, + { ordinal: 2, provider: "blsc", model: "unknown-model", usageStatus: "reported" as const, usage: { inputTokens: 100, outputTokens: 10 } }, + ]; + expect(estimateComboCost(attempts, undefined, undefined, USER_ROWS)).toBeNull(); + const priced = [attempts[0]]; + const combo = estimateComboCost(priced, undefined, undefined, USER_ROWS); + expect(combo?.attempts[0].price.source).toBe("user"); + expect(combo?.attempts[0].cost.total).toBeCloseTo((0.5 * 100 + 2 * 10) / 1e6, 12); + }); + + test("registry refresh replaces rows, bumps the version, and invalidates the memo", () => { + const before = activeUserCostOverlays(); + const versionBefore = userCostOverlayVersion(); + refreshUserCostOverlays({ + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://example.invalid", + modelCosts: { + "deepseek-v4-flash": USER_PRICE, + "overlay-test-model": USER_PRICE, + }, + }, + }, + } as unknown as OcxConfig); + expect(activeUserCostOverlays()).not.toBe(before); + expect(userCostOverlayVersion()).toBe(versionBefore + 1); + // Default lookup path (registry-backed, memoized) picks the configured price up. + const first = resolveMatchedPrice("blsc", "deepseek-v4-flash"); + expect(first).toMatchObject({ source: "user", cost4: USER_PRICE }); + expect(resolveMatchedPrice("blsc", "overlay-test-model")?.source).toBe("user"); + // A price change must not be served from the stale memo. + refreshUserCostOverlays({ + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://example.invalid", + modelCosts: { + "deepseek-v4-flash": { ...USER_PRICE, input: 0.99 }, + "overlay-test-model": { ...USER_PRICE, input: 0.99 }, + }, + }, + }, + } as unknown as OcxConfig); + const second = resolveMatchedPrice("blsc", "deepseek-v4-flash"); + expect(second?.cost4.input).toBe(0.99); + expect(resolveMatchedPrice("blsc", "overlay-test-model")?.cost4.input).toBe(0.99); + // Leave the registry empty for the rest of the file. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + expect(resolveMatchedPrice("blsc", "overlay-test-model")).toBeNull(); + // Without the overlay, deepseek-v4-flash falls back to its jawcode vendor price. + expect(resolveMatchedPrice("blsc", "deepseek-v4-flash")?.source).toBe("jawcode"); + }); + + test("refresh with identical rows is a no-op for the version and memo", () => { + const config = { + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://example.invalid", + modelCosts: { + "deepseek-v4-flash": USER_PRICE, + }, + }, + }, + } as unknown as OcxConfig; + refreshUserCostOverlays(config); + const versionAfterFirst = userCostOverlayVersion(); + const rowsAfterFirst = activeUserCostOverlays(); + // Config reloads (server start, persist paths) pass the same rows again; + // they must not churn the version or replace the active array identity. + refreshUserCostOverlays(config); + expect(userCostOverlayVersion()).toBe(versionAfterFirst); + expect(activeUserCostOverlays()).toBe(rowsAfterFirst); + expect(resolveMatchedPrice("blsc", "deepseek-v4-flash")?.source).toBe("user"); + // Leave the registry empty for the rest of the file. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + }); + + test("registry redacts token-shaped ids in display source but keeps raw matching and change detection", () => { + const configWith = (provider: string, model: string) => ({ + providers: { [provider]: { modelCosts: { [model]: USER_PRICE } } }, + }) as unknown as OcxConfig; + refreshUserCostOverlays(configWith("sk-provider-123", "sk-model-456")); + const rows = activeUserCostOverlays(); + expect(rows).toHaveLength(1); + // Matching fields stay raw so exact-name resolution still works. + expect(rows[0].provider).toBe("sk-provider-123"); + expect(rows[0].modelId).toBe("sk-model-456"); + // The display-only source redacts token-shaped ids. + expect(rows[0].source).not.toContain("sk-provider-123"); + expect(rows[0].source).not.toContain("sk-model-456"); + expect(rows[0].source).toContain("[REDACTED]"); + expect(resolveMatchedPrice("sk-provider-123", "sk-model-456")?.source).toBe("user"); + // Identical refresh stays a no-op. + const versionBefore = userCostOverlayVersion(); + refreshUserCostOverlays(configWith("sk-provider-123", "sk-model-456")); + expect(userCostOverlayVersion()).toBe(versionBefore); + // A DIFFERENT secret-shaped id with the same rates must still bump: the + // change-detection signature compares raw matching fields, not the redacted + // display strings (both would otherwise collapse to "[REDACTED]"). + refreshUserCostOverlays(configWith("sk-provider-789", "sk-model-456")); + expect(userCostOverlayVersion()).toBe(versionBefore + 1); + expect(activeUserCostOverlays()[0].provider).toBe("sk-provider-789"); + // Leave the registry empty for the rest of the file. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + }); +}); diff --git a/tests/user-cost-overlay-coderabbit-regressions.test.ts b/tests/user-cost-overlay-coderabbit-regressions.test.ts new file mode 100644 index 0000000000..00d7d18bb5 --- /dev/null +++ b/tests/user-cost-overlay-coderabbit-regressions.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + getConfigPath, + loadConfig, + readConfigDiagnostics, + saveConfig, +} from "../src/config"; +import type { OcxConfig } from "../src/types"; +import { resolveMatchedPrice } from "../src/usage/cost"; +import { + activeUserCostOverlays, + refreshUserCostOverlays, + resetPreservedDiskOnlyProvidersForTests, +} from "../src/usage/user-cost-overlays"; +import { + reconcileUserCostOverlaysFromDisk, + resetUserCostOverlayReconcilerForTests, + startUserCostOverlayReconciler, + stopUserCostOverlayReconciler, + userCostOverlayInvalidReconcileCountForTests, +} from "../src/usage/user-cost-overlay-reconciler"; + +const OVERLAY = { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }; +const BASE_CONFIG: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "acme", + providers: { + acme: { + adapter: "openai-chat", + baseUrl: "https://example.invalid", + apiKey: "sk-test", + models: ["model-x"], + }, + }, +} as OcxConfig; + +let testDir = ""; +let previousHome: string | undefined; + +function readDiskConfig(): OcxConfig { + return JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; +} + +function seedOverlay(): OcxConfig { + const config = loadConfig(); + config.providers.acme!.modelCosts = { "model-x": OVERLAY }; + saveConfig(config); + expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); + return config; +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await Bun.sleep(10); + } + throw new Error("timed out waiting for overlay reconciler observation"); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-overlay-review-")); + process.env.OPENCODEX_HOME = testDir; + writeFileSync(getConfigPath(), `${JSON.stringify(BASE_CONFIG, null, 2)}\n`, "utf8"); +}); + +afterEach(() => { + stopUserCostOverlayReconciler(); + resetUserCostOverlayReconcilerForTests(); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + resetPreservedDiskOnlyProvidersForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("CodeRabbit overlay integrity regressions", () => { + test("loadConfig keeps the last good overlays when an existing config becomes invalid", () => { + seedOverlay(); + expect(activeUserCostOverlays()).toHaveLength(1); + + writeFileSync(getConfigPath(), "{ not json", "utf8"); + const fallback = loadConfig(); + + expect(fallback.defaultProvider).toBe("openai"); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(activeUserCostOverlays()).toHaveLength(1); + expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); + }); + + test("loadConfig still clears stale overlays when config.json is genuinely missing", () => { + seedOverlay(); + expect(activeUserCostOverlays()).toHaveLength(1); + + unlinkSync(getConfigPath()); + const defaults = loadConfig(); + + expect(defaults.defaultProvider).toBe("openai"); + expect(activeUserCostOverlays()).toHaveLength(0); + expect(resolveMatchedPrice("acme", "model-x")?.source).not.toBe("user"); + }); + + test("one-shot reconciliation cannot drop preservation required by another registered owner", () => { + const liveConfigA = loadConfig(); + const ownerA = startUserCostOverlayReconciler({ intervalMs: 60_000, liveConfig: liveConfigA }); + + const edited = readDiskConfig(); + edited.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": OVERLAY }, + }; + writeFileSync(getConfigPath(), `${JSON.stringify(edited, null, 2)}\n`, "utf8"); + expect(reconcileUserCostOverlaysFromDisk()).toBe(true); + expect(liveConfigA.providers.beta).toBeUndefined(); + + const liveConfigB = loadConfig(); + expect(liveConfigB.providers.beta).toBeDefined(); + const ownerB = startUserCostOverlayReconciler({ intervalMs: 60_000, liveConfig: liveConfigB }); + + // The explicit liveConfig branch used to rebuild global preservation from B + // alone. Because B owns beta, that dropped beta even though registered A + // still lacked it, allowing A's next unrelated save to erase the provider. + expect(reconcileUserCostOverlaysFromDisk(liveConfigB)).toBe(true); + liveConfigA.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfigA); + + expect(readDiskConfig().providers.beta?.modelCosts).toEqual({ "beta-model": OVERLAY }); + + ownerB.stop(); + ownerA.stop(); + }); + + test("invalid transient config is observed by the poller before overlay retention is asserted", async () => { + const liveConfig = seedOverlay(); + startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); + const invalidReadsBefore = userCostOverlayInvalidReconcileCountForTests(); + + writeFileSync(getConfigPath(), "{ not json", "utf8"); + await waitUntil( + () => userCostOverlayInvalidReconcileCountForTests() > invalidReadsBefore, + ); + + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(activeUserCostOverlays()).toHaveLength(1); + expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); + }); +}); diff --git a/tests/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts new file mode 100644 index 0000000000..300b6ad923 --- /dev/null +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -0,0 +1,536 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { getConfigPath, loadConfig, saveConfig } from "../src/config"; +import { resolveMatchedPrice } from "../src/usage/cost"; +import { + activeUserCostOverlays, + refreshUserCostOverlays, + resetPreservedDiskOnlyProvidersForTests, + userCostOverlayVersion, + withPreservedDiskOnlyProviders, +} from "../src/usage/user-cost-overlays"; +import { + reconcileUserCostOverlaysFromDisk, + resetUserCostOverlayReconcilerForTests, + startUserCostOverlayReconciler, + stopUserCostOverlayReconciler, + userCostOverlayInvalidReconcileCountForTests, +} from "../src/usage/user-cost-overlay-reconciler"; +import type { OcxConfig } from "../src/types"; + +const repoRoot = resolve(import.meta.dir, ".."); + +const DISK_CONFIG: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "acme", + providers: { + acme: { + adapter: "openai-chat", + baseUrl: "https://example.invalid", + apiKey: "sk-test", + models: ["model-x"], + }, + }, +} as OcxConfig; + +const OVERLAY = { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }; + +let testDir = ""; +let previousHome: string | undefined; + +async function runChild(script: string): Promise<{ exitCode: number; stderr: string }> { + const child = Bun.spawn([process.execPath, "--eval", script], { + cwd: repoRoot, + env: process.env, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, , stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { exitCode, stderr }; +} + +async function waitForOverlayLive( + provider = "acme", + model = "model-x", + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const price = resolveMatchedPrice(provider, model); + if (price?.source === "user") return; + await Bun.sleep(20); + } + throw new Error("timed out waiting for the external overlay to become live"); +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await Bun.sleep(20); + } + throw new Error("timed out waiting for the reconciler to observe the config change"); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-overlay-live-")); + process.env.OPENCODEX_HOME = testDir; + writeFileSync(getConfigPath(), `${JSON.stringify(DISK_CONFIG, null, 2)}\n`, "utf8"); +}); + +afterEach(() => { + stopUserCostOverlayReconciler(); + resetUserCostOverlayReconcilerForTests(); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + resetPreservedDiskOnlyProvidersForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("cross-process user cost overlay reconciliation", () => { + test("a CLI-process saveConfig edit becomes live in the running server registry", async () => { + // "Server" state: the live config object plus the module-level overlay + // registry, with the reconciler polling the shared disk config. + const liveConfig = loadConfig(); + startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); + const versionBefore = userCostOverlayVersion(); + + // Separate writer process: exactly what `ocx config set` does — a fresh + // module instance calling saveConfig() under the same OPENCODEX_HOME. + const { exitCode, stderr } = await runChild(` + const { loadConfig, saveConfig } = await import("./src/config.ts"); + const config = loadConfig(); + config.providers.acme.modelCosts = { "model-x": ${JSON.stringify(OVERLAY)} }; + saveConfig(config); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + await waitForOverlayLive(); + + expect(userCostOverlayVersion()).toBeGreaterThan(versionBefore); + const price = resolveMatchedPrice("acme", "model-x"); + expect(price).toMatchObject({ + provider: "acme", + modelId: "model-x", + source: "user", + cost4: OVERLAY, + }); + expect(activeUserCostOverlays()).toHaveLength(1); + // The live config adopted the disk row, so an unrelated in-process save + // cannot erase the external edit. + expect(liveConfig.providers.acme?.modelCosts).toEqual({ "model-x": OVERLAY }); + saveConfig(liveConfig); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.acme?.modelCosts).toEqual({ "model-x": OVERLAY }); + }); + + test("a direct config.json edit becomes live without running saveConfig", async () => { + const liveConfig = loadConfig(); + startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); + const versionBefore = userCostOverlayVersion(); + + // Separate writer process: raw file edit, no ocx code at all. + const { exitCode, stderr } = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.acme.modelCosts = { "model-x": ${JSON.stringify(OVERLAY)} }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + await waitForOverlayLive(); + + expect(userCostOverlayVersion()).toBeGreaterThan(versionBefore); + expect(resolveMatchedPrice("acme", "model-x")).toMatchObject({ + provider: "acme", + modelId: "model-x", + source: "user", + cost4: OVERLAY, + }); + expect(liveConfig.providers.acme?.modelCosts).toEqual({ "model-x": OVERLAY }); + }); + + test("an invalid transient config edit does not wipe the active overlay registry", async () => { + const liveConfig = loadConfig(); + startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); + + // Make the overlay live first through the same cross-process path. + const seed = await runChild(` + const { loadConfig, saveConfig } = await import("./src/config.ts"); + const config = loadConfig(); + config.providers.acme.modelCosts = { "model-x": ${JSON.stringify(OVERLAY)} }; + saveConfig(config); + `); + expect(seed.exitCode).toBe(0); + expect(seed.stderr).toBe(""); + await waitForOverlayLive(); + + // A non-cooperating writer leaves a transient broken file; the reconciler + // must keep serving the last good overlay instead of falling back to + // defaults. + const invalidCountBefore = userCostOverlayInvalidReconcileCountForTests(); + writeFileSync(getConfigPath(), "{ not json", "utf8"); + await waitUntil(() => userCostOverlayInvalidReconcileCountForTests() > invalidCountBefore); + + expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); + }); + + test("an externally added provider survives an unrelated live-config save", async () => { + const liveConfig = loadConfig(); + startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); + + // Separate writer adds a brand-new provider (not present in the live + // config at boot) with its own overlay. + const { exitCode, stderr } = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": ${JSON.stringify(OVERLAY)} }, + }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + // Reconciler adopts the overlay from disk; beta is not added to live + // routing state but its overlay is active for display estimates. + await waitForOverlayLive("beta", "beta-model"); + expect(liveConfig.providers.beta).toBeUndefined(); + + // An unrelated live save (a different provider's models list) must not + // erase beta or its overlay. + liveConfig.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfig); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta?.modelCosts).toEqual({ "beta-model": OVERLAY }); + expect(resolveMatchedPrice("beta", "beta-model")?.source).toBe("user"); + }); + + test("a later owner's smaller poll interval is honored and the cadence relaxes when it stops", async () => { + const slowConfig = loadConfig(); + const slowOwner = startUserCostOverlayReconciler({ intervalMs: 500, liveConfig: slowConfig }); + const fastConfig = loadConfig(); + const fastOwner = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: fastConfig }); + + // External edit: with the 20ms owner active it must become live well + // inside the 500ms owner's cadence. + const { exitCode, stderr } = await runChild(` + const { loadConfig, saveConfig } = await import("./src/config.ts"); + const config = loadConfig(); + config.providers.acme.modelCosts = { "model-x": ${JSON.stringify(OVERLAY)} }; + saveConfig(config); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + // Deadline shorter than the slow owner's 500ms cadence: only the 20ms + // owner can satisfy it, so a syncReconcileTimer regression that ignores + // the later smaller interval fails here instead of passing silently. + await waitForOverlayLive("acme", "model-x", 200); + + // Remove the fast owner: a fresh edit must NOT appear before the slow + // cadence elapses, then must be observed once it does. The "not yet + // observed" half is intentionally not asserted with a fixed sleep: the + // child spawn itself can consume most of the 500ms window on a loaded + // machine, so only the positive observation is checked. + fastOwner.stop(); + const edit = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.acme.modelCosts = { "model-x": { input: 3, output: 4, cacheRead: 0.3, cacheWrite: 0 } }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(edit.exitCode).toBe(0); + expect(edit.stderr).toBe(""); + await waitUntil( + () => resolveMatchedPrice("acme", "model-x")?.cost4?.input === 3, + 2_000, + ); + + slowOwner.stop(); + }); + + test("overlay-only refresh without live config clears stale preservation so a deleted provider cannot resurrect", async () => { + const liveConfig = loadConfig(); + + // External writer adds beta; a one-shot refresh WITH a live config + // populates the preservation registry, the state a stopped or + // never-registered owner would leave behind. + const add = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": ${JSON.stringify(OVERLAY)} }, + }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(add.exitCode).toBe(0); + expect(reconcileUserCostOverlaysFromDisk(liveConfig)).toBe(true); + expect(withPreservedDiskOnlyProviders(liveConfig).providers.beta).toBeDefined(); + + // External writer deletes beta. + const del = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + delete raw.providers.beta; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(del.exitCode).toBe(0); + + // Overlay-only refresh (no live routing config) must clear the stale + // registry; otherwise the next saveConfig resurrects beta. + expect(reconcileUserCostOverlaysFromDisk()).toBe(true); + + liveConfig.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfig); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta).toBeUndefined(); + }); + + test("a stopped newer owner cannot leave an older owner able to erase a disk-only provider (A lacks beta -> B has beta -> B stops -> A saves -> beta survives)", async () => { + const liveConfigA = loadConfig(); + const ownerA = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigA }); + + // External writer adds beta while only A is running. + const { exitCode, stderr } = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": ${JSON.stringify(OVERLAY)} }, + }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + await waitForOverlayLive("beta", "beta-model"); + // A alone preserves beta as disk-only. + expect(liveConfigA.providers.beta).toBeUndefined(); + + // B starts later with beta already in its live config. + const liveConfigB = loadConfig(); + expect(liveConfigB.providers.beta).toBeDefined(); + const ownerB = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigB }); + + // Force a reconcile across both owners. B owns beta, but A still lacks + // it, so the preservation registry must keep beta for A's writes — an + // older live projection must never erase a provider another active owner + // owns. The disk rewrite below only bumps the file stamp to trigger a + // reconcile tick; it is a fresh read of the file, so nothing needs to be + // mutated in memory for it. + writeFileSync(getConfigPath(), `${JSON.stringify(loadConfig(), null, 2)}\n`, "utf8"); + // A's serialization view must still carry beta while both owners are + // alive (A lacks it in its live config; preservation protects it). + expect(withPreservedDiskOnlyProviders(liveConfigA).providers.beta).toBeDefined(); + + // B stops; A remains without beta in its live config. + ownerB.stop(); + + // An unrelated A save must keep beta on disk because A still treats it as + // disk-only after the preservation recompute on owner removal. + liveConfigA.providers.acme!.models = ["model-x", "model-extra", "model-y"]; + saveConfig(liveConfigA); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta?.modelCosts).toEqual({ "beta-model": OVERLAY }); + expect(resolveMatchedPrice("beta", "beta-model")?.source).toBe("user"); + + ownerA.stop(); + }); + + test("an unrelated save from an older owner cannot erase a provider owned by a newer active owner (A lacks beta -> B has beta -> both remain alive -> A saves -> beta survives)", async () => { + const liveConfigA = loadConfig(); + const ownerA = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigA }); + + // External writer adds beta while only A is running; A preserves it as a + // provider it does not own. + const { exitCode, stderr } = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": ${JSON.stringify(OVERLAY)} }, + }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + await waitForOverlayLive("beta", "beta-model"); + + // B starts with beta already in its live config and STAYS alive. + const liveConfigB = loadConfig(); + expect(liveConfigB.providers.beta).toBeDefined(); + const ownerB = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigB }); + + // A performs an unrelated save while B is still running. A's live config + // lacks beta, but the preservation registry must keep beta because a + // different active owner owns it — otherwise A's save deletes B's + // provider from disk. + liveConfigA.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfigA); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta?.modelCosts).toEqual({ "beta-model": OVERLAY }); + expect(resolveMatchedPrice("beta", "beta-model")?.source).toBe("user"); + + ownerA.stop(); + ownerB.stop(); + }); + + test("final owner stop clears preservation so a later save cannot resurrect a deleted provider", async () => { + const liveConfig = loadConfig(); + const owner = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); + + // External writer adds beta; preservation remembers it. + const { exitCode, stderr } = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": ${JSON.stringify(OVERLAY)} }, + }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + await waitForOverlayLive("beta", "beta-model"); + + // Final owner stops: preservation must be cleared. + owner.stop(); + + // External writer deletes beta. + const del = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + delete raw.providers.beta; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(del.exitCode).toBe(0); + + // An unrelated in-process save must NOT resurrect beta. + liveConfig.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfig); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta).toBeUndefined(); + }); + + test("process-wide stop clears preservation so a later save cannot resurrect a deleted provider", async () => { + const liveConfig = loadConfig(); + startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); + + const { exitCode, stderr } = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": ${JSON.stringify(OVERLAY)} }, + }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + await waitForOverlayLive("beta", "beta-model"); + + stopUserCostOverlayReconciler(); + + const del = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + delete raw.providers.beta; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(del.exitCode).toBe(0); + + liveConfig.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfig); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta).toBeUndefined(); + }); + + test("overlay-only refresh without a live config does not preserve every disk provider", async () => { + const liveConfig = loadConfig(); + + // External writer adds beta. + const { exitCode, stderr } = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + raw.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": ${JSON.stringify(OVERLAY)} }, + }; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + + // Overlay-only refresh (no live routing config) must not populate the + // preservation registry with every disk provider. + expect(reconcileUserCostOverlaysFromDisk()).toBe(true); + + // External writer deletes beta. + const del = await runChild(` + import { readFileSync, writeFileSync } from "node:fs"; + import { join } from "node:path"; + const path = join(process.env.OPENCODEX_HOME, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")); + delete raw.providers.beta; + writeFileSync(path, JSON.stringify(raw, null, 2) + "\\n", "utf8"); + `); + expect(del.exitCode).toBe(0); + + // An unrelated in-process save must NOT resurrect beta. + liveConfig.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfig); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta).toBeUndefined(); + }); +}); diff --git a/tests/user-cost-overlay-provider-delete.test.ts b/tests/user-cost-overlay-provider-delete.test.ts new file mode 100644 index 0000000000..ad0b1b0bbb --- /dev/null +++ b/tests/user-cost-overlay-provider-delete.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + getConfigPath, + loadConfig, + saveConfig, + saveConfigPreservingClaudeCode, +} from "../src/config"; +import type { OcxConfig } from "../src/types"; +import { resolveMatchedPrice } from "../src/usage/cost"; +import { + activeUserCostOverlays, + refreshUserCostOverlays, + resetPreservedDiskOnlyProvidersForTests, + userCostOverlayVersion, + withPreservedDiskOnlyProviders, +} from "../src/usage/user-cost-overlays"; +import { + reconcileUserCostOverlaysFromDisk, + resetUserCostOverlayReconcilerForTests, + startUserCostOverlayReconciler, + stopUserCostOverlayReconciler, +} from "../src/usage/user-cost-overlay-reconciler"; + +const OVERLAY = { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0 }; + +const DISK_CONFIG: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "acme", + providers: { + acme: { + adapter: "openai-chat", + baseUrl: "https://example.invalid", + apiKey: "sk-test", + models: ["model-x"], + }, + }, +} as OcxConfig; + +let testDir = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-overlay-delete-")); + process.env.OPENCODEX_HOME = testDir; + writeFileSync(getConfigPath(), `${JSON.stringify(DISK_CONFIG, null, 2)}\n`, "utf8"); +}); + +afterEach(() => { + stopUserCostOverlayReconciler(); + resetUserCostOverlayReconcilerForTests(); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + resetPreservedDiskOnlyProvidersForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); + testDir = ""; +}); + +describe("provider deletion with disk-only preservation", () => { + test("an owner that previously owned a provider can delete it without stale owners resurrecting it", () => { + // A booted before beta existed and therefore must preserve beta on unrelated saves. + const liveConfigA = loadConfig(); + const ownerA = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigA }); + + const externallyEdited = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + externallyEdited.providers.beta = { + adapter: "openai-chat", + baseUrl: "https://beta.example.invalid", + apiKey: "sk-beta", + modelCosts: { "beta-model": OVERLAY }, + }; + writeFileSync(getConfigPath(), `${JSON.stringify(externallyEdited, null, 2)}\n`, "utf8"); + expect(reconcileUserCostOverlaysFromDisk()).toBe(true); + expect(liveConfigA.providers.beta).toBeUndefined(); + expect(withPreservedDiskOnlyProviders(liveConfigA).providers.beta).toBeDefined(); + + // B and C start later from disk and genuinely own beta in their live projections. + const liveConfigB = loadConfig(); + const ownerB = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigB }); + const liveConfigC = loadConfig(); + const ownerC = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigC }); + expect(liveConfigB.providers.beta).toBeDefined(); + expect(liveConfigC.providers.beta).toBeDefined(); + expect(reconcileUserCostOverlaysFromDisk()).toBe(true); + + // The old-owner protection still works: A's unrelated save must keep beta. + liveConfigA.providers.acme!.models = ["model-x", "model-extra"]; + saveConfig(liveConfigA); + let persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta?.modelCosts).toEqual({ "beta-model": OVERLAY }); + expect(resolveMatchedPrice("beta", "beta-model")?.source).toBe("user"); + + // This mirrors DELETE /api/providers: B owned beta, then intentionally removes it + // and uses the live-config save wrapper. The real route has an await import after + // deleting the row, so force a reconcile in that gap to prove ownership is retained + // until disk confirms the deletion. Preservation must still not put beta back. + const versionBeforeDelete = userCostOverlayVersion(); + delete liveConfigB.providers.beta; + expect(reconcileUserCostOverlaysFromDisk()).toBe(true); + saveConfigPreservingClaudeCode(liveConfigB); + persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta).toBeUndefined(); + expect(activeUserCostOverlays().some(row => row.provider === "beta")).toBe(false); + expect(resolveMatchedPrice("beta", "beta-model")?.source).not.toBe("user"); + expect(userCostOverlayVersion()).toBeGreaterThan(versionBeforeDelete); + + // Successful deletion converges every active owner so a third, older projection + // that still had beta cannot recreate it on a later unrelated save. + expect(liveConfigC.providers.beta).toBeUndefined(); + liveConfigC.providers.acme!.models = ["model-x", "model-c"]; + saveConfig(liveConfigC); + persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta).toBeUndefined(); + + ownerC.stop(); + ownerB.stop(); + ownerA.stop(); + }); +}); diff --git a/tests/zz-ci-api-usage-isolation.test.ts b/tests/zz-ci-api-usage-isolation.test.ts new file mode 100644 index 0000000000..d3dee352ae --- /dev/null +++ b/tests/zz-ci-api-usage-isolation.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; + +type Step = { + name?: string; + run?: string; +}; + +type Job = { + "runs-on"?: string; + "timeout-minutes"?: number; + needs?: string[]; + steps?: Step[]; +}; + +test("Linux shards isolate api-usage into its own gated job", async () => { + const text = await Bun.file( + new URL("../.github/workflows/ci.yml", import.meta.url), + ).text(); + const workflow = Bun.YAML.parse(text) as { + jobs?: Record; + }; + + const shardRun = workflow.jobs?.test?.steps?.find(step => step.name === "Test")?.run ?? ""; + expect(shardRun).toContain( + "--path-ignore-patterns 'tests/api-usage.test.ts'", + ); + + const apiUsageJob = workflow.jobs?.["api-usage"]; + expect(apiUsageJob?.["runs-on"]).toBe("ubuntu-latest"); + expect(apiUsageJob?.["timeout-minutes"]).toBe(5); + + const apiUsageRun = apiUsageJob?.steps?.find( + step => step.name === "Test api usage API", + )?.run ?? ""; + expect(apiUsageRun).toBe("bun test --isolate ./tests/api-usage.test.ts"); + expect(apiUsageRun).not.toContain("--shard"); + + expect(workflow.jobs?.ci?.needs).toContain("api-usage"); +});