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 19e07d53d2..60308ce8e1 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 をキーにし、値は `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 11ed1e2e74..bdd77d5117 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를 키로 사용하며 값은 `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/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 70962e4c31..f801711e1a 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 } }`. 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 7dc87358a0..3ba13e8883 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 модели, значение — четыре поля: `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 450db54372..f48794a67e 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 为键,值为四个字段:`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/docs/screenshots/provider-cost-overlay-logs-detail.png b/docs/screenshots/provider-cost-overlay-logs-detail.png new file mode 100644 index 0000000000..1b7be458e0 Binary files /dev/null and b/docs/screenshots/provider-cost-overlay-logs-detail.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 46d78faceb..8c25149e4a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -548,6 +548,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", @@ -573,6 +574,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 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 8389a237a2..0791e610d7 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -575,6 +575,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", @@ -600,6 +601,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 77aaa0dd82..7b93fec2da 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -533,6 +533,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": "プロバイダー / モデル", @@ -558,6 +559,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 93e03755ec..166aa22c42 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -567,6 +567,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": "프로바이더 / 모델", @@ -592,6 +593,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 48e9149b63..6d90866993 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -565,6 +565,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": "Провайдер / модель", @@ -590,6 +591,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 15e6053b33..312e3d49f2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -560,6 +560,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": "提供方 / 模型", @@ -585,6 +586,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..dc1ddb31db 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 } @@ -52,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"; @@ -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; /** @@ -306,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 8f9513deb2..4e3e53f38f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -61,6 +61,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, @@ -690,6 +691,36 @@ export function providerHeadersConfigError(headers: unknown): string | null { return null; } +/** + * Validate `providers..modelCosts`: a plain object keyed by exact model + * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates. + * Returns null when valid/absent, else a human-readable error. + */ +export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return `${field} must be a plain object keyed by model id`; + } + for (const [modelId, entry] of Object.entries(value)) { + if (!modelId.trim()) return `${field} keys must be nonblank model ids`; + // Redact secret-shaped model ids and JSON-escape control characters so a + // malformed write cannot echo a pasted key/secret back through the + // management API response. + const safeModelId = JSON.stringify(redactSecretString(modelId)); + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`; + } + const rates = entry as Record; + for (const key of ["input", "output", "cacheRead", "cacheWrite"]) { + const rate = rates[key]; + if (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0) { + return `${field}.${safeModelId}.${key} must be a non-negative finite number (USD per 1M tokens)`; + } + } + } + return null; +} + /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */ export function apiKeyTransportConfigError( provider: Pick, @@ -1161,6 +1192,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({ @@ -1479,6 +1518,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 — @@ -1678,6 +1765,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(); @@ -1685,12 +1778,13 @@ export function loadConfig(): OcxConfig { hardenExistingSecret(configPath); hardenExistingSecret(join(dir, "auth.json")); if (!existsSync(configPath)) { - return getDefaultConfig(); + return withRefreshedCostOverlays(getDefaultConfig()); } try { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); sanitizeRetryOn429ForLoad(parsed); + sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); @@ -1699,7 +1793,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 @@ -1718,17 +1812,23 @@ 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()); } } +/** Refresh the user cost-overlay registry from `config` and return it unchanged. */ +function withRefreshedCostOverlays(config: OcxConfig): OcxConfig { + refreshUserCostOverlays(config); + return config; +} + export type ConfigDiagnostics = { config: OcxConfig; source: "default" | "file" | "fallback"; @@ -1862,6 +1962,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); @@ -2145,6 +2246,12 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync } }; +/** + * Atomic config.json write WITHOUT the mutation lock; callers must hold + * `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the + * cost-overlay registry from the persisted config so runtime estimates follow + * every save path. + */ function persistConfigUnlocked(config: OcxConfig): boolean { const configPath = getConfigPath(); const bytes = JSON.stringify(config, null, 2) + "\n"; @@ -2154,9 +2261,13 @@ function persistConfigUnlocked(config: OcxConfig): boolean { if (!isMissingPathError(error)) throw error; } atomicWriteFile(configPath, bytes); + // Keep the runtime overlay registry in sync with every persist path + // (saveConfig and mutatePersistedConfig both funnel through here). + refreshUserCostOverlays(config); return true; } +/** 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 6de271da2a..12971ae958 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"; @@ -17,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"; @@ -435,6 +436,12 @@ export function providerManagementConfigError(name: unknown, provider: unknown): // it before it reaches the management API response. return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`; } + const modelCostsError = providerModelCostsConfigError(raw.modelCosts); + if (modelCostsError) { + // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape + // it before it reaches the management API response (same rule as retryOn429 above). + return `provider ${JSON.stringify(redactSecretString(name))} ${modelCostsError}`; + } const apiKeyTransportError = apiKeyTransportConfigError(typed); if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`; const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens"); @@ -508,6 +515,37 @@ 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; + // Null prototype so a model id like "__proto__" becomes an own row instead + // of mutating the map's prototype and vanishing from Object.keys(). + const out: Record = Object.create(null) as Record; + for (const [modelId, entry] of Object.entries(costs)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const rates = entry as Record; + const input = rates.input; + const output = rates.output; + const cacheRead = rates.cacheRead; + const cacheWrite = rates.cacheWrite; + if (validRate(input) && validRate(output) && validRate(cacheRead) && validRate(cacheWrite)) { + 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)) { @@ -547,6 +585,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/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>; estimateReasons: CostEstimateReason[] } @@ -123,6 +127,7 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea return "price_unmatched"; } +/** Display-time cost estimate for one log entry (or its attempt list), including the reasons that qualify the estimate. */ export function costResult(entry: MetricSource): CostResult { const tier = serviceTierContext(entry); const estimate = entry.attempts?.length @@ -136,6 +141,8 @@ export function costResult(entry: MetricSource): CostResult { && entry.usage.cacheCreationInputTokens === undefined ? "cache_detail_missing" as const : undefined, estimate.price?.source === "expected" || estimate.attempts?.some(a => a.price.source === "expected") ? "expected_price_overlay" as const : undefined, + estimate.price?.source === "user" || estimate.attempts?.some(a => a.price.source === "user") + ? "provider_cost_overlay" as const : undefined, ].filter((reason): reason is CostEstimateReason => reason !== undefined); return { kind: "value", estimate, estimateReasons }; } diff --git a/src/server/management/usage-summary-cache.ts b/src/server/management/usage-summary-cache.ts index 7b8f0e7489..4faac4acf8 100644 --- a/src/server/management/usage-summary-cache.ts +++ b/src/server/management/usage-summary-cache.ts @@ -10,6 +10,8 @@ export type CachedUsageSummary = UsageSummary & { interface UsageSummaryCacheEntry { revisionKey: string; + /** userCostOverlayVersion() when the summary was computed; overlay edits invalidate the entry. */ + overlayVersion: number; expiresAt: number; summary: CachedUsageSummary; revisionReadAt: number; diff --git a/src/types.ts b/src/types.ts index 18c8a63393..b32ecb9de9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1038,6 +1038,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. @@ -1148,6 +1160,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 45f78c6264..3388742f3d 100644 Binary files a/src/usage/cost.ts and b/src/usage/cost.ts differ diff --git a/src/usage/user-cost-overlays.ts b/src/usage/user-cost-overlays.ts new file mode 100644 index 0000000000..1e53fd421f --- /dev/null +++ b/src/usage/user-cost-overlays.ts @@ -0,0 +1,87 @@ +/** + * Runtime registry for user-configured provider cost overlays + * (`providers..modelCosts` in config.json — per-model prices in ocx's + * flat `modelXxx` convention, mirroring opencode's per-model pricing). + * + * The usage cost estimator stays pure: it receives overlays as parameters and + * defaults to this registry, which is refreshed at the config chokepoints + * (loadConfig and every persist path). A refresh that actually changes the + * rows replaces the active array with a NEW identity and bumps a version + * counter, so the estimator's memo and the /api/usage summary cache skip stale + * rows without cross-module invalidation. Refreshes with byte-identical rows + * are no-ops: config is reloaded at many chokepoints and an unchanged reload + * must not churn the version (see refreshUserCostOverlays). + * + * 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 activeSignature = ""; +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; + 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, + // 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", + }); + } + } + } + // Config is re-loaded at many chokepoints (server start, migrations, persist + // paths). Bumping the version on every load — even when nothing changed — + // would invalidate the /api/usage summary cache on unrelated reloads and + // churn the cost memo. Only a real overlay change bumps the version, so the + // cache survives reloads of an unchanged config. + const signature = JSON.stringify(rows); + if (signature === activeSignature) return; + activeSignature = signature; + 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/api-usage.test.ts b/tests/api-usage.test.ts index ae7e32cd70..1836713dc5 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; +import { refreshUserCostOverlays } from "../src/usage/user-cost-overlays"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import { resetUsageReadCacheForTests, usageReadCacheStatsForTests } from "../src/usage/log"; @@ -154,6 +155,40 @@ describe("GET /api/usage", () => { } }); + 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 { + // This test installs a module-level blsc overlay; clear it even when an + // assertion or shutdown fails so later tests cannot resolve + // user-configured prices unexpectedly. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + await server.stop(true); + } + }); + test("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 new file mode 100644 index 0000000000..4a5fb94b25 --- /dev/null +++ b/tests/provider-cost-overlay-config.test.ts @@ -0,0 +1,261 @@ +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, 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 }, + "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(() => { + // 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 = ""; +}); + +describe("providerModelCostsConfigError", () => { + test("absent and valid modelCosts pass", () => { + expect(providerModelCostsConfigError(undefined)).toBeNull(); + expect(providerModelCostsConfigError(VALID_COSTS)).toBeNull(); + }); + + test("non-object or array value is rejected", () => { + expect(providerModelCostsConfigError("nope")).toContain("plain object"); + expect(providerModelCostsConfigError([{ input: 1 }])).toContain("plain object"); + }); + + test("blank model keys are rejected", () => { + expect(providerModelCostsConfigError({ "": { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 } })) + .toContain("nonblank"); + }); + + test("malformed entries are rejected with a field path", () => { + expect(providerModelCostsConfigError({ m: "not-an-object" })).toContain('modelCosts."m"'); + expect(providerModelCostsConfigError({ m: { input: 1, output: 1, cacheRead: 0 } })) + .toContain('modelCosts."m".cacheWrite'); + expect(providerModelCostsConfigError({ m: { input: -1, output: 1, cacheRead: 0, cacheWrite: 0 } })) + .toContain('modelCosts."m".input'); + expect(providerModelCostsConfigError({ m: { input: 1, output: Infinity, cacheRead: 0, cacheWrite: 0 } })) + .toContain('modelCosts."m".output'); + expect(providerModelCostsConfigError({ m: { input: 1, output: 1, cacheRead: 0, cacheWrite: "0" } })) + .toContain('modelCosts."m".cacheWrite'); + }); + + test("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]"); + }); +}); + +describe("modelCosts config persistence and registry refresh", () => { + test("loadConfig preserves modelCosts and refreshes the overlay registry", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { + blsc: { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: VALID_COSTS, + }, + }, + })); + const versionBefore = userCostOverlayVersion(); + const config = loadConfig(); + expect(config.providers.blsc.modelCosts).toEqual(VALID_COSTS); + expect(userCostOverlayVersion()).toBe(versionBefore + 1); + const rows = activeUserCostOverlays(); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + provider: "blsc", + modelId: "deepseek-v4-flash", + cost4: VALID_COSTS["deepseek-v4-flash"], + status: "verified", + }); + expect(rows[0].source).toBe("config:providers.blsc.modelCosts[deepseek-v4-flash]"); + }); + + test("saveConfig round-trips modelCosts and refreshes the registry", () => { + const config = loadConfig(); + config.providers.blsc = { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + modelCosts: VALID_COSTS, + }; + saveConfig(config); + const onDisk = JSON.parse(readFileSync(getConfigPath(), "utf-8")); + expect(onDisk.providers.blsc.modelCosts).toEqual(VALID_COSTS); + const reloaded = loadConfig(); + expect(reloaded.providers.blsc.modelCosts).toEqual(VALID_COSTS); + expect(activeUserCostOverlays()).toHaveLength(2); + // Removing the overlay clears the registry rows. + delete reloaded.providers.blsc.modelCosts; + saveConfig(reloaded); + expect(activeUserCostOverlays()).toHaveLength(0); + }); + + test("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", () => { + const providerBase = { + adapter: "openai-chat", + baseUrl: "https://llmapi.blsc.cn", + }; + + test("providerManagementConfigError accepts valid modelCosts and rejects malformed ones", () => { + expect(providerManagementConfigError("blsc", { ...providerBase, modelCosts: VALID_COSTS })).toBeNull(); + const error = providerManagementConfigError("blsc", { + ...providerBase, + modelCosts: { "deepseek-v4-flash": { input: -0.5, output: 1, cacheRead: 0, cacheWrite: 0 } }, + }); + expect(error).toContain("blsc"); + expect(error).toContain('modelCosts."deepseek-v4-flash".input'); + }); + + test("providerManagementConfigError redacts a token-shaped provider name in modelCosts errors", () => { + const error = providerManagementConfigError("sk-abcdef1234567890", { + ...providerBase, + modelCosts: { m: { input: -1, output: 1, cacheRead: 0, cacheWrite: 0 } }, + }); + expect(error).not.toContain("sk-abcdef1234567890"); + expect(error).toContain("[REDACTED]"); + }); + + test("safeConfigDTO exposes modelCosts for the dashboard", () => { + writeFileSync(getConfigPath(), JSON.stringify({ + port: 12345, + providers: { blsc: { ...providerBase, modelCosts: VALID_COSTS } }, + })); + const dto = safeConfigDTO(loadConfig()) as { + providers: Record; + }; + expect(dto.providers.blsc.modelCosts).toEqual(VALID_COSTS); + }); + + test("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(); + }); + + 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 aad24abe76..87063599a8 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { calculateCost, estimateAttemptCost, @@ -17,6 +17,12 @@ import { resolvePriorityMultiplier, type ExpectedPriceOverlay, } from "../src/usage/expected-prices"; +import { + activeUserCostOverlays, + refreshUserCostOverlays, + userCostOverlayVersion, +} from "../src/usage/user-cost-overlays"; +import type { OcxConfig } from "../src/types"; const RATE = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }; @@ -683,3 +689,177 @@ describe("long-context pricing tiers (#908)", () => { } }); }); + +describe("provider cost overlay (user-configured)", () => { + const USER_PRICE = { input: 0.5, output: 2, cacheRead: 0.1, cacheWrite: 0.25 }; + const USER_ROWS: ExpectedPriceOverlay[] = [{ + provider: "deepseek", + modelId: "deepseek-chat", + cost4: USER_PRICE, + source: "config:providers.deepseek.modelCosts[deepseek-chat]", + verifiedAt: "user-configured", + status: "verified", + }]; + + afterEach(() => { + // The registry is module-level; reset it even when a test fails early so + // rows cannot leak into other files in a shared-process run. + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + }); + + test("user overlay beats the jawcode price and reads verified (not estimated)", () => { + const price = resolveMatchedPrice("deepseek", "deepseek-chat", undefined, USER_ROWS); + expect(price).toMatchObject({ + provider: "deepseek", + modelId: "deepseek-chat", + cost4: USER_PRICE, + source: "user", + status: "verified", + }); + expect(price?.sourceRef).toBe("config:providers.deepseek.modelCosts[deepseek-chat]"); + expect(price?.verifiedAt).toBe("user-configured"); + const estimate = estimateRequestCost({ + provider: "deepseek", + model: "deepseek-chat", + usage: { inputTokens: 1_000_000, outputTokens: 500_000 }, + usageStatus: "reported", + }, undefined, USER_ROWS); + expect(estimate?.cost.total).toBeCloseTo(0.5 + 1.0, 9); + expect(estimate?.estimated).toBe(false); + expect(estimate?.price?.source).toBe("user"); + }); + + test("custom provider names resolve only via the user overlay", () => { + // Fabricated model id: absent from the jawcode catalog, so only the user + // overlay can price it (deepseek-v4-flash itself would resolve through the + // model-level vendor fallback). + expect(resolveMatchedPrice("blsc", "blsc-test-model")).toBeNull(); + const rows: ExpectedPriceOverlay[] = [{ + provider: "blsc", + modelId: "blsc-test-model", + cost4: USER_PRICE, + source: "config:providers.blsc.modelCosts[blsc-test-model]", + verifiedAt: "user-configured", + status: "verified", + }]; + const price = resolveMatchedPrice("blsc", "blsc-test-model", undefined, rows); + expect(price).toMatchObject({ provider: "blsc", modelId: "blsc-test-model", source: "user" }); + }); + + test("user overlay matches an exact provider name ending with an account-label suffix", () => { + refreshUserCostOverlays({ + providers: { + "blsc-pabcdef": { + modelCosts: { "custom-model": USER_PRICE }, + }, + }, + } as unknown as OcxConfig); + // "pabcdef" matches the Codex account-log-label pattern, so the base label + // collapses to "blsc"; the exact provider name must still win for its own + // configured overlay. + const price = resolveMatchedPrice("blsc-pabcdef", "custom-model"); + expect(price).toMatchObject({ + provider: "blsc-pabcdef", + modelId: "custom-model", + cost4: USER_PRICE, + source: "user", + status: "verified", + }); + expect(price?.sourceRef).toBe("config:providers.blsc-pabcdef.modelCosts[custom-model]"); + }); + + test("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"); + }); + + 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); + }); +});