From e1b73f27f42102576959c85cb4d65b6e565839ee Mon Sep 17 00:00:00 2001 From: codingbo Date: Sat, 26 Sep 2026 22:51:57 +0800 Subject: [PATCH] fix(cli): derive catalog price estimates for models without manual overrides Closes #5960 --- .../docs/reference/cli/providers-accounts.md | 12 +++++++-- src/cli/models-runtime.ts | 9 +++++-- src/cli/models.ts | 7 ++++- structure/runtime.md | 2 +- tests/cli/cli-models-price.test.ts | 27 +++++++++++++++++-- tests/cli/cli-models.test.ts | 23 ++++++++++++++++ 6 files changed, 72 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index e344104d868..9805136a375 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -657,6 +657,14 @@ catalog entries; `enable`, `disable`, and `provider` control visibility; `select provider allowlist; `context` controls provider context caps; and `shadow` manages background shadow-call interception. +Model prices are estimates in USD per million tokens. `ocx models --json` includes a +`price` object with `cost4` rates and their source; `ocx models price --json` keeps +`cost` for the saved override and reports resolved rates in `effectiveCost`. +Manual prices (including zero) take precedence, followed by the shared catalog and +verified official-price fallbacks. Unknown models return `null`; no price is invented. +Automatic defaults are derived on read and do not populate `modelCosts` in your config, +so catalog updates remain effective. Use `set-price` to save provider-specific rates. + Every per-model operation the dashboard offers is available here, so a headless install never needs the GUI to manage a catalog. `add`, `remove`, and `list-custom` work against the config file and apply to a running proxy through a catalog sync; the rest talk to the live management API and require the @@ -664,9 +672,9 @@ proxy to be running (`ocx start`, or an installed service). | Subcommand | Supported flags | Action | | --- | --- | --- | -| `list` (default) | `--provider `, `--json` | List models seeded in configured providers. | +| `list` (default) | `--provider `, `--json` | List models seeded in configured providers, with estimated input/output prices. | | `live` | `--provider `, `--json` | Read the running catalog, including models discovered at runtime. Rows are flagged `native`/`routed`, `custom`, and `enabled`/`disabled`. | -| `price ` | `--json` | Read the model's saved manual price override; no override means automatic pricing. | +| `price ` | `--json` | Read the saved manual override and effective price, including automatic catalog defaults. | | `set-price ` | `--input `, `--output `, `--cache-read `, `--cache-write `, `--auto`, `--json` | Set display prices in USD per 1M tokens. Input/output are required when setting; omitted cache rates become zero. `--auto` removes only this model's override. | | `add ` | `--display-name `, `--context-window `, `--modalities ` | Register a model the provider catalog does not advertise. | | `edit ` | `--model-id `, `--display-name `, `--context-window `, `--modalities `, `--json` | Edit a custom model. `-` clears a field; `0` clears the context window. | diff --git a/src/cli/models-runtime.ts b/src/cli/models-runtime.ts index 1be92b6c301..ad753f3002f 100644 --- a/src/cli/models-runtime.ts +++ b/src/cli/models-runtime.ts @@ -18,6 +18,7 @@ import { isValidProviderName } from "../config/provider-name"; import { isValidModelDiscoveryModelId } from "../providers/model-discovery-limits"; import { redactSecretString } from "../lib/redact"; import type { ProviderCostOverlay } from "../types"; +import { resolveMatchedPrice } from "../usage/cost"; import { MAX_COST4_RATE } from "../usage/expected-prices"; import { isValidCost4Rate } from "../usage/user-cost-overlays"; @@ -124,8 +125,12 @@ async function priceRequest(write: boolean, argv: string[], deps: RuntimeApiDeps if (!validPriceCost(stored)) throw new Error("Invalid model price response"); cost = { ...stored }; } - printData({ provider, modelId, cost }, wantsJson, [ - cost === null ? `${selector}: automatic pricing` : `${selector}: ${JSON.stringify(cost)} USD per 1M tokens`, + // The API map owns manual overrides; bundled defaults remain derived rather + // than being persisted as overrides that would mask later catalog updates. + const effectiveCost = cost ?? resolveMatchedPrice(provider, modelId, undefined, [])?.cost4 ?? null; + printData({ provider, modelId, cost, effectiveCost }, wantsJson, [ + effectiveCost === null ? `${selector}: automatic pricing (unknown)` + : `${selector}: ${JSON.stringify(effectiveCost)} USD per 1M tokens${cost === null ? " (automatic estimate)" : ""}`, ]); return; } diff --git a/src/cli/models.ts b/src/cli/models.ts index 0f917964085..791dbf70dc2 100644 --- a/src/cli/models.ts +++ b/src/cli/models.ts @@ -1,6 +1,7 @@ /** * `ocx models` subcommand — list configured models and manage custom models. */ +import { resolveMatchedPrice, type MatchedPrice } from "../usage/cost"; import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline/promises"; import { syncModelsToCodex } from "../codex/sync"; @@ -85,6 +86,7 @@ interface ModelEntry { contextWindow: number | null; inputModalities: string[] | null; reasoningEfforts: string[] | null; + price: MatchedPrice | null; } /** @@ -132,6 +134,7 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[] contextWindow: configuredContextWindow(prov, model) ?? null, inputModalities: modalities, reasoningEfforts: efforts, + price: resolveMatchedPrice(provName, model), }); }; @@ -428,7 +431,9 @@ function handleConfiguredModels(args: string[]): void { for (const m of provModels) { const marker = m.isDefault ? " *" : ""; const ctx = m.contextWindow ? ` (${Math.round(m.contextWindow / 1000)}k)` : ""; - console.log(` ${m.model}${marker}${ctx}`); + const rates = m.price?.cost4; + const pricing = rates ? ` ~$${rates.input}/$${rates.output} input/output per 1M tokens` : " price unknown"; + console.log(` ${m.model}${marker}${ctx}${pricing}`); } console.log(); } diff --git a/structure/runtime.md b/structure/runtime.md index 11a8444d9f1..78c33f71b88 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -26,7 +26,7 @@ Native steering follows [the shared WebSocket contract](transports/streaming-hea Responses admission and finalization are composed through the [core module ownership](transports/responses.md#core-module-ownership). Kiro's optional account-load admission is process-local and request-owned; its slot ends with the response body or cancellation. Other providers retain their admission path. -Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). +Catalog HTTP acquisition follows the [proxy-routing contract](catalog.md#remote-catalog-http-proxy-routing). CLI model lists expose shared catalog estimates as `price`; `models price` retains the saved override in `cost` and adds `effectiveCost`. Explicit zero overrides win; unknown prices remain null, and automatic defaults are never persisted to `modelCosts`. Covered by `tests/cli/cli-models.test.ts` and `tests/cli/cli-models-price.test.ts`. OAuth refresh coordination follows the [refresh-lock identity contract](catalog.md#accounts-namespaces-and-pool-rotation): a fresh unreadable lock remains held, and release requires matching descriptor identity. A failed path-identity probe preserves the refresh callback outcome. Cooperating lock metadata changes serialize through the existing SQLite mutation transaction; release keeps the descriptor open through identity comparison and any unlink, then closes it. Failed metadata writes remove only a matching owned path after successful coordination; unknown identity, failed probes or unavailable coordination retain the path for stale recovery. Async refresh work holds no metadata transaction. diff --git a/tests/cli/cli-models-price.test.ts b/tests/cli/cli-models-price.test.ts index 9adda799770..50aa79c07a0 100644 --- a/tests/cli/cli-models-price.test.ts +++ b/tests/cli/cli-models-price.test.ts @@ -45,19 +45,42 @@ describe("models manual price commands", () => { }); expect(result.code).toBe(0); expect(result.calls).toEqual([{ path: "/api/providers/custom-price/model-costs", method: "GET", body: undefined }]); - expect(JSON.parse(result.stdout)).toEqual({ provider: "custom-price", modelId: "org/model--fast", cost: COST }); + expect(JSON.parse(result.stdout)).toEqual({ provider: "custom-price", modelId: "org/model--fast", cost: COST, effectiveCost: COST }); }); test("missing own keys read as automatic, including prototype-shaped selectors", async () => { for (const modelId of ["missing", "__proto__", "constructor", "toString"]) { const result = await invoke("price", [`custom-price/${modelId}`, "--json"], { provider: "custom-price", modelCosts: {} }); expect(result.code).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ provider: "custom-price", modelId, cost: null }); + expect(JSON.parse(result.stdout)).toEqual({ provider: "custom-price", modelId, cost: null, effectiveCost: null }); } const automatic = await invoke("price", ["custom-price/missing"], { provider: "custom-price", modelCosts: {} }); expect(automatic.stdout).toContain("automatic pricing"); }); + test("automatic pricing exposes known vendor rates without creating an override", async () => { + const result = await invoke("price", ["custom-price/claude-sonnet-4-6", "--json"], { + provider: "custom-price", modelCosts: {}, + }); + expect(result.code).toBe(0); + const row = JSON.parse(result.stdout); + expect(row.cost).toBeNull(); + expect(row.effectiveCost).toEqual({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }); + expect(result.calls).toHaveLength(1); + }); + + test("explicit zero prices override known automatic rates", async () => { + const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const result = await invoke("price", ["custom-price/claude-sonnet-4-6", "--json"], { + provider: "custom-price", modelCosts: { "claude-sonnet-4-6": zero }, + }); + expect(JSON.parse(result.stdout)).toMatchObject({ cost: zero, effectiveCost: zero }); + const automatic = await invoke("price", ["custom-price/claude-sonnet-4-6"], { + provider: "custom-price", modelCosts: {}, + }); + expect(automatic.stdout).toContain("USD per 1M tokens (automatic estimate)"); + }); + test("set-price sends four numeric rates with omitted cache rates defaulted to zero", async () => { const result = await invoke("set-price", ["custom-price/org/model", "--input", "1.25", "--output", "5", "--json"]); expect(result.code).toBe(0); diff --git a/tests/cli/cli-models.test.ts b/tests/cli/cli-models.test.ts index 14c426c97d1..fcc99236780 100644 --- a/tests/cli/cli-models.test.ts +++ b/tests/cli/cli-models.test.ts @@ -60,6 +60,29 @@ describe("ocx models", () => { await warmModuleGraph({ graph: "cli-index/models", entry: cliPath }); }, COLD_SPAWN_WARMUP_HOOK_BUDGET_MS); + test("configured models expose automatic prices, overrides and unknowns without persisting defaults", () => { + const { dir } = freshConfig({ providers: { relay: { + adapter: "openai-chat", baseUrl: "https://example.com/v1", + models: ["claude-sonnet-4-6", "gpt-4o", "unknown-model"], + modelCosts: { "gpt-4o": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }, + } } }); + try { + const before = readFileSync(join(dir, "config.json"), "utf8"); + const result = runCli(["models", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + const rows = JSON.parse(result.stdout).models; + expect(rows[0].price.cost4).toEqual({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }); + expect(rows[1].price).toMatchObject({ source: "user", cost4: { input: 0, output: 0 } }); + expect(rows[2].price).toBeNull(); + expect(readFileSync(join(dir, "config.json"), "utf8")).toBe(before); + const human = runCli(["models"], { OPENCODEX_HOME: dir }); + expect(human.stdout).toContain("~$3/$15 input/output per 1M tokens"); + expect(human.stdout).toContain("price unknown"); + } finally { + removeTreeWithRetry(dir); + } + }); + test("models lists all provider models", () => { const { dir } = freshConfig(); try {