Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs-site/src/content/docs/reference/cli/providers-accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,16 +657,24 @@ 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
proxy to be running (`ocx start`, or an installed service).

| Subcommand | Supported flags | Action |
| --- | --- | --- |
| `list` (default) | `--provider <name>`, `--json` | List models seeded in configured providers. |
| `list` (default) | `--provider <name>`, `--json` | List models seeded in configured providers, with estimated input/output prices. |
| `live` | `--provider <name>`, `--json` | Read the running catalog, including models discovered at runtime. Rows are flagged `native`/`routed`, `custom`, and `enabled`/`disabled`. |
| `price <provider/model>` | `--json` | Read the model's saved manual price override; no override means automatic pricing. |
| `price <provider/model>` | `--json` | Read the saved manual override and effective price, including automatic catalog defaults. |
| `set-price <provider/model>` | `--input <rate>`, `--output <rate>`, `--cache-read <rate>`, `--cache-write <rate>`, `--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 <provider> <modelId>` | `--display-name <name>`, `--context-window <tokens>`, `--modalities <text,image,audio>` | Register a model the provider catalog does not advertise. |
| `edit <custom-id>` | `--model-id <id>`, `--display-name <name\|->`, `--context-window <tokens\|0>`, `--modalities <text,image,audio\|->`, `--json` | Edit a custom model. `-` clears a field; `0` clears the context window. |
Expand Down
9 changes: 7 additions & 2 deletions src/cli/models-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion src/cli/models.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -85,6 +86,7 @@ interface ModelEntry {
contextWindow: number | null;
inputModalities: string[] | null;
reasoningEfforts: string[] | null;
price: MatchedPrice | null;
}

/**
Expand Down Expand Up @@ -132,6 +134,7 @@ function collectModels(config: OcxConfig, providerFilter?: string): ModelEntry[]
contextWindow: configuredContextWindow(prov, model) ?? null,
inputModalities: modalities,
reasoningEfforts: efforts,
price: resolveMatchedPrice(provName, model),
});
};

Expand Down Expand Up @@ -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();
}
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 25 additions & 2 deletions tests/cli/cli-models-price.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
23 changes: 23 additions & 0 deletions tests/cli/cli-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading