From 180b8b4326853d139ac25a2410d588730de76734 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 00:35:30 +0800 Subject: [PATCH 01/10] 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 18688 -> 20076 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 a9603702bf..93d9f072ce 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -72,6 +72,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 639f00c7ae..1ad07bc900 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -571,6 +571,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 cbfd40892f..7b546c80fe 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -598,6 +598,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 23df35de48..b29f335ba5 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -556,6 +556,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 b080304114..00fcd53f19 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -590,6 +590,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 98b7c6243b..7bfa3af714 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -588,6 +588,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 c77a6fb6dc..2ae25b6fbb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -583,6 +583,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 ebe177532d..dac61bc748 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -42,7 +42,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 } @@ -290,6 +294,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 72defeb9ca..e0c8dbd125 100644 --- a/src/config.ts +++ b/src/config.ts @@ -45,6 +45,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, @@ -661,6 +662,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, @@ -1117,6 +1144,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({ @@ -1620,7 +1655,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/, ""); @@ -1634,7 +1669,7 @@ export function loadConfig(): OcxConfig { warnDegradedApiKeys(parsed, config); warnDegradedClaudeSubagentEffort(parsed); warnDegradedNativeSubagentConfig(parsed, config); - 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 @@ -1653,17 +1688,22 @@ export function loadConfig(): OcxConfig { warnDegradedApiKeys(parsed, config); warnDegradedClaudeSubagentEffort(parsed); warnDegradedNativeSubagentConfig(parsed, config); - 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"; @@ -1939,6 +1979,9 @@ export function withConfigMutationLockSync(fn: () => T): T { function persistConfigUnlocked(config: OcxConfig): void { const configPath = getConfigPath(); atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n"); + // Keep the runtime overlay registry in sync with every persist path + // (saveConfig and mutatePersistedConfig both funnel through here). + refreshUserCostOverlays(config); } export function saveConfig(config: OcxConfig): void { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 2acea28480..353d53314f 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"; @@ -433,6 +434,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"); @@ -526,6 +529,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 ae84aa6740..17dddce93f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -945,6 +945,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. @@ -1055,6 +1067,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 45f78c62645b9573d651bdc259edd0ade27b505d..3cbb60ca2adb03dae96d8950a34600374fc2f243 100644 GIT binary patch delta 1292 zcmb7D&1(}u6eqS)8_*-o!n4(?6gn`X_i55j}cyWVOj4Pq>jKQ_8Ou@VzGgo7m0!LR6i8Tc`H1a3n{xtvc&o*b?E zpo69%rh=q75A?+YD*d5a>!n|n~ z-0>*sF|iq+aiQDsts!uysfmf%>Cye?&1sH3dN0zy(n4SmtBp-bsXrG3og!dd=h!$K z1t^u=bi3{*;-JIg!Zit`l6fhE%`snvX^?cfwB77jZIJXKX;_sN6d^!SgCf?-dpQ{a zD~T+E*JNom0@w--O`>X>NE}b(3ote1%_2QgWCoQ=C0X#Za*Kni3c{AQv(!&bzva>*^d%hdo6@Su_P`>1>hMH{^9Glm`;S z6Dje1Jll6+<*;yGq*7jejC81h%HH8*_F6Ba#@R2C9TCrGw*!G4eQx=1I5_>6;4+9uvnlbl^6r?wSpLpB ZFRQix%aRuH3vYrZgg$3ErG?J}zX6ra!K(lO delta 318 zcmaDehp}N2}j zB(Wqj8R({xjLbZRM4%J%lT(X}Cs#}BsA(!xb148pa(-TMi9&K>az?6mYNdj$LV`*) z%!RcKlf`7VPrfZvFga3o$7E?m>B+Hj&YRcC&0}QLoV->.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 d583471390..752e7741f0 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 }; @@ -656,3 +662,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 7a4ce62a88d1f7f605180bc7873580d272b1014f Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 00:57:15 +0800 Subject: [PATCH 02/10] 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 | 13 +++++++ 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, 104 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 7c3ab0288a..0f72bee54b 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -65,6 +65,7 @@ namespace 付き combo alias はその namespace prefix に selector を再利 | `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 a389a79fca..2f73d21075 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -65,6 +65,7 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 | `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 93d9f072ce..c88c86c479 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -72,7 +72,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 d2bb183b17..7f821eca4f 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -75,6 +75,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 b400ffbd43..d80e0d953d 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 @@ -64,6 +64,7 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex | `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 1ad07bc900..ebb961877c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -546,6 +546,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", @@ -571,7 +572,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 7b546c80fe..fcdea79341 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -573,6 +573,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 b29f335ba5..7bbd02d4d3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -531,6 +531,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 00fcd53f19..b2e9b8350f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -565,6 +565,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 7bfa3af714..4fdfa1a1ce 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -563,6 +563,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 2ae25b6fbb..5811544fc2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -558,6 +558,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 dac61bc748..dc1ddb31db 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -56,7 +56,7 @@ interface MatchedPriceInfo { provider: string; modelId: string; jawcodeProvider?: string; - source: "jawcode" | "expected"; + source: "jawcode" | "expected" | "user"; sourceRef?: string; verifiedAt?: string; status: "verified" | "verified-derived"; @@ -311,10 +311,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 e0c8dbd125..df426c7ff5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1648,6 +1648,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(); @@ -1699,6 +1705,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; @@ -1976,6 +1983,11 @@ export function withConfigMutationLockSync(fn: () => T): T { } } +/** + * Atomic config.json write WITHOUT the mutation lock; callers must hold + * `withConfigMutationLockSync`. Refreshes the cost-overlay registry from the + * persisted config so runtime estimates follow every save path. + */ function persistConfigUnlocked(config: OcxConfig): void { const configPath = getConfigPath(); atomicWriteFile(configPath, JSON.stringify(config, null, 2) + "\n"); @@ -1984,6 +1996,7 @@ function persistConfigUnlocked(config: OcxConfig): void { refreshUserCostOverlays(config); } +/** 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 353d53314f..cd3f82e1ba 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 { 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"; let _corsOrigin = "http://localhost:10100"; @@ -506,6 +506,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)) { @@ -529,7 +558,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { "modelContextWindows", "defaultMaxOutputTokens", "modelMaxOutputTokens", - "modelCosts", "openRouterRouting", "modelOpenRouterRouting", "reasoningEfforts", @@ -546,6 +574,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 3cbb60ca2a..be50d97799 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 51016721f232c69d3afa70dce69440f65db1e817 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 01:06:04 +0800 Subject: [PATCH 03/10] 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 0f72bee54b..2273a09490 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -65,7 +65,7 @@ namespace 付き combo alias はその namespace prefix に selector を再利 | `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 2f73d21075..b602745dee 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -65,7 +65,7 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 | `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 7f821eca4f..1d2a0c850c 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -75,7 +75,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 d80e0d953d..8af453fa7b 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 @@ -64,7 +64,7 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex | `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 4fdfa1a1ce..d97d04e8bb 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -589,7 +589,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 feff1195edb83c7a4ddb7dd7724eb24fa7d19b54 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 18:55:28 +0800 Subject: [PATCH 04/10] =?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 f363ed0125..b8ff034a07 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -524,7 +524,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 c5db9f883f2245573a021fe832dead14adae6be2 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 19:43:06 +0800 Subject: [PATCH 05/10] 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 50cc14787f85af5528078a87bf5dcacccea100e9 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 21:06:27 +0800 Subject: [PATCH 06/10] 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 32c1b8e77e..d0694ff485 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1514,6 +1514,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 — @@ -1732,6 +1780,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); @@ -1909,6 +1958,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 0e37a89d8acfb146aabd76abf65d4c0106a69981 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 21:38:49 +0800 Subject: [PATCH 07/10] 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 be50d97799..3388742f3d 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 jawcodeProvider = resolveJawcodeProvider(provider); const jawcode = jawcodeProvider ? getJawcodeModelMetadata(jawcodeProvider, 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 724a4396396fbaadb6389ee37a81b43dd4c133de Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 22:43:28 +0800 Subject: [PATCH 08/10] 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 d0694ff485..4e3e53f38f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -703,14 +703,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 66fc6a3d4fc6ac551d319067f95fd2b567a7cbec Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 22:53:17 +0800 Subject: [PATCH 09/10] 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 7dda69477980e4db4142f89630c91bcc0157c1d8 Mon Sep 17 00:00:00 2001 From: HarryZhou <2373256746@qq.com> Date: Wed, 5 Aug 2026 23:15:32 +0800 Subject: [PATCH 10/10] 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 b8ff034a07..12971ae958 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -437,7 +437,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,