From 680590d2e3b512073798c86d658ee7d1e3b8a617 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 00:35:30 +0800 Subject: [PATCH 01/49] feat(usage): user-configurable per-model cost overlay Add providers..modelCosts (model -> input/output/cacheRead/ cacheWrite in USD per 1M tokens) so operators can price internal/custom providers whose ids do not match the compiled catalogs, or whose actual costs vary from list prices. - modelCosts rows win over the jawcode catalog and the expected-price overlay in resolveMatchedPrice (exact provider/model match; all-zero entries fall through to the catalogs). Follows ocx's flat per-model config convention (models, modelContextWindows, ...). - Rows are lifted from config at loadConfig and every persist path into a versioned registry; the estimator memo keys on that version so edits apply immediately and stale rows are never served. - New CostResult reason provider_cost_overlay is surfaced in the GUI logs detail with i18n strings. - config.json validation accepts only non-negative finite 4-tuples; the management API rejects malformed overlays; safeConfigDTO exposes the field to the dashboard. - Also replace a stray NUL byte in the price-memo cache-key template literal with a space separator. - Tests cover precedence, fall-through, registry refresh/memo invalidation, config round-trip, management validation, and DTO passthrough. --- .../docs/reference/configuration/providers.md | 1 + gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 7 +- src/config.ts | 53 ++++++- src/server/auth-cors.ts | 4 + src/server/management/shared.ts | 8 +- src/types.ts | 21 +++ src/usage/cost.ts | Bin 18705 -> 20093 bytes src/usage/user-cost-overlays.ts | 66 +++++++++ tests/provider-cost-overlay-config.test.ts | 134 ++++++++++++++++++ tests/usage-cost.test.ts | 127 +++++++++++++++++ 16 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 src/usage/user-cost-overlays.ts create mode 100644 tests/provider-cost-overlay-config.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index dd5ca0b52b..951ae005a3 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 exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. User-configured prices win over the built-in catalogs in the Logs `~$` estimate; display-time estimation only, never billing. An all-zero entry falls through to the catalogs. | | `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/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a53882bef9..ae9a0a3a81 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -649,6 +649,7 @@ export const de: Record = { "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 provider-konfigurierter 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..25a73e5f47 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -676,6 +676,7 @@ export const en = { "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..6f486484cc 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -634,6 +634,7 @@ export const ja: Record = { "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..f912807a40 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -668,6 +668,7 @@ export const ko: Record = { "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..69aaa26da1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -666,6 +666,7 @@ export const ru: Record = { "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/zh.ts b/gui/src/i18n/zh.ts index 2ae8b6ac2c..f98d412767 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -661,6 +661,7 @@ export const zh: Record = { "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..30397c99a7 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 } @@ -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; /** diff --git a/src/config.ts b/src/config.ts index 12d855fa07..616af40f80 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,6 +74,7 @@ import { import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; +import { refreshUserCostOverlays } from "./usage/user-cost-overlays"; import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, @@ -704,6 +705,32 @@ 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`; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return `${field}.${modelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`; + } + const rates = entry as Record; + for (const key of ["input", "output", "cacheRead", "cacheWrite"]) { + const rate = rates[key]; + if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0) { + return `${field}.${modelId}.${key} must be a non-negative finite number (USD per 1M tokens)`; + } + } + } + return null; +} + /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */ export function apiKeyTransportConfigError( provider: Pick, @@ -1232,6 +1259,14 @@ const configSchema = z.object({ message: headersError, }); } + const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts); + if (modelCostsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", name, "modelCosts"], + message: modelCostsError, + }); + } const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig); if (apiKeyTransportError) { ctx.addIssue({ @@ -1811,7 +1846,7 @@ 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/, ""); @@ -1828,7 +1863,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,17 +1885,22 @@ 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); - return getDefaultConfig(); + return withRefreshedCostOverlays(getDefaultConfig()); } catch (error) { warnAndBackupInvalidConfig(configPath, error); - return getDefaultConfig(); + return withRefreshedCostOverlays(getDefaultConfig()); } } +function withRefreshedCostOverlays(config: OcxConfig): OcxConfig { + refreshUserCostOverlays(config); + return config; +} + export type ConfigDiagnostics = { config: OcxConfig; source: "default" | "file" | "fallback"; @@ -2388,6 +2428,9 @@ function persistConfigUnlocked(config: OcxConfig): boolean { if (!isMissingPathError(error)) throw error; } atomicWriteFile(configPath, bytes); + // Keep the runtime overlay registry in sync with every persist path + // (saveConfig and mutatePersistedConfig both funnel through here). + refreshUserCostOverlays(config); return true; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a59bfba5f1..4d69c2e840 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -10,6 +10,7 @@ import { positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, + providerModelCostsConfigError, reasoningSummaryDeliveryRecordConfigError, retryOn429PolicyConfigError, } from "../config"; @@ -471,6 +472,8 @@ 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) return `provider ${name} ${modelCostsError}`; const apiKeyTransportError = apiKeyTransportConfigError(typed); if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`; const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens"); @@ -567,6 +570,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelContextWindows", "defaultMaxOutputTokens", "modelMaxOutputTokens", + "modelCosts", "openRouterRouting", "modelOpenRouterRouting", "reasoningEfforts", diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 5880b03400..f0a4984249 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -82,7 +82,11 @@ export type TokPerSecondResult = | { kind: "value"; value: number; estimated: boolean } | { kind: "unavailable"; reason: MetricUnavailableReason }; -export type CostEstimateReason = "usage_estimated" | "cache_detail_missing" | "expected_price_overlay"; +export type CostEstimateReason = + | "usage_estimated" + | "cache_detail_missing" + | "expected_price_overlay" + | "provider_cost_overlay"; export type CostResult = | { kind: "value"; estimate: NonNullable>; estimateReasons: CostEstimateReason[] } @@ -136,6 +140,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/types.ts b/src/types.ts index b2b0c6d9e8..faee58fd78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1128,6 +1128,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. @@ -1250,6 +1262,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 266cb4eb4418c1184aac5a22aa0760ff1f6f8aa5..1b499a6be7a21e8d75a5a37df915d1b481d5d5aa 100644 GIT binary patch delta 1283 zcma)5&ubGw6eb#N4JZ_C5|XsO#?mBhet57dkpznMBs3tlq8C{=v&qQrPMDb_ZM7Eh zpokZpgC0EElOV!^B8bJaH+vJI;L(%*0R?AvH|bUy=$>ZZ``)kbd++&+(9Y}7`=_C~ zHbbfGH<1l@wXRO;2y9}nB1$9{>&ST%Dj&KE zn$B?tEy`?{3F9>{SO%*60m~Ai3=FnwB$gKEW2J<&gUn*L zO|uAdrde=Dq@>Hlm!u_n$eNb8g2UhSsk)5$MEfVN=rT&&EKb z2pHEm)-Oc>N+mbluDgLaXs~eQh6GZ{zLdh|n6JPLNJ?Gm?zF8IND7fOtjG$A6rjkU zh&A(GPDa2=B8%WvSz3(%wn9ULsM;nH;Y7XwQ&Zk7(j$dtP^nY~$e7KEvx(Hy{#s^p zYcP4QNB0_trNqY}PxzWRn(X%9o#bYKzO2|BKO>e>m7d>umf8sQe#G?n%m2V*>yoX- zgRHAdXP+Nm0bP@mlSWysvd0o>A*yjgGYrsj%fve7-bw?OI<oXVzURk z?fFPp)ewJ&PYDX6&ad$g@qkJ*$w3#9kH`(Gj*52|zV#K}nzx+oi#y?9f`KO`epI$k b_)F#QtMjQ^J95zA36Yt1dZnGM`D*wd58A;p delta 339 zcmex6hjHR0#tmJJQdx=R$@wX%3VHc?RjEb!3I#=($*BtEnR&&VPcvFEZH{4i!m`HNotu;_8$4iz|WI55@JB z6m;zr@=9}Z^b{O(a&*BK!Hg@)FE0jZ*OD;h0lK0@K`E^$HC0J-@&|pf$t@Din}a3m z8CCqtQj2mDD~lBpi&7PeOA<>mlYx#b$;ix8NCdh$KRLCycyf-kj+&-IHJ1VqB!<<~pFqvIu`{Xk+1(UsGcTDC{l%DJ-=e&8o+&o4`&B^mb zBqpaQltYEtHajS9lbGye^Ki18SK8+3wtrbBKXAIc`Lwe?E07^Sxz%&?W;ZW$764{x Bb+7;c diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts new file mode 100644 index 0000000000..02058dbac8 --- /dev/null +++ b/src/usage/user-cost-overlays.ts @@ -0,0 +1,66 @@ +/** + * 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 two config chokepoints + * (loadConfig and every persist path). A refresh replaces the active array with + * a NEW identity and bumps a version counter, so the estimator's memo skips + * stale rows without cross-module invalidation. + * + * Display-time estimation only — these rows never affect billing. + */ +import type { OcxConfig, ProviderCostOverlay } from "../types"; +import type { ExpectedPriceOverlay } from "./expected-prices"; + +const EMPTY: readonly ExpectedPriceOverlay[] = []; + +let active: readonly ExpectedPriceOverlay[] = EMPTY; +let version = 0; + +function validCost4(value: unknown): value is ProviderCostOverlay { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const entry = value as Record; + return (["input", "output", "cacheRead", "cacheWrite"] as const) + .every(key => typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0); +} + +/** + * 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 { + 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, + cost4: { ...cost4 }, + source: `config:providers.${providerName}.modelCosts[${modelId}]`, + verifiedAt: "user-configured", + status: "verified", + }); + } + } + } + active = rows; + 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; +} diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts new file mode 100644 index 0000000000..b010c58f4e --- /dev/null +++ b/tests/provider-cost-overlay-config.test.ts @@ -0,0 +1,134 @@ +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, +} from "../src/config"; +import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; +import { activeUserCostOverlays, userCostOverlayVersion } from "../src/usage/user-cost-overlays"; + +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 = ""; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-model-costs-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + delete process.env.OPENCODEX_HOME; + 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"); + }); +}); + +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); + }); +}); + +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("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); + }); +}); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index aad24abe76..42261b478e 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -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,124 @@ 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", + }]; + + 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("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"); + expect(price?.cost4.input).toBe(0.27); + }); + + 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"); + }); +}); From 70206f5a6df8653bbb603b2e9f76558a9a3dd725 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 00:57:15 +0800 Subject: [PATCH 02/49] docs(gui): docstring coverage and review fixes for modelCosts - Add JSDoc to the functions touched by this PR (loadConfig, persistConfigUnlocked, saveConfig, withRefreshedCostOverlays, safeConfigDTO, costResult, resolveMatchedPriceInner/Exact, estimateAttemptCost, validCost4, Logs key helpers) to satisfy the docstring-coverage gate. - GUI: MatchedPriceInfo.source accepts "user"; add logs.detail.source.user to all six locales so user-priced rows render a label instead of a raw key. - de.ts: fix provider_cost_overlay grammar (Ein vom Anbieter konfiguriertes Preis-Overlay ...). - safeConfigDTO: serialize only the four rate fields of modelCosts rows so extra hand-edited fields cannot leak to the dashboard; regression test added. - Docs: document the complete fallback order (user modelCosts -> jawcode catalog -> expected-price overlay -> vendor fallback) with all-zero fall-through in the providers reference, and add the modelCosts row to the ja/ko/ru/zh-cn provider pages. --- .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + gui/src/i18n/de.ts | 3 +- gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 4 ++- src/config.ts | 14 ++++++++ src/server/auth-cors.ts | 34 +++++++++++++++++-- src/server/management/shared.ts | 1 + src/usage/cost.ts | 15 ++++++++ src/usage/user-cost-overlays.ts | 1 + tests/provider-cost-overlay-config.test.ts | 27 +++++++++++++++ 18 files changed, 105 insertions(+), 5 deletions(-) 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..bce42119d1 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 をキーにします。組み込みカタログにないカスタム・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` 見積もりで組み込みカタログより優先されます(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、請求には影響しません。 | | `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..b729f2e4a7 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를 키로 사용합니다. 내장 카탈로그에 없는 커스텀·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 추정에서 내장 카탈로그보다 우선합니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 청구에는 영향을 주지 않습니다. | | `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 951ae005a3..2dd15595fa 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -83,7 +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 exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. User-configured prices win over the built-in catalogs in the Logs `~$` estimate; display-time estimation only, never billing. An all-zero entry falls through to the catalogs. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom 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 `~$` estimate; 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. Display-time estimation only, never 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..5e797c57be 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 токенов), ключ — точный id модели. Любой id допустим — кастомные/внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценке `~$` в Logs (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения, не биллинг. | | `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..348c6ef419 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 为键。任何模型 ID 都是有效键——即使内置于内置目录中不存在,自定义/内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 估算中优先于内置目录(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算,绝不涉及计费。 | | `headers?` | `Record` | 额外的上游请求头。会拒绝 Authorization、cookie、API key 头、嵌入换行符以及无效名称。 | | `openRouterRouting?` | `OpenRouterProviderRouting` | 默认的 OpenRouter `order`、`only` 和 `allowFallbacks` 偏好;仅对使用 `openai-chat` 的规范 OpenRouter 有效。 | | `modelOpenRouterRouting?` | `Record` | 精确模型 id 级别的覆盖项,会替换提供者级 OpenRouter 偏好。 | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index ae9a0a3a81..a61b381bf3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -624,6 +624,7 @@ export const de: Record = { "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", @@ -649,7 +650,7 @@ export const de: Record = { "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 provider-konfigurierter Preis-Overlay 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 25a73e5f47..7ce96b2f3d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -651,6 +651,7 @@ export const en = { "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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6f486484cc..271ab4763c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -609,6 +609,7 @@ export const ja: Record = { "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": "プロバイダー / モデル", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f912807a40..481f9cd176 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -643,6 +643,7 @@ export const ko: Record = { "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": "프로바이더 / 모델", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 69aaa26da1..56e0c84d23 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -641,6 +641,7 @@ export const ru: Record = { "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": "Провайдер / модель", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f98d412767..06b9bdbd04 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -636,6 +636,7 @@ export const zh: Record = { "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": "提供方 / 模型", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 30397c99a7..4d7ff2adc5 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -61,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"; @@ -323,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/config.ts b/src/config.ts index 616af40f80..5b5698241d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1839,6 +1839,12 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig) } } +/** + * Load and validate config.json into an OcxConfig. Missing or broken files fall + * back to defaults (invalid files are backed up first); a partially-invalid + * config is merged with defaults so providers and pool accounts survive. Also + * refreshes the user cost-overlay registry from the resulting config. + */ export function loadConfig(): OcxConfig { const dir = getConfigDir(); const configPath = getConfigPath(); @@ -1896,6 +1902,7 @@ 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; @@ -2419,6 +2426,12 @@ 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"; @@ -2434,6 +2447,7 @@ function persistConfigUnlocked(config: OcxConfig): boolean { 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()); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4d69c2e840..d9ddbf5f43 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -18,7 +18,7 @@ import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; -import type { OcxConfig, OcxProviderConfig } from "../types"; +import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; @@ -547,6 +547,35 @@ export function copyIfDefined( if (value !== undefined) out[key as string] = value as unknown; } +/** True when `value` is a non-negative finite USD-per-1M-token rate. */ +function validRate(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +/** + * Serialize `providers..modelCosts` for the dashboard, copying ONLY the + * four numeric rate fields per model. Extra hand-edited fields (which the load + * validator ignores) must never reach the client, so secrets accidentally + * nested under a cost row cannot leak through the DTO. + */ +function sanitizeModelCosts(costs: unknown): Record | undefined { + if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined; + const out: 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 (validRate(input) && validRate(output) && validRate(cacheRead) && validRate(cacheWrite)) { + out[modelId] = { input, output, cacheRead, cacheWrite }; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** 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)) { @@ -570,7 +599,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelContextWindows", "defaultMaxOutputTokens", "modelMaxOutputTokens", - "modelCosts", "openRouterRouting", "modelOpenRouterRouting", "reasoningEfforts", @@ -588,6 +616,8 @@ export function safeConfigDTO(config: OcxConfig): unknown { ] as const) { copyIfDefined(dto, provider, key); } + const modelCosts = sanitizeModelCosts(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/management/shared.ts b/src/server/management/shared.ts index f0a4984249..57a443294b 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -127,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 diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 1b499a6be7..8cfc955df4 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -193,6 +193,11 @@ export function resolveMatchedPrice( const priceMemo = new Map(); +/** + * Resolution wrapper: exact provider/model lookup first, then the Antigravity + * base-model fallback for collapsed wire ids. Never falls through to the + * cross-provider vendor price at this level. + */ function resolveMatchedPriceInner( provider: string, modelId: string, @@ -210,6 +215,11 @@ function resolveMatchedPriceInner( return null; } +/** + * Exact provider/model price lookup: user-configured `modelCosts` first, then + * the jawcode provider bundle, then the expected-price overlay, then the + * model-level vendor fallback. All-zero rows fall through ("not billable"). + */ function resolveMatchedPriceExact( provider: string, modelId: string, @@ -389,6 +399,11 @@ function applyPriorityMultiplier( }, multiplier]; } +/** + * Per-attempt cost estimate: tokens normalized, price resolved (user overlay → + * catalogs), priority/long-context tiers applied. Null when usage or price is + * missing so combos can fail closed. + */ export function estimateAttemptCost( attempt: Pick, overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index 02058dbac8..760d334757 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -19,6 +19,7 @@ const EMPTY: readonly ExpectedPriceOverlay[] = []; let active: readonly ExpectedPriceOverlay[] = EMPTY; let version = 0; +/** 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; diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index b010c58f4e..c50f98427c 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -131,4 +131,31 @@ describe("modelCosts management validation and DTO", () => { }; expect(dto.providers.blsc.modelCosts).toEqual(VALID_COSTS); }); + + test("safeConfigDTO serializes only the four rate fields of each modelCosts row", () => { + 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> }>; + }; + expect(dto.providers.blsc.modelCosts).toEqual({ + "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, + }); + expect(dto.providers.blsc.modelCosts?.["deepseek-v4-flash"]?.apiKey).toBeUndefined(); + }); }); From c17366f1f83299d023d859df40b33bf97bcb0961 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 01:06:04 +0800 Subject: [PATCH 03/49] docs(i18n): document Cost4 fields in localized pages, align ru terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ja/ko/ru/zh-cn provider references now list the four Cost4 rate fields (input, output, cacheRead, cacheWrite) with a JSON example. - ru.ts: logs.detail.estimate.provider_cost_overlay reuses the same "ценовой оверлей провайдера" term as logs.detail.source.user. --- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- .../src/content/docs/zh-cn/reference/configuration/providers.md | 2 +- gui/src/i18n/ru.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 bce42119d1..c6a68426e7 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -73,7 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにします。組み込みカタログにないカスタム・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` 見積もりで組み込みカタログより優先されます(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、請求には影響しません。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` 見積もりで組み込みカタログより優先されます(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、請求には影響しません。 | | `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 b729f2e4a7..fa66c07d99 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -73,7 +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를 키로 사용합니다. 내장 카탈로그에 없는 커스텀·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 추정에서 내장 카탈로그보다 우선합니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 청구에는 영향을 주지 않습니다. | +| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 정확한 모델 ID를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 추정에서 내장 카탈로그보다 우선합니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 청구에는 영향을 주지 않습니다. | | `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/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 5e797c57be..0df2bbb3e9 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -86,7 +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 токенов), ключ — точный id модели. Любой id допустим — кастомные/внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценке `~$` в Logs (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения, не биллинг. | +| `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные/внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценке `~$` в Logs (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения, не биллинг. | | `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 348c6ef419..39455d1f1d 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,7 +73,7 @@ selector,而不是分配一个新名称。 | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | -| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键。任何模型 ID 都是有效键——即使内置于内置目录中不存在,自定义/内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 估算中优先于内置目录(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算,绝不涉及计费。 | +| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使内置于内置目录中不存在,自定义/内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 估算中优先于内置目录(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算,绝不涉及计费。 | | `headers?` | `Record` | 额外的上游请求头。会拒绝 Authorization、cookie、API key 头、嵌入换行符以及无效名称。 | | `openRouterRouting?` | `OpenRouterProviderRouting` | 默认的 OpenRouter `order`、`only` 和 `allowFallbacks` 偏好;仅对使用 `openai-chat` 的规范 OpenRouter 有效。 | | `modelOpenRouterRouting?` | `Record` | 精确模型 id 级别的覆盖项,会替换提供者级 OpenRouter 偏好。 | diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 56e0c84d23..ffa0f8aab6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -667,7 +667,7 @@ export const ru: Record = { "logs.detail.estimate.usage_estimated": "Данные об использовании от провайдера — оценочные.", "logs.detail.estimate.cache_detail_missing": "Детализация кэша недоступна; входные токены оценены по верхней границе.", "logs.detail.estimate.expected_price_overlay": "Использована подтверждённая ожидаемая цена из прайс-листа.", - "logs.detail.estimate.provider_cost_overlay": "Использована настроенная пользователем цена провайдера.", + "logs.detail.estimate.provider_cost_overlay": "Использован ценовой оверлей провайдера.", "logs.col.error": "Ошибка", "logs.col.upstreamReason": "Причина от провайдера", "logs.col.duration": "Длительность", From 5f486b0d5286ec4c66e8d2d70b744c965c82d3fe Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 18:55:28 +0800 Subject: [PATCH 04/49] =?UTF-8?q?fix(usage):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20=5F=5Fproto=5F=5F=20modelCosts=20row=20and=20registry=20test?= =?UTF-8?q?=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sanitizeModelCosts builds the DTO map with a null prototype so a model id literally named __proto__ stays an own row instead of mutating the map's prototype and vanishing from Object.keys (CodeRabbit minor). - usage-cost and provider-cost-overlay-config tests reset the module-level overlay registry in afterEach so rows cannot leak across test files in a shared-process run (CodeRabbit stability). - Regression test: safeConfigDTO keeps a __proto__ model id as an own row. --- src/server/auth-cors.ts | 4 +++- tests/provider-cost-overlay-config.test.ts | 19 ++++++++++++++++++- tests/usage-cost.test.ts | 8 +++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d9ddbf5f43..8ae51d1a69 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -560,7 +560,9 @@ function validRate(value: unknown): value is number { */ function sanitizeModelCosts(costs: unknown): Record | undefined { if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined; - const out: Record = {}; + // Null prototype so a model id like "__proto__" becomes an own row instead + // of mutating the map's prototype and vanishing from Object.keys(). + const out: Record = 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; diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index c50f98427c..a7d6be8cf4 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -9,7 +9,7 @@ import { saveConfig, } from "../src/config"; import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; -import { activeUserCostOverlays, userCostOverlayVersion } from "../src/usage/user-cost-overlays"; +import { activeUserCostOverlays, refreshUserCostOverlays, userCostOverlayVersion } from "../src/usage/user-cost-overlays"; const VALID_COSTS = { "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, @@ -24,6 +24,9 @@ beforeEach(() => { }); 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); delete process.env.OPENCODEX_HOME; if (testDir && existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); testDir = ""; @@ -158,4 +161,18 @@ describe("modelCosts management validation and DTO", () => { }); expect(dto.providers.blsc.modelCosts?.["deepseek-v4-flash"]?.apiKey).toBeUndefined(); }); + + 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 }); + }); }); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 42261b478e..ecd631d080 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, @@ -701,6 +701,12 @@ describe("provider cost overlay (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({ From 00cfe0b947f2c9d0891201ee8a5d0a38e4d328b0 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 19:43:06 +0800 Subject: [PATCH 05/49] docs(usage): screenshot of the provider-configured price overlay in the logs detail --- .../provider-cost-overlay-logs-detail.png | Bin 0 -> 90385 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/provider-cost-overlay-logs-detail.png 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 0000000000000000000000000000000000000000..1b7be458e0309d8e576b2582b01f318498c86e46 GIT binary patch literal 90385 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 literal 0 HcmV?d00001 From cd956e8cea3c6f091509fca2132565cb320bcb37 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 21:06:27 +0800 Subject: [PATCH 06/49] fix(usage): sanitize cost-overlay rows on load and in the registry Codex review findings: - refreshUserCostOverlays now copies ONLY the four validated rate fields into the overlay row, so a hand-edited row carrying extra properties (e.g. a misplaced apiKey) can no longer leak through /api/logs display estimates (P1). - loadConfig/configDiagnosticsFromRaw run sanitizeModelCostsForLoad before schema validation: malformed display-price rows are dropped with a warning instead of failing the whole parse and falling back to defaults, which previously discarded otherwise valid providers. Strict rejection stays at the management/write boundary (P2). Regression tests: malformed row degradation, non-object modelCosts drop, and registry rows containing only the four rate fields. --- src/config.ts | 50 ++++++++++++++++ src/usage/user-cost-overlays.ts | 10 +++- tests/provider-cost-overlay-config.test.ts | 66 ++++++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 5b5698241d..93ffb8f18a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1585,6 +1585,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 — @@ -1858,6 +1906,7 @@ export function loadConfig(): OcxConfig { 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); @@ -2143,6 +2192,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); diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index 760d334757..4514171f34 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -44,7 +44,15 @@ export function refreshUserCostOverlays(config: OcxConfig): void { rows.push({ provider: providerName, modelId, - cost4: { ...cost4 }, + // 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, + }, source: `config:providers.${providerName}.modelCosts[${modelId}]`, verifiedAt: "user-configured", status: "verified", diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index a7d6be8cf4..8150c77811 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -106,6 +106,72 @@ describe("modelCosts config persistence and registry refresh", () => { saveConfig(reloaded); expect(activeUserCostOverlays()).toHaveLength(0); }); + + 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"]); + }); }); describe("modelCosts management validation and DTO", () => { From 05d5d9725268141852c02487bf18c039505c6d5c Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 21:38:49 +0800 Subject: [PATCH 07/49] fix(usage): try the exact provider name before collapsing user overlays A custom provider whose name ends with the Codex account-log-label suffix pattern (e.g. blsc-pabcdef) had its provider collapsed by baseProviderLabel before the user-overlay lookup, so providers..modelCosts rows stored under the exact name never matched and estimates stayed unpriced. resolveMatchedPrice now checks the exact provider against the user overlay first, then collapses for the compiled catalogs. --- src/usage/cost.ts | 44 ++++++++++++++++++++++++++++------------ tests/usage-cost.test.ts | 22 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/usage/cost.ts b/src/usage/cost.ts index 8cfc955df4..f3334daec7 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -173,8 +173,17 @@ export function resolveMatchedPrice( overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(), ): MatchedPrice | null { + // User-configured overlays are keyed by the EXACT configured provider name. + // Try them before collapsing pool/account log suffixes: a custom provider can + // legitimately end with a label-shaped suffix (e.g. blsc-pabcdef), and its own + // overlay would otherwise never match the collapsed base name. + const collapsed = baseProviderLabel(provider); + if (collapsed !== provider) { + const exactUserOverlay = userOverlayMatch(provider, modelId, userOverlays); + if (exactUserOverlay) return exactUserOverlay; + } // Pool/account log suffixes (e.g. google-antigravity-p442fff) must collapse before overlay lookup. - provider = baseProviderLabel(provider); + provider = collapsed; // Memoize by (provider, model): usage summaries iterate hundreds of thousands of // rows that share a handful of provider/model keys, so resolving each time would // dominate /api/usage latency (WP6 audit). The compiled overlays are static; @@ -228,18 +237,8 @@ function resolveMatchedPriceExact( ): MatchedPrice | null { // User-configured provider overlay wins over every compiled catalog: the // operator's explicit price is authoritative for the ~$ estimate. - const userOverlay = findExpectedPriceOverlay(provider, modelId, userOverlays); - if (userOverlay && validCost4(userOverlay.cost4) && hasNonZeroCost(userOverlay.cost4)) { - return { - provider, - modelId, - cost4: userOverlay.cost4, - source: "user", - sourceRef: userOverlay.source, - verifiedAt: userOverlay.verifiedAt, - status: "verified", - }; - } + const userOverlay = userOverlayMatch(provider, modelId, userOverlays); + if (userOverlay) return userOverlay; const metadataProvider = resolveMetadataProvider(provider); const bundled = metadataProvider ? getModelMetadata(metadataProvider, modelId) @@ -271,6 +270,25 @@ function resolveMatchedPriceExact( }; } +/** User-configured overlay match (all-zero rows fall through like any other source). */ +function userOverlayMatch( + provider: string, + modelId: string, + userOverlays: readonly ExpectedPriceOverlay[], +): MatchedPrice | null { + const overlay = findExpectedPriceOverlay(provider, modelId, userOverlays); + if (!overlay || !validCost4(overlay.cost4) || !hasNonZeroCost(overlay.cost4)) return null; + return { + provider, + modelId, + cost4: overlay.cost4, + source: "user", + sourceRef: overlay.source, + verifiedAt: overlay.verifiedAt, + status: "verified", + }; +} + function resolveModelLevelPrice(provider: string, modelId: string): MatchedPrice | null { // Exact first; then dot->dash variant for providers that spell vendor ids with // dots where the catalog uses dashes (kiro "claude-opus-4.6" vs anthropic diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index ecd631d080..9654b2b8d7 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -746,6 +746,28 @@ describe("provider cost overlay (user-configured)", () => { 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("all-zero user overlay falls through to the expected overlay price", () => { const zero: ExpectedPriceOverlay[] = [{ provider: "deepseek", From 16eba407be5bdbe615634120c4ee1bebb41b8324 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 22:43:28 +0800 Subject: [PATCH 08/49] fix(usage): redact modelCosts keys and bind the usage cache to overlay changes - providerModelCostsConfigError now JSON-quotes and redacts secret-shaped model ids so a malformed write cannot echo a pasted key/secret back through the management API. - /api/usage summaries are cached against userCostOverlayVersion(): an overlay save invalidates the entry even when the usage log is unchanged. - refreshUserCostOverlays is a no-op when the extracted rows are byte-identical, so reloads of an unchanged config (server start, migrations, persist paths) no longer churn the version, invalidate the summary cache, or thash the cost memo. - tests: redaction + quoted-key assertions, cache-invalidation test, and an identical-refresh no-op regression test. --- src/config.ts | 8 +++-- src/server/management/logs-usage-routes.ts | 7 ++++- src/server/management/usage-summary-cache.ts | 2 ++ src/usage/user-cost-overlays.ts | 20 ++++++++++--- tests/api-usage.test.ts | 31 ++++++++++++++++++++ tests/provider-cost-overlay-config.test.ts | 20 +++++++++---- tests/usage-cost.test.ts | 25 ++++++++++++++++ 7 files changed, 100 insertions(+), 13 deletions(-) diff --git a/src/config.ts b/src/config.ts index 93ffb8f18a..f0c7736ae9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -717,14 +717,18 @@ export function providerModelCostsConfigError(value: unknown, field = "modelCost } 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}.${modelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`; + 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 ["input", "output", "cacheRead", "cacheWrite"]) { const rate = rates[key]; if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0) { - return `${field}.${modelId}.${key} must be a non-negative finite number (USD per 1M tokens)`; + return `${field}.${safeModelId}.${key} must be a non-negative finite number (USD per 1M tokens)`; } } } diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 6501e5636c..2b60bfd1a7 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,7 +194,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { } }); + test("usage route cache invalidates when the user cost overlay version changes", async () => { + writeFixture(Date.now()); + const server = startServer(0); + try { + // Start from a known overlay version so a leftover entry from an earlier + // test cannot satisfy the first request. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + 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 { + 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/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index 8150c77811..c55377847d 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -49,15 +49,23 @@ describe("providerModelCostsConfigError", () => { }); test("malformed entries are rejected with a field path", () => { - expect(providerModelCostsConfigError({ m: "not-an-object" })).toContain("modelCosts.m"); + expect(providerModelCostsConfigError({ m: "not-an-object" })).toContain('modelCosts."m"'); expect(providerModelCostsConfigError({ m: { input: 1, output: 1, cacheRead: 0 } })) - .toContain("modelCosts.m.cacheWrite"); + .toContain('modelCosts."m".cacheWrite'); expect(providerModelCostsConfigError({ m: { input: -1, output: 1, cacheRead: 0, cacheWrite: 0 } })) - .toContain("modelCosts.m.input"); + .toContain('modelCosts."m".input'); expect(providerModelCostsConfigError({ m: { input: 1, output: Infinity, cacheRead: 0, cacheWrite: 0 } })) - .toContain("modelCosts.m.output"); + .toContain('modelCosts."m".output'); expect(providerModelCostsConfigError({ m: { input: 1, output: 1, cacheRead: 0, cacheWrite: "0" } })) - .toContain("modelCosts.m.cacheWrite"); + .toContain('modelCosts."m".cacheWrite'); + }); + + 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]"); }); }); @@ -187,7 +195,7 @@ describe("modelCosts management validation and DTO", () => { 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"); + expect(error).toContain('modelCosts."deepseek-v4-flash".input'); }); test("safeConfigDTO exposes modelCosts for the dashboard", () => { diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 9654b2b8d7..87063599a8 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -837,4 +837,29 @@ describe("provider cost overlay (user-configured)", () => { // 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); + }); }); From e5dcd00bfd17f62ebc32b348b0b868cadaf5a6b4 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 22:53:17 +0800 Subject: [PATCH 09/49] test(usage): clear the overlay registry in the cache-invalidation test finally block The test installs a module-level blsc overlay; reset it to empty before stopping the server so a later test (or an assertion/shutdown failure) cannot resolve user-configured prices unexpectedly. --- tests/api-usage.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index 52ce08e6b6..1836713dc5 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -181,6 +181,10 @@ describe("GET /api/usage", () => { 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); } }); From d141779d3c6c23a681aa68ad687e4a2134076269 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 23:15:32 +0800 Subject: [PATCH 10/49] fix(usage): redact provider names in modelCosts management errors providerManagementConfigError echoed the caller-controlled provider name verbatim in the modelCosts error path even though the route has not yet validated/sanitized it. JSON-quote and redact the name (same rule as the retryOn429 branch) so a token-shaped provider name cannot serialize back through the management API. Regression test added. --- src/server/auth-cors.ts | 6 +++++- tests/provider-cost-overlay-config.test.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 8ae51d1a69..5f11d6d4de 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -473,7 +473,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown): return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`; } const modelCostsError = providerModelCostsConfigError(raw.modelCosts); - if (modelCostsError) return `provider ${name} ${modelCostsError}`; + 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"); diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index c55377847d..4a5fb94b25 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -198,6 +198,15 @@ describe("modelCosts management validation and DTO", () => { 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, From 2fef5ce1b988e969101df8bd9be766117f85a298 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Thu, 6 Aug 2026 19:31:12 +0800 Subject: [PATCH 11/49] fix(usage): preserve modelCosts on provider overwrite and Alibaba migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/providers POST now carries an existing provider's modelCosts when the overwrite payload omits it, so the dashboard's add/edit form cannot silently erase hand-edited per-model price overlays (same rule as the apiKeyPool carry-over). - The Alibaba region startup migration carries modelCosts in USER_OWNED_FIELDS so an intl migration does not drop user price overlays. - providers.md (en/ja/ko/ru/zh-cn) now states that custom, local OpenAI-compatible, and internal providers support exact model-id overlays and that overlays affect display-time estimates only — never routing, account selection, quotas, or billing. zh-cn wording fixed. - ko/zh i18n align provider terminology with logs.col.provider. - Regression tests for both carry-over paths; OcxConfig type import added. --- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- gui/src/i18n/ko.ts | 4 +-- gui/src/i18n/zh.ts | 4 +-- src/providers/alibaba-region-migration.ts | 2 +- src/server/management/provider-routes.ts | 5 +++ tests/alibaba-region-migration.test.ts | 6 ++-- tests/management-provider-validation.test.ts | 36 +++++++++++++++++++ tests/provider-cost-overlay-config.test.ts | 1 + 12 files changed, 56 insertions(+), 12 deletions(-) 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 c6a68426e7..fedf26134d 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -73,7 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` 見積もりで組み込みカタログより優先されます(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、請求には影響しません。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` 見積もりで組み込みカタログより優先されます(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、ルーティング・アカウント選択・割り当て・請求には影響しません。 | | `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 fa66c07d99..dfafad3342 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -73,7 +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를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 추정에서 내장 카탈로그보다 우선합니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 청구에는 영향을 주지 않습니다. | +| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 정확한 모델 ID를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·로컬 OpenAI 호환·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 추정에서 내장 카탈로그보다 우선합니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | | `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 2dd15595fa..c3869b7c54 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -83,7 +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 exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom 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 `~$` estimate; 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. Display-time estimation only, never billing. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom, local OpenAI-compatible, and 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 `~$` estimate; 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. 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 0df2bbb3e9..fe6f01bbd0 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -86,7 +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 токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные/внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценке `~$` в Logs (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения, не биллинг. | +| `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные, локальные OpenAI-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценке `~$` в Logs (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | | `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 39455d1f1d..c79a3085de 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,7 +73,7 @@ selector,而不是分配一个新名称。 | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | -| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使内置于内置目录中不存在,自定义/内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 估算中优先于内置目录(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算,绝不涉及计费。 | +| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使不存在于内置目录中,自定义、本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 估算中优先于内置目录(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | | `headers?` | `Record` | 额外的上游请求头。会拒绝 Authorization、cookie、API key 头、嵌入换行符以及无效名称。 | | `openRouterRouting?` | `OpenRouterProviderRouting` | 默认的 OpenRouter `order`、`only` 和 `allowFallbacks` 偏好;仅对使用 `openai-chat` 的规范 OpenRouter 有效。 | | `modelOpenRouterRouting?` | `Record` | 精确模型 id 级别的覆盖项,会替换提供者级 OpenRouter 偏好。 | diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 481f9cd176..5f2b4b8974 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -643,7 +643,7 @@ export const ko: Record = { "logs.detail.copied": "복사됨", "logs.detail.source.jawcode": "jawcode 카탈로그", "logs.detail.source.expected": "expected 가격 오버레이", - "logs.detail.source.user": "공급자 구성 가격 오버레이", + "logs.detail.source.user": "프로바이더 구성 가격 오버레이", "logs.detail.verification.verified": "검증됨", "logs.detail.verification.derived": "기반 모델 유도", "logs.detail.attempt.target": "프로바이더 / 모델", @@ -669,7 +669,7 @@ export const ko: Record = { "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.detail.estimate.provider_cost_overlay": "프로바이더 구성 가격 오버레이를 사용했습니다.", "logs.col.error": "오류", "logs.col.upstreamReason": "업스트림 원인", "logs.col.duration": "소요 시간", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 06b9bdbd04..f4244ecab8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -636,7 +636,7 @@ export const zh: Record = { "logs.detail.copied": "已复制", "logs.detail.source.jawcode": "jawcode 目录", "logs.detail.source.expected": "Expected 价格覆盖", - "logs.detail.source.user": "提供商自定义价格覆盖", + "logs.detail.source.user": "提供方自定义价格覆盖", "logs.detail.verification.verified": "已验证", "logs.detail.verification.derived": "由基础模型推导", "logs.detail.attempt.target": "提供方 / 模型", @@ -662,7 +662,7 @@ export const zh: Record = { "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.detail.estimate.provider_cost_overlay": "使用了提供方自定义的价格覆盖。", "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", 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/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 1fb4d5895c..9fcc074988 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -340,6 +340,11 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { 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/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 81ab8133be..42bc9f5787 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -325,6 +325,42 @@ 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 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/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index 4a5fb94b25..65b04d94e2 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -10,6 +10,7 @@ import { } 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 }, From 95397f6bc1b238e5ddb7f0603643019caf203dad Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Thu, 6 Aug 2026 19:49:48 +0800 Subject: [PATCH 12/49] fix(usage): redact provider names in modelCosts schema errors validateConfigCandidate (ocx config validate/import) serializes the issue path as providers..modelCosts; a token-shaped provider key could leak through the CLI error even though model ids inside the message are redacted. The schema issue path now runs the provider name through redactSecretString. Regression test in tests/provider-cost-overlay-config.test.ts. --- src/config.ts | 4 +++- tests/provider-cost-overlay-config.test.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index f0c7736ae9..f9e865bbef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1267,7 +1267,9 @@ const configSchema = z.object({ if (modelCostsError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelCosts"], + // 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, }); } diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index 65b04d94e2..4580839e18 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -7,6 +7,7 @@ import { 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"; @@ -68,6 +69,25 @@ describe("providerModelCostsConfigError", () => { expect(error).not.toContain("sk-abcdef1234567890"); expect(error).toContain("[REDACTED]"); }); + + 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", () => { From 079551802b77e1a272a6e32e688b80611c6addde Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Thu, 6 Aug 2026 22:22:19 +0800 Subject: [PATCH 13/49] fix(usage): carry modelCosts through OpenAI tier migration; redact registry provenance - mergeLegacyOpenAiProviderRows now carries modelCosts from the openai and openai-multi rows, and managedLegacyMultiOverlay accepts modelCosts as a managed overlay field, so a v1/restored config with user price overlays no longer drops them (or collides) during the tier migration. - refreshUserCostOverlays redacts token-shaped provider/model ids in the display-only source string and in the refresh signature, so raw pasted-key identifiers never live in registry metadata or cache keys; matching still uses the raw fields. - Regression tests for both. --- src/providers/openai-tiers.ts | 9 +++++-- src/usage/user-cost-overlays.ts | 13 ++++++++-- .../openai-provider-option-migration.test.ts | 24 +++++++++++++++++ tests/usage-cost.test.ts | 26 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 4ad1ba7078..7bd68edeb1 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -69,8 +69,10 @@ 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; @@ -81,6 +83,7 @@ function managedLegacyMultiOverlay(provider: OcxProviderConfig): Pick row !== undefined); const disabled = formerRows.length > 0 && formerRows.every(row => row.disabled === true); return { ...canonicalCodexForwardProvider(mode), ...(disabled ? { disabled: true } : {}), ...(selectedModels && selectedModels.length > 0 ? { selectedModels } : {}), + ...(modelCosts ? { modelCosts } : {}), }; } diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index 1e53fd421f..f88ad4cf7d 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -16,6 +16,7 @@ */ import type { OcxConfig, ProviderCostOverlay } from "../types"; import type { ExpectedPriceOverlay } from "./expected-prices"; +import { redactSecretString } from "../lib/redact"; const EMPTY: readonly ExpectedPriceOverlay[] = []; @@ -57,7 +58,10 @@ export function refreshUserCostOverlays(config: OcxConfig): void { cacheRead: cost4.cacheRead, cacheWrite: cost4.cacheWrite, }, - source: `config:providers.${providerName}.modelCosts[${modelId}]`, + // Display provenance only — redact token-shaped provider/model ids so + // neither the registry rows nor the refresh signature below can echo + // a pasted key or account id. Matching still uses the raw fields. + source: `config:providers.${redactSecretString(providerName)}.modelCosts[${redactSecretString(modelId)}]`, verifiedAt: "user-configured", status: "verified", }); @@ -69,7 +73,12 @@ export function refreshUserCostOverlays(config: OcxConfig): void { // 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. - const signature = JSON.stringify(rows); + const signature = JSON.stringify(rows.map(row => ({ + provider: redactSecretString(row.provider), + modelId: redactSecretString(row.modelId), + cost4: row.cost4, + source: row.source, + }))); if (signature === activeSignature) return; activeSignature = signature; active = rows; diff --git a/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index cb10642ae3..0a8b4a59b7 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -169,6 +169,30 @@ 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 }, + }, + })); + 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("merges provider context caps to the lower positive cap with path-only warning", () => { const result = projectOpenAiTierMigration(cfg({ openaiProviderTierVersion: 1, diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 87063599a8..21a88bbe98 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -862,4 +862,30 @@ describe("provider cost overlay (user-configured)", () => { // Leave the registry empty for the rest of the file. refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); }); + + test("registry redacts token-shaped provider/model ids in source and signature, keeping matching raw", () => { + const config = { + providers: { + "sk-provider-123": { + modelCosts: { "sk-model-456": USER_PRICE }, + }, + }, + } as unknown as OcxConfig; + refreshUserCostOverlays(config); + 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"); + 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"); + // The redacted projection keeps the no-op signature stable under refresh. + const versionBefore = userCostOverlayVersion(); + refreshUserCostOverlays(config); + expect(userCostOverlayVersion()).toBe(versionBefore); + // Leave the registry empty for the rest of the file. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + }); }); From 39021e3d4fdd915be2c0fd43c953aebe73557038 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Thu, 6 Aug 2026 23:03:14 +0800 Subject: [PATCH 14/49] fix(usage): harden overlay redaction, canonical-openai validation, and legacy merge Review round 8b230c84 findings: - safeConfigDTO redacts secret-shaped model ids in modelCosts keys so the dashboard DTO cannot echo a pasted key (validation errors already redacted). - The overlay refresh signature compares RAW matching fields again: two distinct secret-shaped ids must not collapse to the same redacted signature and skip the version bump (process-local signature; only the display source stays redacted). - providerManagementConfigError tolerates modelCosts on the canonical openai seed (validated separately), so management clients can add overlays. - mergeLegacyOpenAiProviderRows merges disjoint modelCosts from both legacy rows instead of nullish-picking one map and dropping the other. - Tests for every case, including a distinct-id change-detection regression. --- src/providers/openai-tiers.ts | 4 ++- src/server/auth-cors.ts | 7 +++++- src/usage/user-cost-overlays.ts | 12 ++++----- tests/management-provider-validation.test.ts | 24 ++++++++++++++++++ .../openai-provider-option-migration.test.ts | 14 +++++++++++ tests/provider-cost-overlay-config.test.ts | 22 ++++++++++++++++ tests/usage-cost.test.ts | 25 +++++++++++-------- 7 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 7bd68edeb1..9638858222 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -107,7 +107,9 @@ function mergeLegacyOpenAiProviderRows( ...(openai?.selectedModels ?? []), ...(legacyMulti?.selectedModels ?? []), ]); - const modelCosts = openai?.modelCosts ?? legacyMulti?.modelCosts; + // Both rows can carry disjoint overlays; merge them (canonical openai wins on + // key conflicts) so legacy Multi prices are not silently dropped. + const modelCosts = { ...(legacyMulti?.modelCosts ?? {}), ...(openai?.modelCosts ?? {}) }; const formerRows = [openai, legacyMulti].filter((row): row is OcxProviderConfig => row !== undefined); const disabled = formerRows.length > 0 && formerRows.every(row => row.disabled === true); return { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5f11d6d4de..94b75d10c3 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -448,6 +448,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`; @@ -575,7 +578,9 @@ function sanitizeModelCosts(costs: unknown): Record const cacheRead = rates.cacheRead; const cacheWrite = rates.cacheWrite; if (validRate(input) && validRate(output) && validRate(cacheRead) && validRate(cacheWrite)) { - out[modelId] = { input, output, cacheRead, cacheWrite }; + // The DTO is served to the dashboard; a model id shaped like a pasted key + // must not be echoed back (validation errors already redact these). + out[redactSecretString(modelId)] = { input, output, cacheRead, cacheWrite }; } } return Object.keys(out).length > 0 ? out : undefined; diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index f88ad4cf7d..1050f733c9 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -73,12 +73,12 @@ export function refreshUserCostOverlays(config: OcxConfig): void { // 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. - const signature = JSON.stringify(rows.map(row => ({ - provider: redactSecretString(row.provider), - modelId: redactSecretString(row.modelId), - cost4: row.cost4, - source: row.source, - }))); + // 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. + const signature = JSON.stringify(rows); if (signature === activeSignature) return; activeSignature = signature; active = rows; diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 42bc9f5787..e3594e3a54 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -361,6 +361,30 @@ describe("provider management validation", () => { } }); + 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/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index 0a8b4a59b7..7f362bed87 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -179,6 +179,20 @@ describe("OpenAI provider option migration matrix", () => { "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); }); diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index 4580839e18..202d8fb261 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -279,4 +279,26 @@ describe("modelCosts management validation and DTO", () => { expect(rows && Object.keys(rows)).toContain("__proto__"); expect(rows?.["__proto__"]).toEqual({ input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }); }); + + test("safeConfigDTO redacts secret-shaped model ids in 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"); + expect(keys).toContain("[REDACTED]"); + }); }); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 21a88bbe98..89a5b8acaa 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -863,28 +863,31 @@ describe("provider cost overlay (user-configured)", () => { refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); }); - test("registry redacts token-shaped provider/model ids in source and signature, keeping matching raw", () => { - const config = { - providers: { - "sk-provider-123": { - modelCosts: { "sk-model-456": USER_PRICE }, - }, - }, - } as unknown as OcxConfig; - refreshUserCostOverlays(config); + 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"); - // The redacted projection keeps the no-op signature stable under refresh. + // Identical refresh stays a no-op. const versionBefore = userCostOverlayVersion(); - refreshUserCostOverlays(config); + 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); }); From 3da9bb16957a3ebeabd958400b32ff252a48e12e Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Thu, 6 Aug 2026 23:17:33 +0800 Subject: [PATCH 15/49] =?UTF-8?q?fix(usage):=20audit=20follow-up=20?= =?UTF-8?q?=E2=80=94=20empty=20overlay=20guard,=20DTO=20drop,=20legacy=20s?= =?UTF-8?q?hape=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local multi-model audit (deepseek-v4-flash, GLM-5.2) findings: - mergeLegacyOpenAiProviderRows no longer attaches a spurious empty modelCosts: {} when neither legacy row carries an overlay (empty object is truthy); the minimal tier-1 migration stays clean. - managedLegacyMultiOverlay validates the legacy modelCosts shape locally (no config.ts import — that would cycle) so a malformed overlay is a collision instead of being carried into the canonical row. - safeConfigDTO drops secret-shaped model ids from modelCosts entirely instead of collapsing distinct rows into one [REDACTED] placeholder key. - Comment on the raw change-detection signature corrected. - Tests: no-empty-overlay assertion, DTO drop assertions, and a loadConfig-unchanged no-op version test. --- src/providers/openai-tiers.ts | 18 ++++++++++++- src/server/auth-cors.ts | 5 +++- src/usage/user-cost-overlays.ts | 8 +++--- .../openai-provider-option-migration.test.ts | 3 +++ tests/provider-cost-overlay-config.test.ts | 27 +++++++++++++++++-- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 9638858222..56392faa79 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -76,6 +76,10 @@ function managedLegacyMultiOverlay( 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") @@ -87,6 +91,17 @@ function managedLegacyMultiOverlay( }; } +/** Shape check for a legacy overlay: a plain record of complete non-negative finite Cost4 rows. */ +function validLegacyOverlayCosts(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + return Object.values(value as Record).every(entry => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const rates = entry as Record; + return (["input", "output", "cacheRead", "cacheWrite"] as const) + .every(key => typeof rates[key] === "number" && Number.isFinite(rates[key]) && (rates[key] as number) >= 0); + }); +} + function rewriteLegacyOpenAiSelectedId(value: string): string { return value.startsWith(LEGACY_OPENAI_MULTI_PREFIX) ? value.slice(LEGACY_OPENAI_MULTI_PREFIX.length) @@ -110,13 +125,14 @@ function mergeLegacyOpenAiProviderRows( // Both rows can carry disjoint overlays; merge them (canonical openai wins on // key conflicts) so legacy Multi prices are not silently dropped. const modelCosts = { ...(legacyMulti?.modelCosts ?? {}), ...(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 } : {}), - ...(modelCosts ? { modelCosts } : {}), + ...(hasModelCosts ? { modelCosts } : {}), }; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 94b75d10c3..8e43489636 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -580,7 +580,10 @@ function sanitizeModelCosts(costs: unknown): Record if (validRate(input) && validRate(output) && validRate(cacheRead) && validRate(cacheWrite)) { // The DTO is served to the dashboard; a model id shaped like a pasted key // must not be echoed back (validation errors already redact these). - out[redactSecretString(modelId)] = { input, output, cacheRead, 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; diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index 1050f733c9..bdf75429eb 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -58,9 +58,11 @@ export function refreshUserCostOverlays(config: OcxConfig): void { cacheRead: cost4.cacheRead, cacheWrite: cost4.cacheWrite, }, - // Display provenance only — redact token-shaped provider/model ids so - // neither the registry rows nor the refresh signature below can echo - // a pasted key or account id. Matching still uses the raw fields. + // 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", diff --git a/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index 7f362bed87..928a862cb8 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -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); }); diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index 202d8fb261..831c8d5b2d 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -136,6 +136,27 @@ describe("modelCosts config persistence and registry refresh", () => { expect(activeUserCostOverlays()).toHaveLength(0); }); + 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, @@ -280,7 +301,7 @@ describe("modelCosts management validation and DTO", () => { expect(rows?.["__proto__"]).toEqual({ input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }); }); - test("safeConfigDTO redacts secret-shaped model ids in modelCosts", () => { + test("safeConfigDTO drops secret-shaped model ids from modelCosts", () => { writeFileSync(getConfigPath(), JSON.stringify({ port: 12345, providers: { @@ -299,6 +320,8 @@ describe("modelCosts management validation and DTO", () => { const keys = Object.keys(dto.providers.blsc.modelCosts ?? {}); expect(keys).toContain("deepseek-v4-flash"); expect(keys).not.toContain("sk-abcdef1234567890"); - expect(keys).toContain("[REDACTED]"); + // Dropped entirely — distinct secret-shaped rows must not collapse into a + // single placeholder key in the dashboard DTO. + expect(keys).not.toContain("[REDACTED]"); }); }); From fd9e3cde1f01fccdfb32f5ab48849a5c9518ee28 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Thu, 6 Aug 2026 23:55:37 +0800 Subject: [PATCH 16/49] docs(i18n): source-neutral matched-key label and Usage reprice scope - logs.detail.matchedKey is now source-neutral ("Matched price key") in all six locales: it is also rendered when price.source is "user", where no jawcode row was matched. - providers.md (en/ja/ko/ru/zh-cn) now documents that modelCosts also reprices Usage totals, including historical entries, so a price edit can move past aggregates. --- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- .../src/content/docs/zh-cn/reference/configuration/providers.md | 2 +- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/zh.ts | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) 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 fedf26134d..e9ffaee818 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -73,7 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` 見積もりで組み込みカタログより優先されます(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、ルーティング・アカウント選択・割り当て・請求には影響しません。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、ルーティング・アカウント選択・割り当て・請求には影響しません。 | | `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 dfafad3342..8a4030296f 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -73,7 +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를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·로컬 OpenAI 호환·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 추정에서 내장 카탈로그보다 우선합니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | +| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 정확한 모델 ID를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·로컬 OpenAI 호환·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | | `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 c3869b7c54..85fda14331 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -83,7 +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 exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom, local OpenAI-compatible, and 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 `~$` estimate; 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. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom, local OpenAI-compatible, and 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. 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 fe6f01bbd0..00ec4797ea 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -86,7 +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 токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные, локальные OpenAI-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценке `~$` в Logs (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | +| `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные, локальные OpenAI-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | | `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 c79a3085de..28eb113dc3 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,7 +73,7 @@ selector,而不是分配一个新名称。 | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | -| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使不存在于内置目录中,自定义、本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 估算中优先于内置目录(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | +| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使不存在于内置目录中,自定义、本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | | `headers?` | `Record` | 额外的上游请求头。会拒绝 Authorization、cookie、API key 头、嵌入换行符以及无效名称。 | | `openRouterRouting?` | `OpenRouterProviderRouting` | 默认的 OpenRouter `order`、`only` 和 `allowFallbacks` 偏好;仅对使用 `openai-chat` 的规范 OpenRouter 有效。 | | `modelOpenRouterRouting?` | `Record` | 精确模型 id 级别的覆盖项,会替换提供者级 OpenRouter 偏好。 | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a61b381bf3..c1f10ebfc7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -617,7 +617,7 @@ 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", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7ce96b2f3d..eac1d73b6f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -644,7 +644,7 @@ 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", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 271ab4763c..6823fcc168 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -602,7 +602,7 @@ 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 をコピー", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5f2b4b8974..4e8e52f65e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -636,7 +636,7 @@ 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 복사", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ffa0f8aab6..ae5c976049 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -634,7 +634,7 @@ 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 запроса", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index f4244ecab8..87c50edc5c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -629,7 +629,7 @@ 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", From 771a48b5c6b7f54b4dee14cd2bf7a944f8cda0ec Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Fri, 7 Aug 2026 19:23:32 +0800 Subject: [PATCH 17/49] fix(usage): bound overlay rates and refresh registry after OAuth reconciliation Connector round on 0c5b5bca: - A finite-but-huge rate (e.g. 1e308) passed validation yet overflowed calculateCost() to Infinity, serializing /api/logs and /api/usage cost fields as null while the request stayed 'priced'. New shared MAX_COST4_RATE (1e6 USD per 1M tokens) is enforced by every overlay gate: management and schema validation, the runtime registry, the dashboard DTO, and the legacy OpenAI tier migration check. - reconcileLiveConfigFromDisk (OAuth login reconciliation) adopted a cooperating process's modelCosts edit into the long-lived config without refreshing the overlay registry, so Logs/Usage kept the old rates until the next changed save or restart. It now refreshes the registry after the reconciled subtree is applied. - Tests: bound rejection/boundary acceptance, DTO and registry dropping of out-of-bound rows, and a reconcile-adopts-overlay registry refresh test. --- src/config.ts | 9 +++- src/providers/openai-tiers.ts | 6 ++- src/server/auth-cors.ts | 3 +- src/usage/expected-prices.ts | 9 ++++ src/usage/user-cost-overlays.ts | 7 +++- tests/config-user-edits.test.ts | 31 ++++++++++++++ tests/provider-cost-overlay-config.test.ts | 49 ++++++++++++++++++++++ 7 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index f9e865bbef..fc89da5480 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,6 +75,7 @@ import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; import { refreshUserCostOverlays } 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, @@ -727,8 +728,8 @@ export function providerModelCostsConfigError(value: unknown, field = "modelCost const rates = entry as Record; for (const key of ["input", "output", "cacheRead", "cacheWrite"]) { const rate = rates[key]; - if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0) { - return `${field}.${safeModelId}.${key} must be a non-negative finite number (USD per 1M tokens)`; + if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0 || rate > MAX_COST4_RATE) { + return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`; } } } @@ -2778,6 +2779,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/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 56392faa79..0c730d23d1 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 { 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"; @@ -98,7 +99,10 @@ function validLegacyOverlayCosts(value: unknown): boolean { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; const rates = entry as Record; return (["input", "output", "cacheRead", "cacheWrite"] as const) - .every(key => typeof rates[key] === "number" && Number.isFinite(rates[key]) && (rates[key] as number) >= 0); + .every(key => typeof rates[key] === "number" + && Number.isFinite(rates[key]) + && (rates[key] as number) >= 0 + && (rates[key] as number) <= MAX_COST4_RATE); }); } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 8e43489636..d1362bf810 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -16,6 +16,7 @@ import { } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; +import { MAX_COST4_RATE } from "../usage/expected-prices"; import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; @@ -556,7 +557,7 @@ export function copyIfDefined( /** True when `value` is a non-negative finite USD-per-1M-token rate. */ function validRate(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && value >= 0; + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_COST4_RATE; } /** 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-overlays.ts b/src/usage/user-cost-overlays.ts index bdf75429eb..b6b150f292 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -15,7 +15,7 @@ * Display-time estimation only — these rows never affect billing. */ import type { OcxConfig, ProviderCostOverlay } from "../types"; -import type { ExpectedPriceOverlay } from "./expected-prices"; +import { MAX_COST4_RATE, type ExpectedPriceOverlay } from "./expected-prices"; import { redactSecretString } from "../lib/redact"; const EMPTY: readonly ExpectedPriceOverlay[] = []; @@ -29,7 +29,10 @@ function validCost4(value: unknown): value is ProviderCostOverlay { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const entry = value as Record; return (["input", "output", "cacheRead", "cacheWrite"] as const) - .every(key => typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0); + .every(key => typeof entry[key] === "number" + && Number.isFinite(entry[key]) + && entry[key] >= 0 + && entry[key] <= MAX_COST4_RATE); } /** diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 32ef000705..2385663409 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 } from "../src/usage/user-cost-overlays"; import type { OcxConfig } from "../src/types"; /** @@ -549,6 +550,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/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index 831c8d5b2d..62ae985a78 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -62,6 +62,18 @@ describe("providerModelCostsConfigError", () => { .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" }, @@ -222,6 +234,22 @@ describe("modelCosts config persistence and registry refresh", () => { 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", () => { @@ -324,4 +352,25 @@ describe("modelCosts management validation and DTO", () => { // single placeholder key in the dashboard DTO. expect(keys).not.toContain("[REDACTED]"); }); + + test("safeConfigDTO drops modelCosts rows whose rates exceed the safe bound", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { + blsc: { + ...providerBase, + modelCosts: { + "deepseek-v4-flash": VALID_COSTS["deepseek-v4-flash"], + "overflow-model": { input: 1e308, output: 1, cacheRead: 0, cacheWrite: 0 }, + }, + }, + }, + })); + 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("overflow-model"); + }); }); From 27b3ae65b8e4c05a2a158fa2f0f7a02b32c755a9 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Fri, 7 Aug 2026 19:37:08 +0800 Subject: [PATCH 18/49] test(docs): audit follow-up for the rate bound - safeConfigDTO bound test now builds an in-memory config so the DTO gate itself (validRate) is exercised instead of load-time sanitization. - legacy OpenAI tier migration test asserts an out-of-bound overlay rate on openai-multi collides. - providers.md (en/ja/ko/ru/zh-cn) documents the 1,000,000 rate cap and the reject-on-save / drop-on-load behavior. --- .../docs/ja/reference/configuration/providers.md | 2 +- .../docs/ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../docs/ru/reference/configuration/providers.md | 2 +- .../docs/zh-cn/reference/configuration/providers.md | 2 +- tests/openai-provider-option-migration.test.ts | 13 +++++++++++++ tests/provider-cost-overlay-config.test.ts | 9 ++++++--- 7 files changed, 24 insertions(+), 8 deletions(-) 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 e9ffaee818..5c90c81f20 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -73,7 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。表示専用の見積もりであり、ルーティング・アカウント選択・割り当て・請求には影響しません。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は 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 8a4030296f..227f1f9e68 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -73,7 +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를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·로컬 OpenAI 호환·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | +| `modelCosts?` | `Record` | 모델별 표시 가격(100만 토큰당 USD). 정확한 모델 ID를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·로컬 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 85fda14331..73f1b8e5d2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -83,7 +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 exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom, local OpenAI-compatible, and 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. Display-time estimation only: overlays never affect routing, account selection, quotas, or billing. | +| `modelCosts?` | `Record` | Per-model display prices (USD per 1M tokens), keyed by exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom, local OpenAI-compatible, and 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 00ec4797ea..85fe43193c 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -86,7 +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 токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные, локальные OpenAI-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | +| `modelCosts?` | `Record` | Отображаемые цены по моделям (USD за 1M токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные, локальные OpenAI-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в 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 28eb113dc3..d73fa33649 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,7 +73,7 @@ selector,而不是分配一个新名称。 | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | -| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使不存在于内置目录中,自定义、本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | +| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使不存在于内置目录中,自定义、本地 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/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index 928a862cb8..ee63caf0a9 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -210,6 +210,19 @@ describe("OpenAI provider option migration matrix", () => { expect(result.config.providers.openai.modelCosts).toEqual(multiCosts); }); + 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("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 index 62ae985a78..ee4be0b8f3 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -354,7 +354,10 @@ describe("modelCosts management validation and DTO", () => { }); test("safeConfigDTO drops modelCosts rows whose rates exceed the safe bound", () => { - writeFileSync(getConfigPath(), JSON.stringify({ + // 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: { @@ -365,8 +368,8 @@ describe("modelCosts management validation and DTO", () => { }, }, }, - })); - const dto = safeConfigDTO(loadConfig()) as { + } as unknown as OcxConfig; + const dto = safeConfigDTO(config) as { providers: Record }>; }; const keys = Object.keys(dto.providers.blsc.modelCosts ?? {}); From 978fb23f8f0de4129789e1154bcb890037abb9b3 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Fri, 7 Aug 2026 19:52:14 +0800 Subject: [PATCH 19/49] fix(usage): preserve modelCosts across OAuth provider upserts upsertOAuthProvider rebuilds the provider row from the preset on re-login / add-account / reauth and preserves several user-owned fields, but dropped a configured modelCosts overlay, silently reverting Logs/Usage to catalog estimates. Carry existing.modelCosts into the rebuilt row like the other preserved provider controls. Regression test in tests/oauth-upsert-preserves-api-key.test.ts. --- src/oauth/index.ts | 6 ++++++ tests/oauth-upsert-preserves-api-key.test.ts | 8 ++++++++ 2 files changed, 14 insertions(+) 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/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"; From f0235e05606572046e2a8d5f0924c88500870bd5 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Fri, 7 Aug 2026 23:01:44 +0800 Subject: [PATCH 20/49] fix(oauth): preserve modelCosts across key-login provider replacement handleKeyLogin rebuilds the provider row from the key-provider preset, so rotating an API key via `ocx login ` silently dropped a configured modelCosts overlay and reverted Logs/Usage to catalog prices. mergeKeyLoginProviderRow carries the existing overlay onto the replacement row. Regression tests cover rotation with an overlay, no-overlay absence, and an explicit empty overlay. --- src/oauth/login-cli.ts | 18 ++++++++- tests/key-login-preserves-model-costs.test.ts | 39 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/key-login-preserves-model-costs.test.ts diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 0afecb4281..2512ab5c81 100644 --- a/src/oauth/login-cli.ts +++ b/src/oauth/login-cli.ts @@ -107,6 +107,22 @@ 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 } : {}), + }; +} + async function handleKeyLogin(name: string): Promise { const def = KEY_LOGIN_PROVIDERS[name]; const preflightConfig = loadConfig(); @@ -149,7 +165,7 @@ async function handleKeyLogin(name: string): Promise { console.error(`Error: ${commitCollision}.`); process.exit(1); } - config.providers[name] = provider; + config.providers[name] = mergeKeyLoginProviderRow(provider, config.providers[name]); saveConfig(config); await notifyRunningProxy(name, provider); console.log(`✅ ${def.label} added. Try: ocx sync`); 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..f3cf0ba670 --- /dev/null +++ b/tests/key-login-preserves-model-costs.test.ts @@ -0,0 +1,39 @@ +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({}); + }); +}); From fcfa689d61b8b19298b525d73fc371f1aa12870d Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Fri, 7 Aug 2026 23:01:44 +0800 Subject: [PATCH 21/49] fix(i18n): user-configured zh labels and source-agnostic unmatched-price wording zh.ts source.user / estimate.provider_cost_overlay now read as user-configured rather than provider-defined, and price_unmatched drops the jawcode reference in all six locales since the reason also applies to user overlay lookups. --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/zh.ts | 6 +++--- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c1f10ebfc7..917a92f563 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -643,7 +643,7 @@ 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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index eac1d73b6f..7bdebfeb07 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -670,7 +670,7 @@ 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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6823fcc168..4d57ca6701 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -628,7 +628,7 @@ 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 つのコンボ試行に価格を設定できませんでした。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 4e8e52f65e..bf842b922f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -662,7 +662,7 @@ 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 시도 비용을 계산할 수 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ae5c976049..48a584e5e2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -660,7 +660,7 @@ 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": "Не удалось рассчитать стоимость как минимум одной попытки комбо.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 87c50edc5c..f1f0eb62b3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -636,7 +636,7 @@ export const zh: Record = { "logs.detail.copied": "已复制", "logs.detail.source.jawcode": "jawcode 目录", "logs.detail.source.expected": "Expected 价格覆盖", - "logs.detail.source.user": "提供方自定义价格覆盖", + "logs.detail.source.user": "用户配置的提供方价格覆盖", "logs.detail.verification.verified": "已验证", "logs.detail.verification.derived": "由基础模型推导", "logs.detail.attempt.target": "提供方 / 模型", @@ -655,14 +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.detail.estimate.provider_cost_overlay": "使用了用户配置的提供方价格覆盖。", "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", From d198ac13dd8bff97fefe5d8bd48d1f2874230dd1 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Fri, 7 Aug 2026 23:55:38 +0800 Subject: [PATCH 22/49] fix(oauth): push the merged key-login row to a running proxy handleKeyLogin persisted the merged row (with preserved modelCosts) but notified the proxy with the unmerged preset row; the proxy then saved the replacement without the overlay, undoing the just-written disk state until a restart. commitKeyLoginProvider now assigns, saves, and notifies the SAME merged row. Regression test boots a real proxy with a stale in-memory row, adds modelCosts to disk, and asserts the notify keeps the overlay on live and disk (fails on the pre-fix notify argument). --- src/oauth/login-cli.ts | 25 +++++- tests/key-login-live-update.test.ts | 85 +++++++++++++++++++ tests/key-login-preserves-model-costs.test.ts | 17 ++++ 3 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/key-login-live-update.test.ts diff --git a/src/oauth/login-cli.ts b/src/oauth/login-cli.ts index 2512ab5c81..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"; @@ -123,6 +123,25 @@ export function mergeKeyLoginProviderRow( }; } +/** + * 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(); @@ -165,9 +184,7 @@ async function handleKeyLogin(name: string): Promise { console.error(`Error: ${commitCollision}.`); process.exit(1); } - config.providers[name] = mergeKeyLoginProviderRow(provider, config.providers[name]); - saveConfig(config); - await notifyRunningProxy(name, provider); + await commitKeyLoginProvider(config, name, provider); console.log(`✅ ${def.label} added. Try: ocx sync`); } diff --git a/tests/key-login-live-update.test.ts b/tests/key-login-live-update.test.ts new file mode 100644 index 0000000000..25837c118d --- /dev/null +++ b/tests/key-login-live-update.test.ts @@ -0,0 +1,85 @@ +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 { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; + +/** + * 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(() => { + 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"); + } 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 index f3cf0ba670..d653faa99b 100644 --- a/tests/key-login-preserves-model-costs.test.ts +++ b/tests/key-login-preserves-model-costs.test.ts @@ -36,4 +36,21 @@ describe("key login preserves user-configured price overlays", () => { 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(); + }); }); From 1e9a4fb8796bd677c7ea8f0ab1546c88da762fb5 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Fri, 7 Aug 2026 23:55:38 +0800 Subject: [PATCH 23/49] fix(providers): rewrite legacy openai-multi modelCosts keys during migration mergeLegacyOpenAiProviderRows merged legacy openai-multi modelCosts keys raw, so a key like `openai-multi/gpt-5.6-sol` survived into the canonical openai row and never matched after migration (logs resolve as provider openai with the bare model id). rewriteLegacyOpenAiCostKeys applies the same prefix rewrite as selectedModels to both legacy rows before merging; canonical openai still wins on conflicts. Regression tests cover prefixed-key rewrite and canonical-wins after rewrite collision. --- src/providers/openai-tiers.ts | 26 ++++++++++++-- .../openai-provider-option-migration.test.ts | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 0c730d23d1..690089b5e5 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -1,4 +1,4 @@ -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"; @@ -117,6 +117,21 @@ 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 { + const rewritten: Record = {}; + if (costs) { + for (const [key, value] of Object.entries(costs)) { + rewritten[rewriteLegacyOpenAiSelectedId(key)] = value; + } + } + return rewritten; +} + function mergeLegacyOpenAiProviderRows( openai: OcxProviderConfig | undefined, legacyMulti: OcxProviderConfig | undefined, @@ -127,8 +142,13 @@ function mergeLegacyOpenAiProviderRows( ...(legacyMulti?.selectedModels ?? []), ]); // Both rows can carry disjoint overlays; merge them (canonical openai wins on - // key conflicts) so legacy Multi prices are not silently dropped. - const modelCosts = { ...(legacyMulti?.modelCosts ?? {}), ...(openai?.modelCosts ?? {}) }; + // 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); diff --git a/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index ee63caf0a9..bcece1ed71 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -210,6 +210,40 @@ describe("OpenAI provider option migration matrix", () => { 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("legacy multi with an out-of-bound overlay rate collides", () => { const input = cfg({ openaiProviderTierVersion: 1, From 95775ee504dff84c002e0fd3b549aebc3da2b66f Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 00:12:06 +0800 Subject: [PATCH 24/49] fix(providers): stable precedence for legacy modelCosts key collisions rewriteLegacyOpenAiCostKeys overwrote a bare entry when a later openai-multi/ entry resolved to the same key, so the migrated display price depended on JSON property order. The bare key now always wins inside one legacy row; non-colliding prefixed keys are still rewritten and the cross-row canonical-openai-wins rule is unchanged. Regression tests cover both insertion orders. --- src/providers/openai-tiers.ts | 7 ++++++- tests/openai-provider-option-migration.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 690089b5e5..ed1f80a160 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -126,7 +126,12 @@ function rewriteLegacyOpenAiCostKeys(costs: Record const rewritten: Record = {}; if (costs) { for (const [key, value] of Object.entries(costs)) { - rewritten[rewriteLegacyOpenAiSelectedId(key)] = value; + 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; diff --git a/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index bcece1ed71..2d9313e6fb 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -244,6 +244,24 @@ describe("OpenAI provider option migration matrix", () => { 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("legacy multi with an out-of-bound overlay rate collides", () => { const input = cfg({ openaiProviderTierVersion: 1, From fcb38af41dd990c351b5497d0adea04deedd2033 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 00:25:12 +0800 Subject: [PATCH 25/49] fix(providers): preserve prototype-named modelCosts keys during migration rewriteLegacyOpenAiCostKeys assigned into a plain object literal, so a model id named __proto__ invoked the inherited setter instead of creating an own property and the later spread in mergeLegacyOpenAiProviderRows dropped the overlay. The rewrite map is now null-prototype, keeping both __proto__ and openai-multi/__proto__ as own properties through canonicalization. Regression tests cover both forms and fail on the plain-object map. --- src/providers/openai-tiers.ts | 4 ++- .../openai-provider-option-migration.test.ts | 34 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index ed1f80a160..6603b9785b 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -123,7 +123,9 @@ function rewriteLegacyOpenAiModelList(values: string[] | undefined): string[] | * with the bare model id. Keys without the legacy prefix pass through. */ function rewriteLegacyOpenAiCostKeys(costs: Record | undefined): Record { - const rewritten: 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); diff --git a/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index 2d9313e6fb..a5dd0f59b1 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", @@ -262,6 +262,38 @@ describe("OpenAI provider option migration matrix", () => { 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, From 714167c1eb196e73e9d7f1130cfb8063af4db81b Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 00:39:51 +0800 Subject: [PATCH 26/49] fix(config): reject extra fields inside modelCosts rows providerModelCostsConfigError accepted any row whose four rate fields were valid numbers, so a misplaced apiKey/apiKeyPool under a cost row was persisted and could be echoed verbatim by display paths that mask only top-level provider secrets (e.g. ocx provider show --json). The validator now rejects unknown row keys: management writes fail closed, hand-edited configs degrade at load (malformed row dropped with a warning), and the dashboard DTO test pins that a nested secret never serializes. --- src/config.ts | 11 +++++- tests/management-provider-validation.test.ts | 17 ++++++++ tests/provider-cost-overlay-config.test.ts | 41 +++++++++++++++++--- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/config.ts b/src/config.ts index fc89da5480..e3aae3f336 100644 --- a/src/config.ts +++ b/src/config.ts @@ -711,6 +711,8 @@ export function providerHeadersConfigError(headers: unknown): string | null { * 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. */ +const MODEL_COST_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const; + export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -726,12 +728,19 @@ export function providerModelCostsConfigError(value: unknown, field = "modelCost 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 ["input", "output", "cacheRead", "cacheWrite"]) { + for (const key of MODEL_COST_RATE_KEYS) { const rate = rates[key]; if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0 || rate > MAX_COST4_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) => !(MODEL_COST_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; } diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index e3594e3a54..9a95746ed2 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -125,6 +125,23 @@ afterEach(() => { }); describe("provider management validation", () => { + test("provider management rejects modelCosts rows with extra fields", () => { + 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, apiKey: "sk-leak" }, + }, + })).toContain("unexpected fields"); + 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", diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index ee4be0b8f3..84ba5ed883 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -82,6 +82,37 @@ describe("providerModelCostsConfigError", () => { 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(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, @@ -288,7 +319,7 @@ describe("modelCosts management validation and DTO", () => { expect(dto.providers.blsc.modelCosts).toEqual(VALID_COSTS); }); - test("safeConfigDTO serializes only the four rate fields of each modelCosts row", () => { + test("a nested secret under a malformed modelCosts row never reaches the dashboard DTO", () => { writeFileSync(getConfigPath(), JSON.stringify({ port: 12345, providers: { @@ -309,10 +340,10 @@ describe("modelCosts management validation and DTO", () => { const dto = safeConfigDTO(loadConfig()) as { providers: Record> }>; }; - expect(dto.providers.blsc.modelCosts).toEqual({ - "deepseek-v4-flash": { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 }, - }); - expect(dto.providers.blsc.modelCosts?.["deepseek-v4-flash"]?.apiKey).toBeUndefined(); + // 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", () => { From 6418a9d105e4eb6941776fd5db5e07f496e7fc01 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 00:51:54 +0800 Subject: [PATCH 27/49] test(config): assert modelCosts validation errors never echo credential values Both the management-boundary and validator unit tests now assert the error message does not contain the injected nested apiKey value, pinning the no-echo guarantee for extra-field rejection. --- tests/management-provider-validation.test.ts | 6 ++++-- tests/provider-cost-overlay-config.test.ts | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 9a95746ed2..ea990d91cc 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -126,13 +126,15 @@ afterEach(() => { describe("provider management validation", () => { test("provider management rejects modelCosts rows with extra fields", () => { - expect(providerManagementConfigError("blsc", { + 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" }, }, - })).toContain("unexpected fields"); + }); + expect(error).toContain("unexpected fields"); + expect(error).not.toContain("sk-leak"); expect(providerManagementConfigError("blsc", { adapter: "openai-chat", baseUrl: "https://llmapi.blsc.cn", diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index 84ba5ed883..af89f48fac 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -89,6 +89,7 @@ describe("providerModelCostsConfigError", () => { 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(); }); From 5688202d1344d33507c2d3a511cde839d7f40f05 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 01:21:13 +0800 Subject: [PATCH 28/49] fix(cli/config): harden modelCosts display and preservation paths - Shared sanitizeModelCostsForDisplay (moved from a private safeConfigDTO copy): copy only the four rate fields and drop secret-shaped model ids, so a pasted API key in a key position can never be serialized by display paths; safeConfigDTO now uses it. - provider show --json and config show/get route modelCosts through the same sanitizer, matching the dashboard DTO's no-echo guarantee. - provider add --force carries an existing modelCosts overlay onto the rebuilt row (same rule as /api/providers and the login paths), so key rotation no longer reverts Logs/Usage to catalog prices. - persistConfigUnlocked refreshes the overlay registry even on byte-identical saves, so a cooperating CLI write (e.g. the key-login notify path) is adopted by Logs/Usage without a changed save or restart; return-value semantics are unchanged and signature-based refresh keeps no-op saves from bumping the version. --- src/cli/config-command.ts | 5 +- src/cli/provider.ts | 10 ++- src/config.ts | 41 +++++++++-- src/server/auth-cors.ts | 41 +---------- tests/cli-config-command.test.ts | 79 ++++++++++++++++++++++ tests/cli-provider.test.ts | 68 +++++++++++++++++++ tests/provider-cost-overlay-config.test.ts | 26 +++++++ 7 files changed, 226 insertions(+), 44 deletions(-) create mode 100644 tests/cli-config-command.test.ts 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 e3aae3f336..7394d52fed 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 { @@ -745,6 +746,34 @@ export function providerModelCostsConfigError(value: unknown, field = "modelCost 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; + const valid = (rate: unknown): rate is number => + typeof rate === "number" && Number.isFinite(rate) && rate >= 0 && rate <= MAX_COST4_RATE; + if (valid(input) && valid(output) && valid(cacheRead) && valid(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, @@ -2501,15 +2530,19 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync function persistConfigUnlocked(config: OcxConfig): boolean { const configPath = getConfigPath(); const bytes = JSON.stringify(config, 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; } - atomicWriteFile(configPath, bytes); - // Keep the runtime overlay registry in sync with every persist path - // (saveConfig and mutatePersistedConfig both funnel through here). + // 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. refreshUserCostOverlays(config); + if (unchanged) return false; + atomicWriteFile(configPath, bytes); return true; } diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index d1362bf810..a3e9bac79a 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -13,13 +13,13 @@ import { providerModelCostsConfigError, reasoningSummaryDeliveryRecordConfigError, retryOn429PolicyConfigError, + sanitizeModelCostsForDisplay, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; -import { MAX_COST4_RATE } from "../usage/expected-prices"; import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; -import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; +import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; @@ -555,41 +555,6 @@ export function copyIfDefined( if (value !== undefined) out[key as string] = value as unknown; } -/** True when `value` is a non-negative finite USD-per-1M-token rate. */ -function validRate(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_COST4_RATE; -} - -/** - * Serialize `providers..modelCosts` for the dashboard, copying ONLY the - * four numeric rate fields per model. Extra hand-edited fields (which the load - * validator ignores) must never reach the client, so secrets accidentally - * nested under a cost row cannot leak through the DTO. - */ -function sanitizeModelCosts(costs: unknown): Record | undefined { - if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined; - // Null prototype so a model id like "__proto__" becomes an own row instead - // of mutating the map's prototype and vanishing from Object.keys(). - const out: Record = 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 (validRate(input) && validRate(output) && validRate(cacheRead) && validRate(cacheWrite)) { - // The DTO is served to the dashboard; a model id shaped like a pasted key - // must not be echoed back (validation errors already redact these). - // 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; -} - /** 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> = {}; @@ -631,7 +596,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { ] as const) { copyIfDefined(dto, provider, key); } - const modelCosts = sanitizeModelCosts(provider.modelCosts); + 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 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/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index af89f48fac..ae2d3a2059 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -180,6 +180,32 @@ describe("modelCosts config persistence and registry refresh", () => { 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, From 6e3a199e54ac30bd0160888cea81e6710a01c8cd Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 01:35:20 +0800 Subject: [PATCH 29/49] fix(config): refresh overlay registry only after a successful changed write persistConfigUnlocked refreshed the registry before atomicWriteFile, so a failed write could leave Logs/Usage estimating from configuration that was never persisted. The refresh now runs before the byte-identical early return (config already on disk) and only AFTER atomicWriteFile succeeds for changed saves. --- src/config.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 7394d52fed..f1a57a3462 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2540,9 +2540,14 @@ function persistConfigUnlocked(config: OcxConfig): boolean { // 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. - refreshUserCostOverlays(config); - if (unchanged) return false; + if (unchanged) { + refreshUserCostOverlays(config); + 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(config); return true; } From 17fc142efa379b5d984c7bd9222a2a9a9d263e54 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 12:02:27 +0800 Subject: [PATCH 30/49] fix(usage): isolate pricing namespace for configured suffix-shaped providers resolveMatchedPrice collapsed any provider name ending in -main/-pXXXXXX to its baseProviderLabel base before pricing, so a real configured custom provider named like an account label (e.g. acme-pabcdef) could inherit a different provider's user-configured modelCosts overlay, and an all-zero overlay on the suffixed provider still fell through to the base's user overlay. The resolver now collapses suffix-shaped names only when the literal provider is NOT in config.providers (generated account log labels); configured providers keep their own pricing namespace for every lookup. chatgpt/openai-multi still canonicalize to openai. The overlay registry now includes the sorted configured provider-name set in its change signature, so adding/removing a provider (even one without overlays) bumps the version and invalidates the resolver memo and the /api/usage summary cache. Regression tests cover: a configured suffix-shaped provider with no overlay does not inherit the base overlay; an all-zero overlay falls through to compiled pricing; non-configured generated labels still collapse; and configuring a provider invalidates its previously collapsed memo entry immediately. --- src/providers/label.ts | 2 +- src/usage/cost.ts | 21 +++++---- src/usage/user-cost-overlays.ts | 19 +++++++- tests/api-usage.test.ts | 8 ++-- tests/usage-cost.test.ts | 79 +++++++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 14 deletions(-) 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/usage/cost.ts b/src/usage/cost.ts index f3334daec7..615419f324 100644 --- a/src/usage/cost.ts +++ b/src/usage/cost.ts @@ -15,10 +15,10 @@ import { resolveMetadataProvider, } from "../generated/model-metadata"; import type { OcxUsage } from "../types"; -import { baseProviderLabel } from "../providers/label"; +import { baseProviderLabel, canonicalUsageProviderLabel } from "../providers/label"; import type { PersistedUsageAttempt, UsageStatus } from "./log"; import { canonicalAntigravityUsageModel } from "../providers/antigravity-models"; -import { activeUserCostOverlays, userCostOverlayVersion } from "./user-cost-overlays"; +import { activeConfiguredProviders, activeUserCostOverlays, userCostOverlayVersion } from "./user-cost-overlays"; import { EXPECTED_PRICE_OVERLAYS, findExpectedPriceOverlay, @@ -174,16 +174,21 @@ export function resolveMatchedPrice( userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(), ): MatchedPrice | null { // User-configured overlays are keyed by the EXACT configured provider name. - // Try them before collapsing pool/account log suffixes: a custom provider can - // legitimately end with a label-shaped suffix (e.g. blsc-pabcdef), and its own - // overlay would otherwise never match the collapsed base name. + // A provider that literally exists in config.providers keeps its own pricing + // namespace: a real custom provider can legitimately end with a label-shaped + // suffix (e.g. acme-pabcdef) and must not inherit the base provider's user + // overlay. Only NON-configured names (generated account log labels) collapse + // to their label base. chatgpt/openai-multi are the same OpenAI usage surface + // and always canonicalize to openai. const collapsed = baseProviderLabel(provider); - if (collapsed !== provider) { + if (collapsed !== provider && (canonicalUsageProviderLabel(provider) !== provider || !activeConfiguredProviders().has(provider))) { const exactUserOverlay = userOverlayMatch(provider, modelId, userOverlays); if (exactUserOverlay) return exactUserOverlay; + // Pool/account log suffixes (e.g. google-antigravity-p442fff) must collapse + // before the compiled/overlay lookup; configured providers keep their own + // namespace above. + provider = collapsed; } - // Pool/account log suffixes (e.g. google-antigravity-p442fff) must collapse before overlay lookup. - provider = collapsed; // Memoize by (provider, model): usage summaries iterate hundreds of thousands of // rows that share a handful of provider/model keys, so resolving each time would // dominate /api/usage latency (WP6 audit). The compiled overlays are static; diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index b6b150f292..1ff8210419 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -10,7 +10,10 @@ * 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). + * 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. */ @@ -22,6 +25,7 @@ const EMPTY: readonly ExpectedPriceOverlay[] = []; let active: readonly ExpectedPriceOverlay[] = EMPTY; let activeSignature = ""; +let activeConfigured = new Set(); let version = 0; /** True when `value` is a complete cost entry: all four rates are non-negative finite numbers. */ @@ -83,10 +87,16 @@ export function refreshUserCostOverlays(config: OcxConfig): void { // 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. - const signature = JSON.stringify(rows); + // 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++; } @@ -99,3 +109,8 @@ export function activeUserCostOverlays(): readonly ExpectedPriceOverlay[] { 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/api-usage.test.ts b/tests/api-usage.test.ts index 1836713dc5..d960afa566 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -157,11 +157,13 @@ 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 { - // Start from a known overlay version so a leftover entry from an earlier - // test cannot satisfy the first request. - refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); 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); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 89a5b8acaa..6b776e009d 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -768,6 +768,85 @@ describe("provider cost overlay (user-configured)", () => { 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); + // The compiled model-level price for claude-opus-4-6 (anthropic vendor). + expect(price?.cost4).toEqual({ input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }); + }); + + 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", From a00eb37833b19583fae44777415cfcff426b180b Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 14:09:38 +0800 Subject: [PATCH 31/49] docs(ja): state quota non-effect explicitly in modelCosts row --- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5c90c81f20..9984df4982 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -73,7 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・割り当て・請求には影響しません。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は 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 のオーバーライド。 | From 79b351fd3f7d75fd62a22e5ea7e74301080d8b02 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sat, 8 Aug 2026 14:16:13 +0800 Subject: [PATCH 32/49] docs(ja): clarify custom providers may target any OpenAI-compatible endpoint --- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9984df4982..b3024e2fb2 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -73,7 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `input`, `output`, `cacheRead`, `cacheWrite` の 4 フィールドです(例: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。組み込みカタログにないカスタム・ローカル OpenAI 互換・内部プロバイダーの ID も有効です。ユーザー設定の価格は Logs の `~$` と Usage の見積もりで組み込みカタログより優先されます。過去のエントリも現在のオーバーレイで再計算されるため、価格を編集すると過去の合計が変わることがあります(フォールバック順: ユーザー設定 → jawcode カタログ → expected-price オーバーレイ → モデル別ベンダー価格)。全ゼロのエントリは次のソースにフォールバックします。各レートは 0 以上の有限数で、最大 1,000,000(100万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 | +| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `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 のオーバーライド。 | From 1296f28363831667b3fac6fd878e7c8fd4869d47 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 9 Aug 2026 16:52:39 +0800 Subject: [PATCH 33/49] fix(usage): reconcile user cost overlays from disk across processes Wibias review: the overlay refresh in persistConfigUnlocked is process-local, so ocx config set (CLI saveConfig) and direct config.json edits never reach a running proxy until restart. Add a lightweight stat-based reconciler started by startServer: when the file changes, re-read the persisted config, mirror disk modelCosts into live provider rows (so a later in-process save cannot erase the external edit), and refresh the overlay registry/version so Logs/Usage estimates follow the edit live. Stops with the server lifecycle. Regression tests use a separate writer process for both the CLI saveConfig path and a raw config.json edit, plus a transient-invalid-file no-wipe case. --- src/server/index.ts | 4 + src/server/lifecycle.ts | 2 + src/usage/user-cost-overlay-reconciler.ts | 106 +++++++++++ .../user-cost-overlay-live-reconcile.test.ts | 171 ++++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 src/usage/user-cost-overlay-reconciler.ts create mode 100644 tests/user-cost-overlay-live-reconcile.test.ts diff --git a/src/server/index.ts b/src/server/index.ts index 0546819350..b68b910ad3 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, @@ -488,6 +489,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server | null = null; +let reconcileLiveConfig: OcxConfig | null = null; +let lastStamp: { mtimeMs: number; size: number } | null = null; + +function configStamp(): { mtimeMs: number; size: number } | null { + try { + const stat = statSync(getConfigPath()); + return { mtimeMs: stat.mtimeMs, size: stat.size }; + } catch { + return null; + } +} + +/** + * 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); + } + } +} + +/** + * 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); + // 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; +} + +/** Start the stat-based reconciler. Idempotent: a previous timer is stopped. */ +export function startUserCostOverlayReconciler( + options: { intervalMs?: number; liveConfig?: OcxConfig | null } = {}, +): { stop(): void } { + stopUserCostOverlayReconciler(); + reconcileLiveConfig = options.liveConfig ?? null; + const intervalMs = options.intervalMs ?? USER_COST_OVERLAY_RECONCILE_INTERVAL_MS; + reconcileTimer = setInterval(() => { + const stamp = configStamp(); + if (!stamp) return; + if (lastStamp && lastStamp.mtimeMs === stamp.mtimeMs && lastStamp.size === stamp.size) return; + lastStamp = stamp; + try { + reconcileUserCostOverlaysFromDisk(reconcileLiveConfig); + } catch { + // Display-only reconciliation must never take the proxy down. + } + }, intervalMs); + reconcileTimer.unref?.(); + return { stop: stopUserCostOverlayReconciler }; +} + +export function stopUserCostOverlayReconciler(): void { + if (reconcileTimer) clearInterval(reconcileTimer); + reconcileTimer = null; + reconcileLiveConfig = null; + lastStamp = null; +} + +/** Test-only reset for module-global reconciler state. */ +export function resetUserCostOverlayReconcilerForTests(): void { + stopUserCostOverlayReconciler(); +} 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..b7314589b1 --- /dev/null +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -0,0 +1,171 @@ +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, readConfigDiagnostics, saveConfig } from "../src/config"; +import { resolveMatchedPrice } from "../src/usage/cost"; +import { + activeUserCostOverlays, + refreshUserCostOverlays, + userCostOverlayVersion, +} from "../src/usage/user-cost-overlays"; +import { + resetUserCostOverlayReconcilerForTests, + startUserCostOverlayReconciler, + stopUserCostOverlayReconciler, +} 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(timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const price = resolveMatchedPrice("acme", "model-x"); + if (price?.source === "user") return; + await Bun.sleep(20); + } + throw new Error("timed out waiting for the external overlay to become live"); +} + +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); + 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. + await runChild(` + const { loadConfig, saveConfig } = await import("./src/config.ts"); + const config = loadConfig(); + config.providers.acme.modelCosts = { "model-x": ${JSON.stringify(OVERLAY)} }; + saveConfig(config); + `); + 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. + writeFileSync(getConfigPath(), "{ not json", "utf8"); + await Bun.sleep(150); + + expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); + expect(readConfigDiagnostics().source).toBe("fallback"); + }); +}); From 5b06151e5c8c5c6e13f025ed4a5bc9f86fba389f Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 9 Aug 2026 17:09:04 +0800 Subject: [PATCH 34/49] fix(usage): review follow-ups for live overlay reconciliation Address CodeRabbit findings on the cross-process reconciler: - preserve externally added providers across unrelated live saves by merging them at the config serialization boundary instead of adding them to live routing state; regression test covers an external provider + overlay surviving a later in-process save - stop the reconciler when listener startup fails so it cannot keep updating process-global overlay state without a running listener - restore the caller's OPENCODEX_HOME in the config overlay test lifecycle - clarify in all five provider docs locales that modelCosts keys are the enclosing provider's exact upstream model ids, not provider identifiers or routed provider/model labels --- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- src/config.ts | 12 +++-- src/server/index.ts | 4 +- src/usage/user-cost-overlay-reconciler.ts | 29 ++++++++++-- src/usage/user-cost-overlays.ts | 38 ++++++++++++++- tests/provider-cost-overlay-config.test.ts | 5 +- .../user-cost-overlay-live-reconcile.test.ts | 46 ++++++++++++++++++- 11 files changed, 127 insertions(+), 17 deletions(-) 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 b3024e2fb2..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,7 +73,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `modelMaxInputTokens?` | `Record` |カタログの自動圧縮ヒントに使用されるモデルごとの正の最大入力制限。 | | `defaultMaxOutputTokens?` | `number` |クライアントが `max_output_tokens` を省略した場合の、プロバイダー全体の `openai-chat` フォールバック。 | | `modelMaxOutputTokens?` | `Record` |モデルごとの `openai-chat` フォールバック バジェットがプラスになります。正確な/パターン一致はプロバイダーのデフォルトを上回ります。 | -| `modelCosts?` | `Record` | モデルごとの表示価格(100万トークンあたりの米ドル)。正確なモデル ID をキーにし、値は `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万トークンあたりの米ドル)です。範囲外の行は管理境界で拒否され、読み込み時に破棄されます。表示専用の見積もりであり、ルーティング・アカウント選択・クォータ・請求には影響しません。 | +| `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 227f1f9e68..60a0f4341c 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -73,7 +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를 키로 사용하며 값은 `input`, `output`, `cacheRead`, `cacheWrite` 네 필드입니다(예: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). 내장 카탈로그에 없는 커스텀·로컬 OpenAI 호환·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | +| `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 호환·내부 공급자 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 73f1b8e5d2..3804153a81 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -83,7 +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 exact model id, e.g. `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`. Any model id is a valid key — custom, local OpenAI-compatible, and 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. | +| `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, local OpenAI-compatible, and 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 85fe43193c..b72349c7b3 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -86,7 +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 токенов), ключ — точный id модели, значение — четыре поля: `input`, `output`, `cacheRead`, `cacheWrite` (пример: `{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`). Любой id допустим — кастомные, локальные OpenAI-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Каждая ставка должна быть неотрицательным конечным числом не более 1 000 000 (USD за 1M токенов); строки вне диапазона отклоняются на управляющей границе и отбрасываются при загрузке. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | +| `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-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в 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 d73fa33649..bf68d25dc9 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,7 +73,7 @@ selector,而不是分配一个新名称。 | `modelMaxInputTokens?` | `Record` | 正数型、按模型设置的最大输入限制,用于目录自动压缩提示。 | | `defaultMaxOutputTokens?` | `number` | 当客户端省略 `max_output_tokens` 时,`openai-chat` 的提供者级回退值。 | | `modelMaxOutputTokens?` | `Record` | 正数型、按模型设置的 `openai-chat` 回退预算;精确/模式匹配优先于提供者默认值。 | -| `modelCosts?` | `Record` | 按模型设置的显示价格(每 100 万 token 的美元数),以精确模型 ID 为键,值为四个字段:`input`、`output`、`cacheRead`、`cacheWrite`(示例:`{ "deepseek-v4-flash": { "input": 0.14, "output": 0.28, "cacheRead": 0.0028, "cacheWrite": 0 } }`)。任何模型 ID 都是有效键——即使不存在于内置目录中,自定义、本地 OpenAI 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。每个费率必须是大于等于 0 的有限数字,且不超过 1,000,000(每 100 万 token 的美元数);超出范围的条目会在管理边界被拒绝,并在加载时被丢弃。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | +| `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 兼容和内部提供者的 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/src/config.ts b/src/config.ts index f1a57a3462..59df77bf58 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,7 +75,7 @@ import { import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; -import { refreshUserCostOverlays } from "./usage/user-cost-overlays"; +import { refreshUserCostOverlays, withPreservedDiskOnlyProviders } from "./usage/user-cost-overlays"; import { MAX_COST4_RATE } from "./usage/expected-prices"; import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, @@ -2529,7 +2529,11 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync */ 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 { unchanged = readFileSync(configPath, "utf8") === bytes; @@ -2541,13 +2545,13 @@ function persistConfigUnlocked(config: OcxConfig): boolean { // 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(config); + 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(config); + refreshUserCostOverlays(persisted); return true; } diff --git a/src/server/index.ts b/src/server/index.ts index b68b910ad3..f78e4008e6 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -423,6 +423,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server = {}; + if (disk.providers && live.providers) { + for (const [name, provider] of Object.entries(disk.providers)) { + if (!live.providers[name] && provider) preserved[name] = structuredClone(provider); + } + } + setPreservedDiskOnlyProviders(Object.keys(preserved).length > 0 ? preserved : null); +} + /** * Re-read the persisted config and make external overlay edits live. * @@ -64,7 +84,10 @@ export function reconcileUserCostOverlaysFromDisk(liveConfig?: OcxConfig | null) const diagnostics = readConfigDiagnostics(); if (diagnostics.source !== "file") return false; const disk = diagnostics.config; - if (liveConfig) adoptDiskModelCosts(liveConfig, disk); + if (liveConfig) { + adoptDiskModelCosts(liveConfig, disk); + rememberDiskOnlyProviders(liveConfig, disk); + } // 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); diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index 1ff8210419..c95abee3dd 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -17,7 +17,7 @@ * * Display-time estimation only — these rows never affect billing. */ -import type { OcxConfig, ProviderCostOverlay } from "../types"; +import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types"; import { MAX_COST4_RATE, type ExpectedPriceOverlay } from "./expected-prices"; import { redactSecretString } from "../lib/redact"; @@ -27,6 +27,42 @@ let active: readonly ExpectedPriceOverlay[] = EMPTY; let activeSignature = ""; let activeConfigured = new Set(); let version = 0; +let preservedDiskOnlyProviders: Record | null = null; + +/** + * 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; +} + +/** + * 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. + */ +export function withPreservedDiskOnlyProviders(config: OcxConfig): OcxConfig { + if (!preservedDiskOnlyProviders || Object.keys(preservedDiskOnlyProviders).length === 0) { + return config; + } + return { + ...config, + providers: { + ...preservedDiskOnlyProviders, + ...config.providers, + }, + }; +} + +/** Test-only reset for the preserved disk-only provider registry. */ +export function resetPreservedDiskOnlyProvidersForTests(): void { + preservedDiskOnlyProviders = null; +} /** True when `value` is a complete cost entry: all four rates are non-negative finite numbers. */ function validCost4(value: unknown): value is ProviderCostOverlay { diff --git a/tests/provider-cost-overlay-config.test.ts b/tests/provider-cost-overlay-config.test.ts index ae2d3a2059..9a370c66b7 100644 --- a/tests/provider-cost-overlay-config.test.ts +++ b/tests/provider-cost-overlay-config.test.ts @@ -19,8 +19,10 @@ const VALID_COSTS = { }; let testDir = ""; +let previousHome: string | undefined; beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; testDir = mkdtempSync(join(tmpdir(), "ocx-model-costs-")); process.env.OPENCODEX_HOME = testDir; }); @@ -29,7 +31,8 @@ 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); - delete process.env.OPENCODEX_HOME; + 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 = ""; }); diff --git a/tests/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts index b7314589b1..ccf872626a 100644 --- a/tests/user-cost-overlay-live-reconcile.test.ts +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -7,6 +7,7 @@ import { resolveMatchedPrice } from "../src/usage/cost"; import { activeUserCostOverlays, refreshUserCostOverlays, + resetPreservedDiskOnlyProvidersForTests, userCostOverlayVersion, } from "../src/usage/user-cost-overlays"; import { @@ -52,10 +53,14 @@ async function runChild(script: string): Promise<{ exitCode: number; stderr: str return { exitCode, stderr }; } -async function waitForOverlayLive(timeoutMs = 10_000): Promise { +async function waitForOverlayLive( + provider = "acme", + model = "model-x", + timeoutMs = 10_000, +): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const price = resolveMatchedPrice("acme", "model-x"); + const price = resolveMatchedPrice(provider, model); if (price?.source === "user") return; await Bun.sleep(20); } @@ -73,6 +78,7 @@ 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 }); @@ -168,4 +174,40 @@ describe("cross-process user cost overlay reconciliation", () => { expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); expect(readConfigDiagnostics().source).toBe("fallback"); }); + + 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"); + }); }); From b02a90547219bd607b5cc1cadcee2f7074d875f2 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 9 Aug 2026 17:17:05 +0800 Subject: [PATCH 35/49] fix(usage): stop cost-overlay reconciler on normal server stop The module-level poller started by startServer was only stopped in the process-wide shutdown path; server.stop() (used by embedded callers and tests) left the interval running with the stopped server's live config. Stop it in the server.stop shutdown steps so no timer keeps updating process-global overlay state after the listener is gone. --- src/server/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/server/index.ts b/src/server/index.ts index f78e4008e6..2ed12eaf5f 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -29,7 +29,10 @@ import { reconcileLiveStateStores, setLiveStateStoreConfig, } from "../lib/state-store-registrations"; -import { startUserCostOverlayReconciler } from "../usage/user-cost-overlay-reconciler"; +import { + startUserCostOverlayReconciler, + stopUserCostOverlayReconciler, +} from "../usage/user-cost-overlay-reconciler"; import { configureAppOwnedMemoryBudget, enforceAppOwnedMemoryBudget, @@ -1543,6 +1546,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), + async () => { + stopUserCostOverlayReconciler(); + }, ], async () => { await backgroundLifecycle.release(); From 7090b49b3687b58f036f753f07b1a9013ab59c7d Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Sun, 9 Aug 2026 17:30:55 +0800 Subject: [PATCH 36/49] fix(usage): owner-scoped reconciler lifecycle within guarded startup CodeRabbit follow-ups after rebasing onto the refcounted background lifecycle: - start the cost-overlay reconciler inside the guarded startup transaction so a listener bind failure releases its lease alongside backgroundLifecycle - make the reconciler owner-scoped: each startServer registers its own live config and the shared timer stops only when the LAST owner releases, so stopping one of several servers cannot kill reconciliation for the others - keep the process-wide stop in runListenerShutdown for full shutdown --- src/server/index.ts | 15 ++-- src/usage/user-cost-overlay-reconciler.ts | 89 ++++++++++++++++------- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 2ed12eaf5f..395f938a78 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -29,10 +29,7 @@ import { reconcileLiveStateStores, setLiveStateStoreConfig, } from "../lib/state-store-registrations"; -import { - startUserCostOverlayReconciler, - stopUserCostOverlayReconciler, -} from "../usage/user-cost-overlay-reconciler"; +import { startUserCostOverlayReconciler } from "../usage/user-cost-overlay-reconciler"; import { configureAppOwnedMemoryBudget, enforceAppOwnedMemoryBudget, @@ -493,9 +490,6 @@ 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 { @@ -1547,7 +1546,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server loopbackListenerRef.stop(closeActiveConnections)] : []), async () => { - stopUserCostOverlayReconciler(); + userCostOverlayReconciler?.stop(); }, ], async () => { diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index 73343bb65d..0549be7d54 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -12,6 +12,11 @@ * 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"; @@ -26,7 +31,7 @@ import { export const USER_COST_OVERLAY_RECONCILE_INTERVAL_MS = 5_000; let reconcileTimer: ReturnType | null = null; -let reconcileLiveConfig: OcxConfig | null = null; +const owners = new Map(); let lastStamp: { mtimeMs: number; size: number } | null = null; function configStamp(): { mtimeMs: number; size: number } | null { @@ -58,17 +63,21 @@ function adoptDiskModelCosts(live: OcxConfig, disk: OcxConfig): void { } /** - * Remember provider rows present on disk but absent from the live routing - * config. They stay out of `liveConfig` (adding them would change the routing - * surface), but `persistConfigUnlocked` merges them back at serialization so - * an unrelated in-process save cannot erase the external provider or its - * overlay. + * Remember provider rows present on disk but absent from every live routing + * config. They stay out of the live configs (adding them would change the + * routing surface), but `persistConfigUnlocked` merges them back at + * serialization so an unrelated in-process save cannot erase the external + * provider or its overlay. */ -function rememberDiskOnlyProviders(live: OcxConfig, disk: OcxConfig): void { +function rememberDiskOnlyProviders(liveConfigs: readonly OcxConfig[], disk: OcxConfig): void { const preserved: Record = {}; - if (disk.providers && live.providers) { + if (disk.providers) { + const liveNames = new Set(); + for (const live of liveConfigs) { + for (const name of Object.keys(live.providers ?? {})) liveNames.add(name); + } for (const [name, provider] of Object.entries(disk.providers)) { - if (!live.providers[name] && provider) preserved[name] = structuredClone(provider); + if (!liveNames.has(name) && provider) preserved[name] = structuredClone(provider); } } setPreservedDiskOnlyProviders(Object.keys(preserved).length > 0 ? preserved : null); @@ -86,40 +95,66 @@ export function reconcileUserCostOverlaysFromDisk(liveConfig?: OcxConfig | null) const disk = diagnostics.config; if (liveConfig) { adoptDiskModelCosts(liveConfig, disk); - rememberDiskOnlyProviders(liveConfig, disk); } + rememberDiskOnlyProviders(liveConfig ? [liveConfig] : [], disk); // 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; } -/** Start the stat-based reconciler. Idempotent: a previous timer is stopped. */ +function reconcileForOwners(): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source !== "file") return; + const disk = diagnostics.config; + const liveConfigs = [...owners.values()].filter((config): config is OcxConfig => config !== null); + for (const live of liveConfigs) adoptDiskModelCosts(live, disk); + rememberDiskOnlyProviders(liveConfigs, disk); + refreshUserCostOverlays(disk); +} + +/** + * 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 } { - stopUserCostOverlayReconciler(); - reconcileLiveConfig = options.liveConfig ?? null; + const token = Symbol("user-cost-overlay-reconciler"); + owners.set(token, options.liveConfig ?? null); const intervalMs = options.intervalMs ?? USER_COST_OVERLAY_RECONCILE_INTERVAL_MS; - reconcileTimer = setInterval(() => { - const stamp = configStamp(); - if (!stamp) return; - if (lastStamp && lastStamp.mtimeMs === stamp.mtimeMs && lastStamp.size === stamp.size) return; - lastStamp = stamp; - try { - reconcileUserCostOverlaysFromDisk(reconcileLiveConfig); - } catch { - // Display-only reconciliation must never take the proxy down. - } - }, intervalMs); - reconcileTimer.unref?.(); - return { stop: stopUserCostOverlayReconciler }; + if (!reconcileTimer) { + reconcileTimer = setInterval(() => { + const stamp = configStamp(); + if (!stamp) return; + if (lastStamp && lastStamp.mtimeMs === stamp.mtimeMs && lastStamp.size === stamp.size) return; + lastStamp = stamp; + try { + reconcileForOwners(); + } catch { + // Display-only reconciliation must never take the proxy down. + } + }, intervalMs); + reconcileTimer.unref?.(); + } + return { + stop() { + owners.delete(token); + if (owners.size === 0) { + if (reconcileTimer) clearInterval(reconcileTimer); + reconcileTimer = null; + lastStamp = null; + } + }, + }; } +/** Process-wide stop: releases every owner and the shared timer. */ export function stopUserCostOverlayReconciler(): void { + owners.clear(); if (reconcileTimer) clearInterval(reconcileTimer); reconcileTimer = null; - reconcileLiveConfig = null; lastStamp = null; } From 5f1a6bb1b1797e8f57239760df9e4531fb516749 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 02:45:39 +0800 Subject: [PATCH 37/49] fix(usage): recompute/clear disk-only preservation on reconciler owner stop Wibias re-review (REQUEST CHANGES) found two reachable data-integrity failures in the preservation lifecycle: - With multiple servers, a newer owner can own a provider that an older owner still treats as disk-only; when the newer owner stops, the older owner's next unrelated save could erase the provider from disk. stop() now re-reads config and recomputes disk-only preservation against the remaining owners. - When the final owner stops (or the process-wide stop runs), preserved disk-only providers stayed in the module cache, so a later save could resurrect an externally deleted provider. Final-owner/process-wide stop now clears the cache. Also, reconcileUserCostOverlaysFromDisk no longer preserves every disk provider when no live routing config is available (CodeRabbit outside-diff finding): an overlay-only refresh must not turn later intentional provider deletions into resurrected rows. Regression coverage: A-lacks-beta/B-has-beta/B-stops/A-saves survives; final-owner stop -> delete beta -> save does not resurrect; process-wide stop -> delete beta -> save does not resurrect; overlay-only refresh with no live config does not preserve disk providers. --- src/usage/user-cost-overlay-reconciler.ts | 55 +++++- .../user-cost-overlay-live-reconcile.test.ts | 174 ++++++++++++++++++ 2 files changed, 226 insertions(+), 3 deletions(-) diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index 0549be7d54..e31dd04368 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -83,6 +83,31 @@ function rememberDiskOnlyProviders(liveConfigs: readonly OcxConfig[], disk: OcxC setPreservedDiskOnlyProviders(Object.keys(preserved).length > 0 ? preserved : null); } +/** + * 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 = [...owners.values()].filter( + (config): config is OcxConfig => config !== null, + ); + 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. * @@ -95,8 +120,8 @@ export function reconcileUserCostOverlaysFromDisk(liveConfig?: OcxConfig | null) const disk = diagnostics.config; if (liveConfig) { adoptDiskModelCosts(liveConfig, disk); + rememberDiskOnlyProviders([liveConfig], disk); } - rememberDiskOnlyProviders(liveConfig ? [liveConfig] : [], disk); // 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); @@ -108,8 +133,15 @@ function reconcileForOwners(): void { if (diagnostics.source !== "file") return; const disk = diagnostics.config; const liveConfigs = [...owners.values()].filter((config): config is OcxConfig => config !== null); - for (const live of liveConfigs) adoptDiskModelCosts(live, disk); - rememberDiskOnlyProviders(liveConfigs, disk); + 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); } @@ -145,6 +177,20 @@ export function startUserCostOverlayReconciler( if (reconcileTimer) clearInterval(reconcileTimer); reconcileTimer = null; 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; + } + // 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. } }, }; @@ -156,6 +202,9 @@ export function stopUserCostOverlayReconciler(): void { if (reconcileTimer) clearInterval(reconcileTimer); reconcileTimer = null; 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. */ diff --git a/tests/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts index ccf872626a..299cdd4989 100644 --- a/tests/user-cost-overlay-live-reconcile.test.ts +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -11,6 +11,7 @@ import { userCostOverlayVersion, } from "../src/usage/user-cost-overlays"; import { + reconcileUserCostOverlaysFromDisk, resetUserCostOverlayReconcilerForTests, startUserCostOverlayReconciler, stopUserCostOverlayReconciler, @@ -210,4 +211,177 @@ describe("cross-process user cost overlay reconciliation", () => { expect(persisted.providers.beta?.modelCosts).toEqual({ "beta-model": OVERLAY }); expect(resolveMatchedPrice("beta", "beta-model")?.source).toBe("user"); }); + + 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: beta is now owned by B, so the + // preservation registry drops it. + liveConfigA.providers.acme!.models = ["model-x", "model-extra"]; + writeFileSync(getConfigPath(), `${JSON.stringify(loadConfig(), null, 2)}\n`, "utf8"); + await Bun.sleep(150); + + // 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("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(); + }); }); From bec47b99269f672e0578b7e03458f4182317969c Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 03:45:13 +0800 Subject: [PATCH 38/49] fix(usage): owner-scoped reconciler teardown and honor min poll interval - drainAndShutdown now releases only this server's reconciler lease via the startServer stop override; the process-wide stop stays test/teardown-only and is documented as such. - startUserCostOverlayReconciler restarts the shared timer when a later owner requests a smaller interval and relaxes the cadence when it stops. - key-login live-update test asserts the running proxy's live DTO retains the overlay, not just disk. - reconcile tests wait on observed conditions instead of fixed sleeps and drop an ineffective in-memory mutation. - usage-cost fall-through test no longer pins the vendor catalog rate. --- src/server/lifecycle.ts | 8 ++- src/usage/user-cost-overlay-reconciler.ts | 67 ++++++++++++++----- tests/key-login-live-update.test.ts | 10 +++ tests/usage-cost.test.ts | 6 +- .../user-cost-overlay-live-reconcile.test.ts | 65 ++++++++++++++++-- 5 files changed, 131 insertions(+), 25 deletions(-) diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 5371a260c2..5cbe9fd42b 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -7,7 +7,6 @@ import { import { abortRestoreTrashJobAsync } from "../storage/restore-job"; import { stopStorageCleanupScheduler } from "../storage/policy-scheduler"; import { stopStateStoreSweeper } from "../lib/state-store-sweeper"; -import { stopUserCostOverlayReconciler } from "../usage/user-cost-overlay-reconciler"; import { cancelQueuedStorageWorkerSpawns, drainStorageWorkers, @@ -448,7 +447,12 @@ export async function drainAndShutdown( // then drain leftovers; failures must not prevent `server.stop`. stopStorageCleanupScheduler(); stopStateStoreSweeper(); - stopUserCostOverlayReconciler(); + // 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/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index e31dd04368..f087e6b284 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -31,7 +31,9 @@ import { 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 } | null = null; function configStamp(): { mtimeMs: number; size: number } | null { @@ -145,6 +147,41 @@ function reconcileForOwners(): void { refreshUserCostOverlays(disk); } +/** 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) 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 @@ -155,27 +192,16 @@ export function startUserCostOverlayReconciler( ): { stop(): void } { const token = Symbol("user-cost-overlay-reconciler"); owners.set(token, options.liveConfig ?? null); - const intervalMs = options.intervalMs ?? USER_COST_OVERLAY_RECONCILE_INTERVAL_MS; - if (!reconcileTimer) { - reconcileTimer = setInterval(() => { - const stamp = configStamp(); - if (!stamp) return; - if (lastStamp && lastStamp.mtimeMs === stamp.mtimeMs && lastStamp.size === stamp.size) return; - lastStamp = stamp; - try { - reconcileForOwners(); - } catch { - // Display-only reconciliation must never take the proxy down. - } - }, intervalMs); - reconcileTimer.unref?.(); - } + ownerIntervals.set(token, options.intervalMs ?? USER_COST_OVERLAY_RECONCILE_INTERVAL_MS); + syncReconcileTimer(); return { stop() { owners.delete(token); + ownerIntervals.delete(token); 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 @@ -183,6 +209,7 @@ export function startUserCostOverlayReconciler( 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 @@ -196,11 +223,19 @@ export function startUserCostOverlayReconciler( }; } -/** Process-wide stop: releases every owner and the shared timer. */ +/** + * 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 { 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. diff --git a/tests/key-login-live-update.test.ts b/tests/key-login-live-update.test.ts index 25837c118d..477701f200 100644 --- a/tests/key-login-live-update.test.ts +++ b/tests/key-login-live-update.test.ts @@ -8,6 +8,7 @@ import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; 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 @@ -78,6 +79,15 @@ describe("CLI key-login live-update overlay preservation", () => { 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); } diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 6b776e009d..f178a4d5b0 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -805,8 +805,10 @@ describe("provider cost overlay (user-configured)", () => { expect(price?.provider).toBe("acme-pabcdef"); expect(price?.source).toBe("jawcode"); expect(price?.cost4).not.toEqual(USER_PRICE); - // The compiled model-level price for claude-opus-4-6 (anthropic vendor). - expect(price?.cost4).toEqual({ input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }); + // 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", () => { diff --git a/tests/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts index 299cdd4989..380d30ba28 100644 --- a/tests/user-cost-overlay-live-reconcile.test.ts +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -9,6 +9,7 @@ import { refreshUserCostOverlays, resetPreservedDiskOnlyProvidersForTests, userCostOverlayVersion, + withPreservedDiskOnlyProviders, } from "../src/usage/user-cost-overlays"; import { reconcileUserCostOverlaysFromDisk, @@ -68,6 +69,15 @@ async function waitForOverlayLive( 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-")); @@ -170,10 +180,9 @@ describe("cross-process user cost overlay reconciliation", () => { // must keep serving the last good overlay instead of falling back to // defaults. writeFileSync(getConfigPath(), "{ not json", "utf8"); - await Bun.sleep(150); + await waitUntil(() => readConfigDiagnostics().source === "fallback"); expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); - expect(readConfigDiagnostics().source).toBe("fallback"); }); test("an externally added provider survives an unrelated live-config save", async () => { @@ -212,6 +221,48 @@ describe("cross-process user cost overlay reconciliation", () => { 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(""); + await waitForOverlayLive(); + + // Remove the fast owner: a fresh edit must NOT appear before the slow + // cadence elapses, then must be observed once it does. + fastOwner.stop(); + const versionBefore = userCostOverlayVersion(); + 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 Bun.sleep(120); + expect(userCostOverlayVersion()).toBe(versionBefore); + await waitUntil( + () => resolveMatchedPrice("acme", "model-x")?.cost4?.input === 3, + 2_000, + ); + + slowOwner.stop(); + }); + 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 }); @@ -242,10 +293,14 @@ describe("cross-process user cost overlay reconciliation", () => { const ownerB = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigB }); // Force a reconcile across both owners: beta is now owned by B, so the - // preservation registry drops it. - liveConfigA.providers.acme!.models = ["model-x", "model-extra"]; + // preservation registry drops it. 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"); - await Bun.sleep(150); + // Wait until the two-owner reconcile has actually run: with B owning + // beta, the serialization view of A no longer includes the preserved + // disk-only row. + await waitUntil(() => !("beta" in withPreservedDiskOnlyProviders(liveConfigA).providers)); // B stops; A remains without beta in its live config. ownerB.stop(); From 913fdcf0292ac62dda419b662dd7753576737222 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 03:58:10 +0800 Subject: [PATCH 39/49] fix(usage): strict legacy overlay shape, one-shot preservation clear, docs - validLegacyOverlayCosts requires exactly the four Cost4 own fields, so a malformed legacy row with an extra field (e.g. apiKey) collides instead of being carried into canonical openai config. - reconcileUserCostOverlaysFromDisk without a live config now mirrors the owners path: registered owners keep protecting disk-only rows, and with no live owners the stale preservation registry is cleared so a deleted provider cannot be resurrected by the next saveConfig. - config-user-edits afterEach resets the module-level overlay registry. - cadence regression test drops the timing-fragile negative assertion. - providers docs (en/ko/ru/zh-cn) state custom providers may target any OpenAI-compatible endpoint through the openai-chat adapter. --- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- src/providers/openai-tiers.ts | 6 ++- src/usage/user-cost-overlay-reconciler.ts | 14 +++++ tests/config-user-edits.test.ts | 6 ++- .../openai-provider-option-migration.test.ts | 13 +++++ .../user-cost-overlay-live-reconcile.test.ts | 52 +++++++++++++++++-- 9 files changed, 88 insertions(+), 11 deletions(-) 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 60a0f4341c..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,7 +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 호환·내부 공급자 ID도 유효합니다. 사용자 구성 가격은 Logs `~$` 및 Usage 추정에서 내장 카탈로그보다 우선합니다. 기존 항목도 현재 오버레이로 다시 계산되므로 가격을 편집하면 과거 합계가 바뀔 수 있습니다(폴백 순서: 사용자 설정 → jawcode 카탈로그 → expected-price 오버레이 → 모델별 벤더 가격). 전부 0인 항목은 다음 소스로 폴백합니다. 각 요율은 0 이상의 유한한 숫자이며 최대 1,000,000(100만 토큰당 USD)입니다. 범위를 벗어난 행은 관리 경계에서 거부되고 로드 시 삭제됩니다. 표시 전용 추정이며 라우팅·계정 선택·할당량·청구에는 영향을 주지 않습니다. | +| `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 3804153a81..8b9724937d 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -83,7 +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, local OpenAI-compatible, and 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. | +| `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 b72349c7b3..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,7 +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-совместимые и внутренние провайдеры работают даже без строки во встроенных каталогах. Пользовательские цены имеют приоритет над встроенными каталогами в оценках `~$` в Logs и Usage; исторические записи пересчитываются по текущему оверлею, поэтому изменение цены может сдвинуть прошлые суммы (порядок: пользователь → каталог jawcode → expected-price overlay → вендорская цена модели); полностью нулевая запись переходит к следующему источнику. Каждая ставка должна быть неотрицательным конечным числом не более 1 000 000 (USD за 1M токенов); строки вне диапазона отклоняются на управляющей границе и отбрасываются при загрузке. Только оценка для отображения: оверлеи не влияют на маршрутизацию, выбор аккаунта, квоты или биллинг. | +| `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 bf68d25dc9..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,7 +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 兼容和内部提供者的 ID 同样有效。用户配置的价格在 Logs 的 `~$` 和 Usage 估算中优先于内置目录;历史条目也会按当前覆盖项重新计价,因此修改价格可能改变过去的总额(回退顺序:用户配置 → jawcode 目录 → expected-price 覆盖 → 模型级厂商价格);全零条目会回退到该顺序中的下一个来源。每个费率必须是大于等于 0 的有限数字,且不超过 1,000,000(每 100 万 token 的美元数);超出范围的条目会在管理边界被拒绝,并在加载时被丢弃。仅用于显示的估算:覆盖项不影响路由、账户选择、配额或计费。 | +| `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/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 6603b9785b..f1156cb447 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -95,11 +95,13 @@ function managedLegacyMultiOverlay( /** Shape check for a legacy overlay: a plain record of complete non-negative finite Cost4 rows. */ function validLegacyOverlayCosts(value: unknown): boolean { if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const fields = ["input", "output", "cacheRead", "cacheWrite"] as const; return Object.values(value as Record).every(entry => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; const rates = entry as Record; - return (["input", "output", "cacheRead", "cacheWrite"] as const) - .every(key => typeof rates[key] === "number" + 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); diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index f087e6b284..05e81f8013 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -123,6 +123,20 @@ export function reconcileUserCostOverlaysFromDisk(liveConfig?: OcxConfig | null) if (liveConfig) { adoptDiskModelCosts(liveConfig, disk); rememberDiskOnlyProviders([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 = [...owners.values()].filter( + (config): config is OcxConfig => config !== null, + ); + 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. diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 2385663409..ded0912299 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -16,7 +16,7 @@ import { } from "../src/config"; import { legacyCustomModelCatalogSlugs } from "../src/codex/custom-model-catalog-migration"; import { rateLimitRetryPolicyFor } from "../src/providers/key-failover"; -import { activeUserCostOverlays } from "../src/usage/user-cost-overlays"; +import { activeUserCostOverlays, refreshUserCostOverlays } from "../src/usage/user-cost-overlays"; import type { OcxConfig } from "../src/types"; /** @@ -63,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 }); diff --git a/tests/openai-provider-option-migration.test.ts b/tests/openai-provider-option-migration.test.ts index a5dd0f59b1..436b78912f 100644 --- a/tests/openai-provider-option-migration.test.ts +++ b/tests/openai-provider-option-migration.test.ts @@ -307,6 +307,19 @@ describe("OpenAI provider option migration matrix", () => { 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/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts index 380d30ba28..a07748f7dd 100644 --- a/tests/user-cost-overlay-live-reconcile.test.ts +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -240,9 +240,11 @@ describe("cross-process user cost overlay reconciliation", () => { await waitForOverlayLive(); // Remove the fast owner: a fresh edit must NOT appear before the slow - // cadence elapses, then must be observed once it does. + // 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 versionBefore = userCostOverlayVersion(); const edit = await runChild(` import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -253,8 +255,6 @@ describe("cross-process user cost overlay reconciliation", () => { `); expect(edit.exitCode).toBe(0); expect(edit.stderr).toBe(""); - await Bun.sleep(120); - expect(userCostOverlayVersion()).toBe(versionBefore); await waitUntil( () => resolveMatchedPrice("acme", "model-x")?.cost4?.input === 3, 2_000, @@ -263,6 +263,50 @@ describe("cross-process user cost overlay reconciliation", () => { 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 }); From ace6296abc6df02dbf6d774d6bd626895c23d4f8 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 04:10:18 +0800 Subject: [PATCH 40/49] test(usage): reset overlay registry in key-login afterEach --- tests/key-login-live-update.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/key-login-live-update.test.ts b/tests/key-login-live-update.test.ts index 477701f200..6728e952c4 100644 --- a/tests/key-login-live-update.test.ts +++ b/tests/key-login-live-update.test.ts @@ -7,6 +7,7 @@ import { commitKeyLoginProvider, providerConfigFromKeyLoginProvider } from "../s 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"; @@ -44,6 +45,9 @@ beforeEach(() => { }); 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(); From 7db360da82c60191a1a505311f05a47c47c9b7b5 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 05:01:26 +0800 Subject: [PATCH 41/49] fix(usage): stamp usage-summary cache with the captured overlay version - Capture the overlay version before reading/computing the summary and stamp that captured version; if the version changed mid-read, serve the summary uncached so a mixed-price entry is never accepted as current. - Add a deterministic regression covering an overlay change during the read/summary lifecycle. - Add the two new locale keys to the Turkish catalog after rebasing onto dev. --- gui/src/i18n/tr.ts | 2 + src/server/management/logs-usage-routes.ts | 15 +++++- tests/api-usage.test.ts | 60 +++++++++++++++++++++- 3 files changed, 74 insertions(+), 3 deletions(-) diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6fd435e533..e684bcbbd4 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": "Sağlayıcı tarafından yapılandırılan 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": "Sağlayıcı tarafından yapılandırılan bir fiyat katmanı kullanıldı.", "logs.col.error": "Hata", "logs.col.upstreamReason": "Yukarı akış nedeni", "logs.col.duration": "Süre", diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 2b60bfd1a7..50a66ac908 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -201,6 +201,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { } }); + 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); From 84fc82d26f872b110c96fe8cf94d848370464a8d Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 05:26:32 +0800 Subject: [PATCH 42/49] fix(usage): widen config change stamp, strengthen cadence/overlay tests - configStamp() now includes ctimeMs and ino so a same-size edit inside one coarse mtime tick (or an atomic-rename replacement) still triggers a reconcile. - usage-cost fall-through test no longer pins the expected-overlay rate. - reconcile tests assert the child writer exit status and bound the first wait to 200ms so only the 20ms fast owner can satisfy it. --- src/usage/user-cost-overlay-reconciler.ts | 19 +++++++++++++++---- tests/usage-cost.test.ts | 4 +++- .../user-cost-overlay-live-reconcile.test.ts | 9 +++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index 05e81f8013..75b8cc330c 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -34,12 +34,17 @@ let reconcileTimer: ReturnType | null = null; let reconcileTimerMs = 0; const owners = new Map(); const ownerIntervals = new Map(); -let lastStamp: { mtimeMs: number; size: number } | null = null; +let lastStamp: { mtimeMs: number; size: number; ctimeMs: number; ino: number } | null = null; -function configStamp(): { mtimeMs: number; size: number } | null { +function configStamp(): { mtimeMs: number; size: number; ctimeMs: number; ino: number } | null { try { const stat = statSync(getConfigPath()); - return { mtimeMs: stat.mtimeMs, size: stat.size }; + return { + mtimeMs: stat.mtimeMs, + size: stat.size, + ctimeMs: stat.ctimeMs, + ino: Number(stat.ino), + }; } catch { return null; } @@ -184,7 +189,13 @@ function syncReconcileTimer(): void { reconcileTimer = setInterval(() => { const stamp = configStamp(); if (!stamp) return; - if (lastStamp && lastStamp.mtimeMs === stamp.mtimeMs && lastStamp.size === stamp.size) return; + if ( + lastStamp + && lastStamp.mtimeMs === stamp.mtimeMs + && lastStamp.size === stamp.size + && lastStamp.ctimeMs === stamp.ctimeMs + && lastStamp.ino === stamp.ino + ) return; lastStamp = stamp; try { reconcileForOwners(); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index f178a4d5b0..d717f536d0 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -860,7 +860,9 @@ describe("provider cost overlay (user-configured)", () => { }]; const price = resolveMatchedPrice("deepseek", "deepseek-chat", undefined, zero); expect(price?.source).toBe("expected"); - expect(price?.cost4.input).toBe(0.27); + // 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", () => { diff --git a/tests/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts index a07748f7dd..c7bad75415 100644 --- a/tests/user-cost-overlay-live-reconcile.test.ts +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -168,12 +168,14 @@ describe("cross-process user cost overlay reconciliation", () => { startUserCostOverlayReconciler({ intervalMs: 20, liveConfig }); // Make the overlay live first through the same cross-process path. - await runChild(` + 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 @@ -237,7 +239,10 @@ describe("cross-process user cost overlay reconciliation", () => { `); expect(exitCode).toBe(0); expect(stderr).toBe(""); - await waitForOverlayLive(); + // 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 From 9329797200fb0d3ba6a7a098e891a94b5f1322d3 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 06:21:09 +0800 Subject: [PATCH 43/49] fix(config): redact provider names in all validation paths; clarify tr overlay wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Every provider validation issue path now passes the provider key through redactSecretString(name), matching the modelCosts path, so token-shaped provider keys cannot leak through ocx config validate/import diagnostics. - Turkish overlay strings now say the price tier is user-configured (Kullanıcı tarafından yapılandırılan ...), consistent with ko/ru/zh. --- gui/src/i18n/tr.ts | 4 ++-- src/config.ts | 32 ++++++++++++++++---------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index e684bcbbd4..494fa07211 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -648,7 +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": "Sağlayıcı tarafından yapılandırılan fiyat katmanı", + "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", @@ -674,7 +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": "Sağlayıcı tarafından yapılandırılan bir fiyat katmanı 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/src/config.ts b/src/config.ts index 59df77bf58..222323ff76 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1243,7 +1243,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)", }); } @@ -1254,7 +1254,7 @@ const configSchema = z.object({ code: "custom", path: [ "providers", - name, + redactSecretString(name), openRouterRoutingError.startsWith("modelOpenRouterRouting") ? "modelOpenRouterRouting" : "openRouterRouting", @@ -1265,7 +1265,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", }); } @@ -1273,7 +1273,7 @@ const configSchema = z.object({ if (baseUrlError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "baseUrl"], + path: ["providers", redactSecretString(name), "baseUrl"], message: baseUrlError, }); } else { @@ -1281,7 +1281,7 @@ const configSchema = z.object({ if (destinationError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "baseUrl"], + path: ["providers", redactSecretString(name), "baseUrl"], message: destinationError, }); } @@ -1290,7 +1290,7 @@ const configSchema = z.object({ if (responsesPathError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "responsesPath"], + path: ["providers", redactSecretString(name), "responsesPath"], message: responsesPathError, }); } @@ -1298,7 +1298,7 @@ const configSchema = z.object({ if (headersError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "headers"], + path: ["providers", redactSecretString(name), "headers"], message: headersError, }); } @@ -1316,7 +1316,7 @@ const configSchema = z.object({ if (apiKeyTransportError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "apiKeyTransport"], + path: ["providers", redactSecretString(name), "apiKeyTransport"], message: apiKeyTransportError, }); } @@ -1329,7 +1329,7 @@ const configSchema = z.object({ if (modelAdaptersError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelAdapters"], + path: ["providers", redactSecretString(name), "modelAdapters"], message: modelAdaptersError, }); } @@ -1342,7 +1342,7 @@ const configSchema = z.object({ if (preferHostedToolsError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelPreferHostedTools"], + path: ["providers", redactSecretString(name), "modelPreferHostedTools"], message: preferHostedToolsError, }); } @@ -1353,7 +1353,7 @@ const configSchema = z.object({ if (maxInputError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelMaxInputTokens"], + path: ["providers", redactSecretString(name), "modelMaxInputTokens"], message: maxInputError, }); } @@ -1364,7 +1364,7 @@ const configSchema = z.object({ if (reasoningSummariesError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelSupportsReasoningSummaries"], + path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"], message: reasoningSummariesError, }); } @@ -1375,7 +1375,7 @@ const configSchema = z.object({ if (reasoningSummaryDeliveryError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelReasoningSummaryDelivery"], + path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"], message: reasoningSummaryDeliveryError, }); } @@ -1386,7 +1386,7 @@ const configSchema = z.object({ if (defaultMaxOutputError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "defaultMaxOutputTokens"], + path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"], message: defaultMaxOutputError, }); } @@ -1397,7 +1397,7 @@ const configSchema = z.object({ if (maxOutputError) { ctx.addIssue({ code: "custom", - path: ["providers", name, "modelMaxOutputTokens"], + path: ["providers", redactSecretString(name), "modelMaxOutputTokens"], message: maxOutputError, }); } @@ -1412,7 +1412,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", }); } From 02f511370f01f5cd8edc684921328e07d9633565 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Mon, 10 Aug 2026 07:55:03 +0800 Subject: [PATCH 44/49] fix(usage): owner-aware disk-only preservation across live owners rememberDiskOnlyProviders now preserves a disk provider whenever at least one live owner lacks it, instead of dropping it as soon as any owner owns it. One server's older live projection can no longer erase a provider owned by another active server: an unrelated saveConfig from the older owner re-adds the row through the shared preservation registry. Regression added: A lacks beta -> B has beta -> both remain alive -> A saves -> beta survives on disk. --- src/usage/user-cost-overlay-reconciler.ts | 24 ++++---- .../user-cost-overlay-live-reconcile.test.ts | 59 ++++++++++++++++--- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index 75b8cc330c..dde6734391 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -70,21 +70,23 @@ function adoptDiskModelCosts(live: OcxConfig, disk: OcxConfig): void { } /** - * Remember provider rows present on disk but absent from every live routing - * config. They stay out of the live configs (adding them would change the - * routing surface), but `persistConfigUnlocked` merges them back at - * serialization so an unrelated in-process save cannot erase the external - * provider or its overlay. + * 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) { - const liveNames = new Set(); - for (const live of liveConfigs) { - for (const name of Object.keys(live.providers ?? {})) liveNames.add(name); - } + if (disk.providers && liveConfigs.length > 0) { for (const [name, provider] of Object.entries(disk.providers)) { - if (!liveNames.has(name) && provider) preserved[name] = structuredClone(provider); + 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); diff --git a/tests/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts index c7bad75415..e5990df536 100644 --- a/tests/user-cost-overlay-live-reconcile.test.ts +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -341,15 +341,16 @@ describe("cross-process user cost overlay reconciliation", () => { expect(liveConfigB.providers.beta).toBeDefined(); const ownerB = startUserCostOverlayReconciler({ intervalMs: 20, liveConfig: liveConfigB }); - // Force a reconcile across both owners: beta is now owned by B, so the - // preservation registry drops it. 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. + // 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"); - // Wait until the two-owner reconcile has actually run: with B owning - // beta, the serialization view of A no longer includes the preserved - // disk-only row. - await waitUntil(() => !("beta" in withPreservedDiskOnlyProviders(liveConfigA).providers)); + // 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(); @@ -365,6 +366,48 @@ describe("cross-process user cost overlay reconciliation", () => { 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 }); From d3cf2485e197daef312af903657aedfaae42ef92 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:00:34 +0200 Subject: [PATCH 45/49] fix(usage): let live owners delete preserved providers Track provider ownership across live config projections so stale-owner preservation still protects externally added rows, while an owner that previously owned a provider can intentionally delete it. Successful deletion is propagated to all active preservation owners so a later stale save cannot resurrect the provider. Regression covers A lacking beta, B/C owning beta, an unrelated A save preserving beta, a reconcile tick during B's delete gap, and B's explicit deletion surviving a later C save. --- src/usage/user-cost-overlay-reconciler.ts | 15 +- src/usage/user-cost-overlays.ts | 171 +++++++++++++++++- .../user-cost-overlay-provider-delete.test.ts | 117 ++++++++++++ 3 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 tests/user-cost-overlay-provider-delete.test.ts diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index dde6734391..4b923086a2 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -23,8 +23,11 @@ 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. */ @@ -90,6 +93,9 @@ function rememberDiskOnlyProviders(liveConfigs: readonly OcxConfig[], disk: OcxC } } 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); } /** @@ -218,13 +224,17 @@ export function startUserCostOverlayReconciler( options: { intervalMs?: number; liveConfig?: OcxConfig | null } = {}, ): { stop(): void } { const token = Symbol("user-cost-overlay-reconciler"); - owners.set(token, options.liveConfig ?? null); + 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; @@ -258,6 +268,9 @@ export function startUserCostOverlayReconciler( * 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); diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts index c95abee3dd..74f5868c3b 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -29,6 +29,91 @@ 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). @@ -41,27 +126,92 @@ export function setPreservedDiskOnlyProviders( 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 { - if (!preservedDiskOnlyProviders || Object.keys(preservedDiskOnlyProviders).length === 0) { + 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; } - return { - ...config, - providers: { - ...preservedDiskOnlyProviders, - ...config.providers, - }, - }; + + 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(); } /** True when `value` is a complete cost entry: all four rates are non-negative finite numbers. */ @@ -81,6 +231,11 @@ function validCost4(value: unknown): value is ProviderCostOverlay { * 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) { 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..d0512dad37 --- /dev/null +++ b/tests/user-cost-overlay-provider-delete.test.ts @@ -0,0 +1,117 @@ +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 { + refreshUserCostOverlays, + resetPreservedDiskOnlyProvidersForTests, + 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 }); + + // 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. + delete liveConfigB.providers.beta; + expect(reconcileUserCostOverlaysFromDisk()).toBe(true); + saveConfigPreservingClaudeCode(liveConfigB); + persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.providers.beta).toBeUndefined(); + + // 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(); + }); +}); From af7937a2d5162e1aed6797ace6f9ee2aae702707 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:03:57 +0200 Subject: [PATCH 46/49] fix(usage): address overlay review findings --- src/config.ts | 38 +++-- src/usage/user-cost-overlay-reconciler.ts | 43 ++++- src/usage/user-cost-overlays.ts | 17 +- ...ost-overlay-coderabbit-regressions.test.ts | 156 ++++++++++++++++++ .../user-cost-overlay-provider-delete.test.ts | 8 + 5 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 tests/user-cost-overlay-coderabbit-regressions.test.ts diff --git a/src/config.ts b/src/config.ts index 222323ff76..6c0e97ab1d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -75,7 +75,12 @@ import { import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; -import { refreshUserCostOverlays, withPreservedDiskOnlyProviders } from "./usage/user-cost-overlays"; +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, @@ -712,8 +717,6 @@ export function providerHeadersConfigError(headers: unknown): string | null { * 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. */ -const MODEL_COST_RATE_KEYS = ["input", "output", "cacheRead", "cacheWrite"] as const; - export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -729,16 +732,17 @@ export function providerModelCostsConfigError(value: unknown, field = "modelCost 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 MODEL_COST_RATE_KEYS) { + for (const key of COST4_RATE_KEYS) { const rate = rates[key]; - if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0 || rate > MAX_COST4_RATE) { + 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) => !(MODEL_COST_RATE_KEYS as readonly string[]).includes(key)); + 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)`; } @@ -762,9 +766,12 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record - typeof rate === "number" && Number.isFinite(rate) && rate >= 0 && rate <= MAX_COST4_RATE; - if (valid(input) && valid(output) && valid(cacheRead) && valid(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; @@ -1933,10 +1940,11 @@ function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig) } /** - * Load and validate config.json into an OcxConfig. Missing or broken files fall - * back to defaults (invalid files are backed up first); a partially-invalid - * config is merged with defaults so providers and pool accounts survive. Also - * refreshes the user cost-overlay registry from the resulting config. + * 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(); @@ -1989,10 +1997,10 @@ export function loadConfig(): OcxConfig { } // Merge couldn't fix it — truly broken config warnAndBackupInvalidConfig(configPath, result.error); - return withRefreshedCostOverlays(getDefaultConfig()); + return getDefaultConfig(); } catch (error) { warnAndBackupInvalidConfig(configPath, error); - return withRefreshedCostOverlays(getDefaultConfig()); + return getDefaultConfig(); } } diff --git a/src/usage/user-cost-overlay-reconciler.ts b/src/usage/user-cost-overlay-reconciler.ts index 4b923086a2..5b09d7334e 100644 --- a/src/usage/user-cost-overlay-reconciler.ts +++ b/src/usage/user-cost-overlay-reconciler.ts @@ -38,6 +38,7 @@ 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 { @@ -53,6 +54,22 @@ function configStamp(): { mtimeMs: number; size: number; ctimeMs: number; ino: n } } +/** 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 @@ -111,9 +128,7 @@ function rememberDiskOnlyProviders(liveConfigs: readonly OcxConfig[], disk: OcxC function recomputePreservedDiskOnlyProviders(): void { const diagnostics = readConfigDiagnostics(); if (diagnostics.source !== "file") return; - const liveConfigs = [...owners.values()].filter( - (config): config is OcxConfig => config !== null, - ); + const liveConfigs = liveConfigsForPreservation(); if (liveConfigs.length > 0) { rememberDiskOnlyProviders(liveConfigs, diagnostics.config); } else { @@ -135,15 +150,16 @@ export function reconcileUserCostOverlaysFromDisk(liveConfig?: OcxConfig | null) const disk = diagnostics.config; if (liveConfig) { adoptDiskModelCosts(liveConfig, disk); - rememberDiskOnlyProviders([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 = [...owners.values()].filter( - (config): config is OcxConfig => config !== null, - ); + const liveConfigs = liveConfigsForPreservation(); if (liveConfigs.length > 0) { for (const live of liveConfigs) adoptDiskModelCosts(live, disk); rememberDiskOnlyProviders(liveConfigs, disk); @@ -159,9 +175,12 @@ export function reconcileUserCostOverlaysFromDisk(liveConfig?: OcxConfig | null) function reconcileForOwners(): void { const diagnostics = readConfigDiagnostics(); - if (diagnostics.source !== "file") return; + if (diagnostics.source !== "file") { + if (diagnostics.source === "fallback") invalidReconcileCount += 1; + return; + } const disk = diagnostics.config; - const liveConfigs = [...owners.values()].filter((config): config is OcxConfig => config !== null); + const liveConfigs = liveConfigsForPreservation(); if (liveConfigs.length > 0) { for (const live of liveConfigs) adoptDiskModelCosts(live, disk); rememberDiskOnlyProviders(liveConfigs, disk); @@ -174,6 +193,11 @@ function reconcileForOwners(): void { 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; @@ -285,4 +309,5 @@ export function stopUserCostOverlayReconciler(): void { /** 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 index 74f5868c3b..22af57e87a 100644 --- a/src/usage/user-cost-overlays.ts +++ b/src/usage/user-cost-overlays.ts @@ -214,15 +214,22 @@ export function resetPreservedDiskOnlyProvidersForTests(): void { 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 (["input", "output", "cacheRead", "cacheWrite"] as const) - .every(key => typeof entry[key] === "number" - && Number.isFinite(entry[key]) - && entry[key] >= 0 - && entry[key] <= MAX_COST4_RATE); + return COST4_RATE_KEYS.every(key => isValidCost4Rate(entry[key])); } /** 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-provider-delete.test.ts b/tests/user-cost-overlay-provider-delete.test.ts index d0512dad37..ad0b1b0bbb 100644 --- a/tests/user-cost-overlay-provider-delete.test.ts +++ b/tests/user-cost-overlay-provider-delete.test.ts @@ -10,9 +10,12 @@ import { 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 { @@ -91,16 +94,21 @@ describe("provider deletion with disk-only preservation", () => { 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. From 70150d9890dec5aaad128b081cb36d3bfdbb96d5 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:42:58 +0200 Subject: [PATCH 47/49] fix(ci): stop overlay reconciler leak and CLI spawn hangs on shard 4 Stop the user cost overlay reconciler in api-usage afterEach so isolate workers do not keep a poll timer alive into later shard files. Harden cli-restore-back with spawn timeouts and isolated HOME for the help test, matching cli-provider budgets. --- tests/api-usage.test.ts | 5 +++ tests/cli-restore-back.test.ts | 69 ++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index e265193c67..33e12d0869 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -7,6 +7,7 @@ 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"; @@ -79,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(); 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 }); } }); From 5dfeb4bb4f2d4d36c5ae3688fcacf29996487504 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:47:16 +0200 Subject: [PATCH 48/49] fix(ci): isolate api-usage tests into dedicated Linux job Exclude tests/api-usage.test.ts from sharded Linux test legs and run it in a fresh Bun process, matching the storage-policy isolation pattern. Prevents startServer overlay reconciler cycles from wedging shard 4 after cli-restore-back. --- .github/workflows/ci.yml | 48 +++++++++++++++++++++---- tests/zz-ci-api-usage-isolation.test.ts | 39 ++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 tests/zz-ci-api-usage-isolation.test.ts 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/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"); +}); From 72b146bfd620c569aee3ecd0fcdf4c2b39386f05 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:32:52 +0200 Subject: [PATCH 49/49] test(usage): observe invalid reconcile via test counter --- tests/user-cost-overlay-live-reconcile.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/user-cost-overlay-live-reconcile.test.ts b/tests/user-cost-overlay-live-reconcile.test.ts index e5990df536..300b6ad923 100644 --- a/tests/user-cost-overlay-live-reconcile.test.ts +++ b/tests/user-cost-overlay-live-reconcile.test.ts @@ -2,7 +2,7 @@ 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, readConfigDiagnostics, saveConfig } from "../src/config"; +import { getConfigPath, loadConfig, saveConfig } from "../src/config"; import { resolveMatchedPrice } from "../src/usage/cost"; import { activeUserCostOverlays, @@ -16,6 +16,7 @@ import { resetUserCostOverlayReconcilerForTests, startUserCostOverlayReconciler, stopUserCostOverlayReconciler, + userCostOverlayInvalidReconcileCountForTests, } from "../src/usage/user-cost-overlay-reconciler"; import type { OcxConfig } from "../src/types"; @@ -181,8 +182,9 @@ describe("cross-process user cost overlay reconciliation", () => { // 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(() => readConfigDiagnostics().source === "fallback"); + await waitUntil(() => userCostOverlayInvalidReconcileCountForTests() > invalidCountBefore); expect(resolveMatchedPrice("acme", "model-x")?.source).toBe("user"); });