diff --git a/.changeset/model-pricing-overrides.md b/.changeset/model-pricing-overrides.md new file mode 100644 index 0000000000..ee9def2e1c --- /dev/null +++ b/.changeset/model-pricing-overrides.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add editable global model pricing overrides, a one-click LiteLLM pricing refresh, and override-aware Command Center cost estimates. diff --git a/docs/architecture.md b/docs/architecture.md index c4fff50616..64d788ae82 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -863,7 +863,8 @@ Key server capabilities: - System stats snapshot and vitest process controls APIs (`GET /api/system-stats`, `POST /api/kill-vitest`) exposing dashboard process/system telemetry (including app CPU percentage and host memory rendered as numeric values, radial gauges, and trend sparklines in the Command Center System area), task/agent aggregates, and manual vitest process termination. Host-memory usage is derived from shared OS-available memory (`process.availableMemory()` with an unreliable `freemem` fallback) rather than raw free pages so macOS inactive/cache memory is not counted as used. - Command Center analytics APIs (`GET /api/command-center/tokens`, `/tools`, `/activity`, `/productivity`, `/team`, `/github`, `/signals`, `/plugin-activations`, `/live`) are project-scoped dashboard routes. `/productivity` reads Lines changed from nullable `task_commit_associations.additions`/`deletions` merge-time or backfilled diff stats, derives estimated `hoursSaved` from that LOC via the exported `HUMAN_LINES_PER_HOUR` rate, and keeps the unavailable sentinel for both fields when no in-range association has stats. `POST /api/command-center/productivity/backfill-loc` is the explicit operator-triggered, dry-run-defaulting local-git backfill for historical NULL stats; it is not run during dashboard rendering or analytics reads. Its `taskDuration` payload aggregates done tasks whose `executionCompletedAt` falls in the selected range, using positive `tasks.cumulativeActiveMs` values for completed count, average, median, p90, and total active execution time; missing qualifying durations remain unavailable rather than zero. `/signals` aggregates real local `incidents` rows for total/open/resolved counts, MTTR, and source/severity/status breakdowns and returns honest empty/unavailable sentinels instead of synthetic signal volume. `/plugin-activations` aggregates persisted plugin/extension load events for the selected range and returns unavailable when no rows exist instead of treating missing history as zero activations. -- Model pricing & cost estimation: Command Center token cost is derived at read time by `packages/core/src/model-pricing.ts`, not fetched from providers at runtime and not persisted as billing truth. Maintainers update the hand-maintained `MODEL_PRICING` table in that file; keys are lowercased `${provider}:${model}` with a bare `:model` fallback for callers that only know the model id. Each entry stores USD per 1M tokens for input, output, cache-read, and cache-write plus a `source` citation. Bump `pricingAsOf` in the same change as any rate edit, because the dashboard surfaces it as the **prices as of** date and marks entries low-confidence after `PRICING_STALE_AFTER_MS` (approximately 180 days / two quarters) relative to that date. Unknown models resolve to `unavailable` rather than a guessed price. The table is curated from provider pricing pages for Anthropic, OpenAI including explicit `openai-codex:*` Codex ids, and Google Gemini; keep provider/model additions in that curated map rather than adding runtime pricing fetches. + +- Model pricing & cost estimation: Command Center token cost is derived at read time by `packages/core/src/model-pricing.ts` and is not persisted as billing truth. Maintainers still update the built-in `MODEL_PRICING` fallback table in that file; keys are lowercased `${provider}:${model}` with a bare `:model` fallback for callers that only know the model id. Each entry stores USD per 1M tokens for input, output, cache-read, and cache-write plus a `source` citation. Bump `pricingAsOf` in the same change as any built-in rate edit, because the dashboard surfaces it as the **prices as of** date and marks entries low-confidence after `PRICING_STALE_AFTER_MS` (approximately 180 days / two quarters) relative to that date. Global `modelPricingOverrides` from Settings take precedence over built-ins using the same exact-key then bare-model lookup order; `POST /api/command-center/pricing/fetch` is the only dashboard network path and fetches LiteLLM's model pricing JSON on explicit user action, parses it through the pure core parser, persists the resulting overrides with fetched metadata, and leaves the prior overrides intact on fetch/parse failure. Unknown models resolve to `unavailable` rather than a guessed price. - Remote access APIs (`/api/remote/*`) for provider config, activation, tunnel lifecycle, status, token issuance, authenticated URL generation, and QR payload generation - Operational runbook (prereqs/security/troubleshooting): [`docs/remote-access.md`](./remote-access.md) - `/api/remote/tunnel/start`, `/api/remote/tunnel/stop`, and `/api/remote/tunnel/kill-external` cover tunnel lifecycle and external funnel cleanup. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 7e295ddc08..df797f0848 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -213,6 +213,8 @@ Behavior: Custom Providers live in **Settings → Authentication → Custom Providers**, inside the **Advanced: Custom Providers** disclosure. Use this section to add user-defined model providers that speak an OpenAI-compatible API, the OpenAI Responses API, an Anthropic-compatible API, or Google Generative AI. After a provider is saved with models, those models become selectable in model dropdowns, including **Settings → Project Models** lanes and workflow model lanes. +Settings → Global Models also includes **Model pricing overrides** for Command Center estimates. Add or edit rows with lowercased `provider:model` keys (or bare `:model` fallback keys), USD-per-1M token prices for input/output/cache read/cache write, and optional source text. **Fetch LiteLLM pricing** performs an explicit one-click refresh from LiteLLM's published model pricing JSON, replaces the override table only after a successful parse, and records the fetched timestamp/source; failed fetches keep the existing overrides. + Supported **API type** values match the dropdown in the form: - **OpenAI-compatible** @@ -800,7 +802,8 @@ Features: - **Overview controls dashboard** sits at the top of the Overview landing surface on desktop and mobile. It includes AI engine stop/start backed by `globalPause`, live scheduler status from executor stats, range sliders for `maxConcurrent`, `maxTriageConcurrent`, and `maxWorktrees` that persist through `/api/settings`, and a compact theme dropdown with the same color-chip swatches and Shadcn variant list as Settings → Appearance. These controls reuse existing APIs and App-level theme setters; they do not add a new backend route or second theme owner. - **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview includes a graph-rich software-factory snapshot with the existing tokens-by-model bar, tool-category bar, real recharts token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range. -- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by a hand-maintained per-model pricing table; it is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date for that table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. + +- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by the effective per-model pricing table: Settings → Global Models pricing overrides win first, then the built-in fallback table is used. It is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date/source for the effective table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, and a token-share pie backed by the same grouped token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users. - **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint. - **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users. - **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 566cf9b237..e76e85a631 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -38,6 +38,9 @@ Defaults from `DEFAULT_GLOBAL_SETTINGS`; key scope from `GLOBAL_SETTINGS_KEYS`. | `dashboardFontScalePct` | `number` | `100` | Dashboard font scale percentage used by Appearance settings. Valid range: `85` to `125`; applied pre-hydration via document root font-size so board typography (column headers/counts, task cards, and quick-entry text) scales with the setting from first paint. | | `defaultProvider` | `string` | `undefined` | Default AI provider. | | `defaultModelId` | `string` | `undefined` | Default AI model ID. | +| `modelPricingOverrides` | `Record` | `undefined` | Optional global Command Center pricing overrides keyed by lowercased `provider:model` or bare `:model`. Values store USD per 1M input, output, cache-read, and cache-write tokens plus optional `source`; they override the built-in pricing table for cost estimates only and are editable in Settings → Global Models. | +| `modelPricingFetchedAt` | `string` | `undefined` | ISO timestamp for the last successful one-click pricing refresh from the Settings → Global Models pricing editor. | +| `modelPricingSource` | `string` | `undefined` | Source label/URL for the current pricing override set, currently the LiteLLM model pricing JSON when fetched through the dashboard. | | `fallbackProvider` | `string` | `undefined` | Fallback provider when the primary default model hits transient provider failures or model-compatibility/auth-tier rejections. | | `fallbackModelId` | `string` | `undefined` | Fallback model ID (must pair with `fallbackProvider`). | | `defaultThinkingLevel` | `"off" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | `undefined` | Default reasoning effort for AI sessions. `xhigh` requests maximum reasoning effort; Claude CLI adapters map it to `high` for non-Opus models and `max` for Opus models. If a provider/runtime rejects simultaneous `thinking` and `reasoning_effort` parameters, Fusion retries without the explicit thinking override instead of failing the run. | diff --git a/packages/core/src/__tests__/model-pricing.test.ts b/packages/core/src/__tests__/model-pricing.test.ts index 4d6a4b3fc9..52c96ca9f4 100644 --- a/packages/core/src/__tests__/model-pricing.test.ts +++ b/packages/core/src/__tests__/model-pricing.test.ts @@ -4,8 +4,10 @@ import { costFor, lookupPricing, MODEL_PRICING, + parseLiteLLMPricing, pricingAsOf, PRICING_STALE_AFTER_MS, + type ModelPricingOverrides, } from "../model-pricing.js"; const ZERO = { @@ -150,6 +152,23 @@ describe("model-pricing", () => { }); describe("lookupPricing", () => { + const overrides: ModelPricingOverrides = { + "openai:gpt-4o": { + inputPer1M: 99, + outputPer1M: 199, + cacheReadPer1M: 9, + cacheWritePer1M: 29, + source: "test override", + }, + "acme:unknown-chat": { + inputPer1M: 2, + outputPer1M: 4, + cacheReadPer1M: 1, + cacheWritePer1M: 3, + source: "test override", + }, + }; + it("resolves by provider:model", () => { expect( lookupPricing({ provider: "openai", model: "gpt-4o" }), @@ -179,6 +198,88 @@ describe("model-pricing", () => { expect(lookupPricing({ model: "" })).toBeUndefined(); expect(lookupPricing({ provider: "x", model: "y" })).toBeUndefined(); }); + + it("prefers overrides over baseline and keeps baseline fallback", () => { + expect(lookupPricing({ provider: "openai", model: "gpt-4o" }, overrides)).toBe(overrides["openai:gpt-4o"]); + expect(lookupPricing({ provider: "anthropic", model: "claude-opus-4-8" }, overrides)).toBe( + MODEL_PRICING["anthropic:claude-opus-4-8"], + ); + }); + + it("resolves overrides for otherwise unknown models", () => { + expect(lookupPricing({ provider: "acme", model: "unknown-chat" }, overrides)).toBe(overrides["acme:unknown-chat"]); + const result = costFor( + { ...ZERO, inputTokens: 1_000_000, outputTokens: 500_000 }, + { provider: "acme", model: "unknown-chat" }, + undefined, + overrides, + ); + expect(result).toMatchObject({ unavailable: false, stale: false }); + expect(result.usd).toBeCloseTo(4, 2); + }); + }); + + describe("parseLiteLLMPricing", () => { + it("maps chat rows and cache costs from the LiteLLM schema", () => { + const parsed = parseLiteLLMPricing({ + sample_spec: { mode: "chat" }, + "gpt-test": { + litellm_provider: "openai", + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + cache_read_input_token_cost: 0.00000025, + cache_creation_input_token_cost: 0.00000125, + }, + "claude-test": { + litellm_provider: "anthropic", + mode: "chat", + input_cost_per_token: 0.000003, + output_cost_per_token: 0.000015, + }, + "gemini-test": { + litellm_provider: "vertex_ai-language-models", + mode: "chat", + input_cost_per_token: 0.0000005, + output_cost_per_token: 0.0000015, + }, + "embedding-test": { + litellm_provider: "openai", + mode: "embedding", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + }, + "missing-output": { + litellm_provider: "openai", + mode: "chat", + input_cost_per_token: 0.000001, + }, + }); + + expect(parsed.count).toBe(3); + expect(parsed.overrides["openai:gpt-test"]).toEqual({ + inputPer1M: 1, + outputPer1M: 2, + cacheReadPer1M: 0.25, + cacheWritePer1M: 1.25, + source: "litellm/model_prices_and_context_window.json", + }); + expect(parsed.overrides["anthropic:claude-test"]).toMatchObject({ + inputPer1M: 3, + outputPer1M: 15, + cacheReadPer1M: 3, + cacheWritePer1M: 3, + }); + expect(parsed.overrides["google:gemini-test"]).toMatchObject({ inputPer1M: 0.5, outputPer1M: 1.5 }); + expect(parsed.overrides).not.toHaveProperty("openai:embedding-test"); + expect(parsed.overrides).not.toHaveProperty("openai:missing-output"); + }); + + it("returns an empty map for malformed input", () => { + expect(parseLiteLLMPricing(null)).toEqual({ overrides: {}, count: 0 }); + expect(parseLiteLLMPricing([])).toEqual({ overrides: {}, count: 0 }); + expect(parseLiteLLMPricing({ "gpt-test": "bad" })).toEqual({ overrides: {}, count: 0 }); + }); }); it("seeds Anthropic, OpenAI Codex, OpenAI, and Google providers", () => { diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 1d7cfb7d88..24d962c631 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -73,6 +73,10 @@ describe("settings key parity", () => { expect(isProjectSettingsKey("persistAgentThinkingLogEphemeral")).toBe(false); expect(isGlobalOnlySettingsKey("persistAgentThinkingLogEphemeral")).toBe(true); expect(isGlobalSettingsKey("researchSettings")).toBe(false); + expect(isGlobalSettingsKey("modelPricingOverrides")).toBe(true); + expect(isGlobalSettingsKey("modelPricingFetchedAt")).toBe(true); + expect(isGlobalSettingsKey("modelPricingSource")).toBe(true); + expect(isProjectSettingsKey("modelPricingOverrides")).toBe(false); expect(isGlobalSettingsKey("agentMemoryInclusionMode")).toBe(true); expect(isProjectSettingsKey("agentMemoryInclusionMode")).toBe(false); }); diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index 4a277bd3e4..4464dce2c7 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -1425,6 +1425,31 @@ describe("TaskStore", () => { expect(settings.defaultModelId).toBe("gpt-4o"); }); + it("round-trips model pricing settings through global scope", async () => { + await harness.store().updateGlobalSettings({ + modelPricingOverrides: { + "openai:gpt-4o": { + inputPer1M: 1, + outputPer1M: 2, + cacheReadPer1M: 0.5, + cacheWritePer1M: 1, + source: "test", + }, + }, + modelPricingFetchedAt: "2026-06-22T00:00:00.000Z", + modelPricingSource: "litellm/model_prices_and_context_window.json", + }); + + const settings = await harness.store().getSettings(); + expect(settings.modelPricingOverrides?.["openai:gpt-4o"]?.outputPer1M).toBe(2); + expect(settings.modelPricingFetchedAt).toBe("2026-06-22T00:00:00.000Z"); + expect(settings.modelPricingSource).toBe("litellm/model_prices_and_context_window.json"); + + const { global, project } = await harness.store().getSettingsByScope(); + expect(global.modelPricingOverrides).toEqual(settings.modelPricingOverrides); + expect(project.modelPricingOverrides).toBeUndefined(); + }); + it("updateGlobalSettings emits settings:updated event", async () => { const events: Array<{ settings: any; previous: any }> = []; harness.store().on("settings:updated", (data) => events.push(data)); diff --git a/packages/core/src/__tests__/token-analytics.test.ts b/packages/core/src/__tests__/token-analytics.test.ts index 2ecf9777ca..4b86878114 100644 --- a/packages/core/src/__tests__/token-analytics.test.ts +++ b/packages/core/src/__tests__/token-analytics.test.ts @@ -473,6 +473,45 @@ describe("token-analytics", () => { expect(groups.get("mystery-model")?.cost).toEqual({ usd: null, unavailable: true, stale: false }); }); + it("applies pricing overrides while preserving baseline fallback", () => { + insertTask(db, { + id: "override-priced", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + totalTokens: 2_000_000, + lastUsedAt: "2026-03-01T00:00:00.000Z", + modelProvider: "openai", + modelId: "gpt-4o", + }); + insertTask(db, { + id: "baseline-priced", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + totalTokens: 2_000_000, + lastUsedAt: "2026-03-02T00:00:00.000Z", + modelProvider: "anthropic", + modelId: "claude-opus-4-8", + }); + + const result = aggregateTokenAnalytics(db, { + groupBy: "model", + pricingOverrides: { + "openai:gpt-4o": { + inputPer1M: 1, + outputPer1M: 2, + cacheReadPer1M: 1, + cacheWritePer1M: 1, + source: "test override", + }, + }, + }); + + const groups = new Map(result.groups.map((group) => [group.key, group.cost])); + expect(groups.get("gpt-4o")?.usd).toBeCloseTo(3, 2); + expect(groups.get("claude-opus-4-8")?.usd).toBeCloseTo(30, 2); + expect(result.cost.usd).toBeCloseTo(33, 2); + }); + it("returns an empty series for an empty requested range", () => { insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 906d3dbb76..618f0e2777 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -546,12 +546,16 @@ export type { export { costFor, lookupPricing, + parseLiteLLMPricing, MODEL_PRICING, + LITELLM_PRICING_SOURCE_LABEL, + LITELLM_PRICING_SOURCE_URL, pricingAsOf, PRICING_STALE_AFTER_MS, } from "./model-pricing.js"; export type { ModelPricing, + ModelPricingOverrides, ModelRef, UsageForCost, CostResult, diff --git a/packages/core/src/model-pricing.ts b/packages/core/src/model-pricing.ts index 76a2b10fa0..73421fadff 100644 --- a/packages/core/src/model-pricing.ts +++ b/packages/core/src/model-pricing.ts @@ -2,17 +2,20 @@ * Model pricing → USD cost derivation (KTD6, U3). * * Cost is **derived at read time** from token counts × a hand-maintained - * pricing map; it is never persisted (so historical rows stay correct when - * prices change, and no backfill migration is needed). Unknown models surface - * tokens with cost marked `unavailable` rather than guessing a price. + * pricing map plus optional user-managed overrides; it is never persisted (so + * historical rows stay correct when prices change, and no backfill migration is + * needed). Unknown models surface tokens with cost marked `unavailable` rather + * than guessing a price. * * ⚠️ HAND-MAINTAINED MAP. The `MODEL_PRICING` table below is curated by humans * from each provider's public pricing pages — it is NOT fetched at runtime. - * When you update a rate, bump {@link pricingAsOf} in the same change. The UI - * surfaces `pricingAsOf` ("prices as of ") and marks entries older than - * {@link PRICING_STALE_AFTER_MS} as low-confidence, so stale-but-present rates - * (which the unknown-model guard does not catch) are visible rather than - * silently wrong. + * Callers may supply persisted overrides, including entries parsed from the + * canonical LiteLLM dataset, and those overrides take precedence over this + * baseline. When you update a baseline rate, bump {@link pricingAsOf} in the + * same change. The UI surfaces `pricingAsOf` ("prices as of ") and marks + * entries older than {@link PRICING_STALE_AFTER_MS} as low-confidence, so + * stale-but-present rates (which the unknown-model guard does not catch) are + * visible rather than silently wrong. * * Rates are USD **per 1,000,000 tokens**. * @@ -35,6 +38,15 @@ export const pricingAsOf = "2026-06-21"; */ export const PRICING_STALE_AFTER_MS = 180 * 24 * 60 * 60 * 1000; +/* + * FNXC:CommandCenter 2026-06-22-00:00: + * Users need one-click pricing refreshes from LiteLLM's continuously updated community dataset while the core module remains pure. Keep the URL as data only; dashboard routes own HTTP, validation errors, and persistence. + */ +export const LITELLM_PRICING_SOURCE_URL = + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; + +export const LITELLM_PRICING_SOURCE_LABEL = "litellm/model_prices_and_context_window.json"; + /** A single model's per-1M-token rates plus a citation. */ export interface ModelPricing { /** USD per 1M uncached input tokens. */ @@ -49,6 +61,9 @@ export interface ModelPricing { source: string; } +/** User-managed pricing overrides keyed by lowercased `provider:model`. */ +export type ModelPricingOverrides = Record; + /** Token counts to price. Mirrors {@link TokenTotals} from token-analytics. */ export interface UsageForCost { inputTokens: number; @@ -317,24 +332,92 @@ function normalize(s: string | null | undefined): string { return (s ?? "").trim().toLowerCase(); } +function findBareModelPricing( + model: string, + entries: Record | Readonly>, +): ModelPricing | undefined { + for (const [key, entry] of Object.entries(entries)) { + if (key.endsWith(`:${model}`)) return entry; + } + return undefined; +} + /** - * Resolve a pricing entry for a model. Tries `provider:model` first, then the - * bare `:model` (provider-agnostic) fallback. Returns `undefined` for unknown - * models — callers must treat that as `unavailable`, never as a guessed price. + * Resolve a pricing entry for a model. Tries override `provider:model` first, + * then override bare-model fallback, then the built-in baseline using the same + * precedence. Returns `undefined` for unknown models — callers must treat that + * as `unavailable`, never as a guessed price. + * + * FNXC:CommandCenter 2026-06-22-00:00: + * Editable/fetched model rates must override the hand-maintained baseline without removing the baseline fallback. Keep exact provider:model checks before bare-model scans so provider-specific overrides stay deterministic. */ -export function lookupPricing(ref: ModelRef): ModelPricing | undefined { +export function lookupPricing(ref: ModelRef, overrides?: ModelPricingOverrides): ModelPricing | undefined { const provider = normalize(ref.provider); const model = normalize(ref.model); if (!model) return undefined; + if (provider) { + const exactOverride = overrides?.[`${provider}:${model}`]; + if (exactOverride) return exactOverride; + } + const bareOverride = overrides ? findBareModelPricing(model, overrides) : undefined; + if (bareOverride) return bareOverride; if (provider) { const exact = MODEL_PRICING[`${provider}:${model}`]; if (exact) return exact; } - // Provider-agnostic fallback: scan for any entry whose model id matches. - for (const [key, entry] of Object.entries(MODEL_PRICING)) { - if (key.endsWith(`:${model}`)) return entry; + return findBareModelPricing(model, MODEL_PRICING); +} + +function litellmProviderToFusionProvider(provider: unknown): string | null { + if (typeof provider !== "string") return null; + const normalized = normalize(provider); + if (normalized === "openai") return "openai"; + if (normalized === "anthropic") return "anthropic"; + if (normalized === "gemini" || normalized.startsWith("gemini")) return "google"; + if (normalized === "vertex_ai" || normalized.startsWith("vertex_ai")) return "google"; + if (normalized === "vertex_ai-language-models") return "google"; + return null; +} + +function numericField(entry: Record, key: string): number | undefined { + const value = entry[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * Parse LiteLLM's canonical pricing dataset into Fusion pricing overrides. + * Pure: no HTTP, DB access, or clock reads. Unsupported providers and non-chat + * rows are skipped so a broad upstream dataset can safely feed Fusion's known + * model-provider surface. + */ +export function parseLiteLLMPricing(json: unknown): { overrides: ModelPricingOverrides; count: number } { + const overrides: ModelPricingOverrides = {}; + if (json === null || typeof json !== "object" || Array.isArray(json)) { + return { overrides, count: 0 }; } - return undefined; + for (const [modelId, value] of Object.entries(json as Record)) { + if (modelId === "sample_spec") continue; + if (value === null || typeof value !== "object" || Array.isArray(value)) continue; + const entry = value as Record; + if (entry.mode !== "chat") continue; + const provider = litellmProviderToFusionProvider(entry.litellm_provider); + if (!provider) continue; + const inputCost = numericField(entry, "input_cost_per_token"); + const outputCost = numericField(entry, "output_cost_per_token"); + if (inputCost === undefined || outputCost === undefined) continue; + const inputPer1M = inputCost * 1_000_000; + const outputPer1M = outputCost * 1_000_000; + const cacheRead = numericField(entry, "cache_read_input_token_cost"); + const cacheWrite = numericField(entry, "cache_creation_input_token_cost"); + overrides[`${provider}:${normalize(modelId)}`] = { + inputPer1M, + outputPer1M, + cacheReadPer1M: cacheRead === undefined ? inputPer1M : cacheRead * 1_000_000, + cacheWritePer1M: cacheWrite === undefined ? inputPer1M : cacheWrite * 1_000_000, + source: LITELLM_PRICING_SOURCE_LABEL, + }; + } + return { overrides, count: Object.keys(overrides).length }; } /** True when the pricing map is older than the threshold relative to `now`. */ @@ -359,9 +442,10 @@ export function costFor( usage: UsageForCost, model: ModelRef, now?: number, + overrides?: ModelPricingOverrides, ): CostResult { const stale = isStale(now); - const pricing = lookupPricing(model); + const pricing = lookupPricing(model, overrides); if (!pricing) { return { usd: null, unavailable: true, stale }; } diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 2ba5153fb2..8c0b929bca 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -75,6 +75,9 @@ export const DEFAULT_GLOBAL_SETTINGS = { defaultProvider: undefined, defaultModelId: undefined, testMode: undefined, + modelPricingOverrides: undefined, + modelPricingFetchedAt: undefined, + modelPricingSource: undefined, modelRouterEnabled: undefined, modelRouterCheapProvider: undefined, modelRouterCheapModelId: undefined, diff --git a/packages/core/src/team-analytics.ts b/packages/core/src/team-analytics.ts index ca49585568..a5c9b08b79 100644 --- a/packages/core/src/team-analytics.ts +++ b/packages/core/src/team-analytics.ts @@ -1,5 +1,5 @@ import type { Database } from "./db.js"; -import { costFor, type CostResult } from "./model-pricing.js"; +import { costFor, type CostResult, type ModelPricingOverrides } from "./model-pricing.js"; import type { TokenTotals } from "./token-analytics.js"; export interface TeamAnalyticsQuery { @@ -9,6 +9,8 @@ export interface TeamAnalyticsQuery { to?: string; /** Epoch ms "now" used only for pricing-staleness. */ now?: number; + /** User-managed pricing overrides that take precedence over the built-in baseline. */ + pricingOverrides?: ModelPricingOverrides; } export interface TeamMetricTotals { @@ -106,7 +108,12 @@ function addTokenRow(totals: TokenTotals, row: TaskTokenRow): void { totals.nTasks += 1; } -function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void { +function addRowCost( + acc: CostAccumulator, + row: TaskTokenRow, + now?: number, + pricingOverrides?: ModelPricingOverrides, +): void { const result = costFor( { inputTokens: row.inputTokens ?? 0, @@ -116,6 +123,7 @@ function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void }, { provider: row.modelProvider, model: row.modelId }, now, + pricingOverrides, ); if (result.stale) acc.anyStale = true; if (result.unavailable || result.usd === null) { @@ -188,6 +196,7 @@ export function aggregateTeamAnalytics( const costAccumulators = new Map(); const totalTokens = emptyTokenTotals(); const totalCost = emptyCostAccumulator(); + const pricingOverrides = query.pricingOverrides; const agents = db .prepare(`SELECT id, name, role, state FROM agents ORDER BY id`) @@ -231,8 +240,8 @@ export function aggregateTeamAnalytics( costAccumulators.set(row.agentId, agentCost); addTokenRow(summary.tokens, row); addTokenRow(totalTokens, row); - addRowCost(agentCost, row, query.now); - addRowCost(totalCost, row, query.now); + addRowCost(agentCost, row, query.now, pricingOverrides); + addRowCost(totalCost, row, query.now, pricingOverrides); } const completedClauses = ["assignedAgentId IS NOT NULL", `"column" = 'done'`, "columnMovedAt IS NOT NULL"]; diff --git a/packages/core/src/token-analytics.ts b/packages/core/src/token-analytics.ts index bcbfe1aa1a..714fedddaa 100644 --- a/packages/core/src/token-analytics.ts +++ b/packages/core/src/token-analytics.ts @@ -1,5 +1,5 @@ import type { Database } from "./db.js"; -import { costFor, type CostResult } from "./model-pricing.js"; +import { costFor, type CostResult, type ModelPricingOverrides } from "./model-pricing.js"; import type { TaskTokenUsagePerModel } from "./types.js"; /** @@ -85,6 +85,8 @@ export interface TokenAnalyticsQuery { * cost is never marked stale. Pure: the module never reads the clock itself. */ now?: number; + /** User-managed pricing overrides that take precedence over the built-in baseline. */ + pricingOverrides?: ModelPricingOverrides; } function emptyTotals(): TokenTotals { @@ -149,7 +151,12 @@ function emptyCostAccumulator(): CostAccumulator { return { usd: 0, anyPriced: false, anyUnavailable: false, anyStale: false }; } -function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void { +function addRowCost( + acc: CostAccumulator, + row: TaskTokenRow, + now?: number, + pricingOverrides?: ModelPricingOverrides, +): void { /* * FNXC:CommandCenter 2026-06-18-12:00: * Token cost attribution must use the actually-used model snapshot first, then legacy own-model columns, matching groupKeyFor so resolved-via-settings tasks show priced Command Center costs instead of unavailable groups. @@ -166,6 +173,7 @@ function addRowCost(acc: CostAccumulator, row: TaskTokenRow, now?: number): void model: row.tokenUsageModelId ?? row.modelId, }, now, + pricingOverrides, ); if (result.stale) acc.anyStale = true; if (result.unavailable || result.usd === null) { @@ -308,10 +316,11 @@ export function aggregateTokenAnalytics( const groupBy = query.groupBy; const granularity = query.granularity; const now = query.now; + const pricingOverrides = query.pricingOverrides; for (const row of rows) { addRow(totals, row); - addRowCost(totalCost, row, now); + addRowCost(totalCost, row, now, pricingOverrides); if (groupBy) { const groupRows = (groupBy === "model" || groupBy === "provider") ? parsePerModelRows(row) : []; const rowsForGroup = groupRows.length > 0 ? groupRows : [row]; @@ -324,7 +333,7 @@ export function aggregateTokenAnalytics( groupCostMap.set(key, emptyCostAccumulator()); } addRow(group, groupRow); - addRowCost(groupCostMap.get(key)!, groupRow, now); + addRowCost(groupCostMap.get(key)!, groupRow, now, pricingOverrides); } } if (granularity) { @@ -336,7 +345,7 @@ export function aggregateTokenAnalytics( seriesCostMap.set(bucket, emptyCostAccumulator()); } addRow(point, row); - addRowCost(seriesCostMap.get(bucket)!, row, now); + addRowCost(seriesCostMap.get(bucket)!, row, now, pricingOverrides); } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 23d6be4adc..a2bd1bf732 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,5 @@ import type { InReviewStallSignal } from "./in-review-stall.js"; +import type { ModelPricing } from "./model-pricing.js"; import type { InReviewStalledSignal } from "./in-review-stalled.js"; import type { StalePausedReviewSignal } from "./stale-paused-review.js"; import type { StalePausedTodoSignal } from "./stale-paused-todo.js"; @@ -2978,6 +2979,17 @@ export interface GlobalSettings { * of per-task or per-lane overrides. No network calls, zero token cost. * Project `testMode` takes precedence over the global value. */ testMode?: boolean; + /** + * User-edited or one-click-fetched pricing entries keyed by lowercased `provider:model`. + * + * FNXC:CommandCenter 2026-06-22-00:00: + * Global pricing overrides let Command Center cost estimates reflect user-maintained or LiteLLM-refreshed rates while preserving the built-in MODEL_PRICING fallback for unedited models. + */ + modelPricingOverrides?: Record; + /** ISO timestamp for the last successful pricing refresh from the configured source. */ + modelPricingFetchedAt?: string; + /** Source label or URL for the current global pricing override set. */ + modelPricingSource?: string; /** Fusion Model Router opt-in (U17/KTD9). When true, a conservative selection * layer may down-route an allowlist of mechanical steps (dependabot bumps, * lint-only fixes) to a cheap model tier before a session starts; everything diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 817d186eb2..ee7f96253a 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -2631,6 +2631,8 @@ export function SettingsModal({ favoriteModels={favoriteModels} onToggleFavorite={handleToggleFavorite} onToggleModelFavorite={handleToggleModelFavorite} + addToast={addToast} + projectId={projectId} /> ); diff --git a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx index 13685fe9bf..352d02496e 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx @@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next"; import { THINKING_LEVELS } from "@fusion/core"; import type { Settings, ThinkingLevel } from "@fusion/core"; import type { ModelInfo } from "../../../api"; +import type { ToastType } from "../../../hooks/useToast"; +import { ModelPricingSection } from "./ModelPricingSection"; import { CustomModelDropdown } from "../../CustomModelDropdown"; import type { SectionBaseProps, ModelLane } from "./context"; import { LoadingSpinner } from "../../LoadingSpinner"; @@ -22,8 +24,10 @@ export interface GlobalModelsSectionProps extends SectionBaseProps { favoriteModels: string[]; onToggleFavorite: (provider: string) => void; onToggleModelFavorite: (modelId: string) => void; + addToast: (message: string, type?: ToastType) => void; + projectId?: string; } -export function GlobalModelsSection({ scopeBanner, form, setForm, availableModels, modelsLoading, globalModelLanes, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, }: GlobalModelsSectionProps) { +export function GlobalModelsSection({ scopeBanner, form, setForm, availableModels, modelsLoading, globalModelLanes, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, addToast, projectId, }: GlobalModelsSectionProps) { const { t } = useTranslation("app"); const selectedValue = form.defaultProvider && form.defaultModelId ? `${form.defaultProvider}/${form.defaultModelId}` @@ -122,6 +126,8 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel })} )} + + {/* --- Startup Model Sync --- */}

{t("settings.globalModels.startupModelSync", "Startup Model Sync")}

diff --git a/packages/dashboard/app/components/settings/sections/ModelPricingSection.css b/packages/dashboard/app/components/settings/sections/ModelPricingSection.css new file mode 100644 index 0000000000..fb691d776f --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ModelPricingSection.css @@ -0,0 +1,82 @@ +.model-pricing-section { + display: flex; + flex-direction: column; + gap: var(--spacing-md, var(--space-md)); +} + +.model-pricing-section__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-md, var(--space-md)); +} + +.model-pricing-section__meta { + margin: var(--spacing-xs, var(--space-xs)) 0 0; +} + +.model-pricing-table { + display: flex; + flex-direction: column; + gap: var(--spacing-xs, var(--space-xs)); + overflow-x: auto; +} + +.model-pricing-row { + display: grid; + grid-template-columns: + minmax(12rem, 1.4fr) + minmax(7rem, 1fr) + minmax(7rem, 1fr) + minmax(7rem, 1fr) + minmax(7rem, 1fr) + minmax(10rem, 1.2fr) + minmax(5rem, auto); + gap: var(--spacing-xs, var(--space-xs)); + align-items: center; +} + +.model-pricing-row--head { + color: var(--text-muted); + font-size: var(--font-size-xs, 0.75rem); + font-weight: 600; +} + +.model-pricing-row--add { + padding-top: var(--spacing-xs, var(--space-xs)); + border-top: thin solid var(--border); +} + +.model-pricing-key { + overflow-wrap: anywhere; + color: var(--text); +} + +.model-pricing-empty { + grid-column: 1 / -1; +} + +@media (max-width: 768px) { + .model-pricing-section__header { + flex-direction: column; + } + + .model-pricing-section__header .btn { + width: 100%; + } + + .model-pricing-row { + grid-template-columns: minmax(14rem, 1fr); + padding: var(--spacing-sm, var(--space-sm)); + border: thin solid var(--border); + border-radius: var(--radius-md); + } + + .model-pricing-row--head { + display: none; + } + + .model-pricing-row--add { + border-top: thin solid var(--border); + } +} diff --git a/packages/dashboard/app/components/settings/sections/ModelPricingSection.test.tsx b/packages/dashboard/app/components/settings/sections/ModelPricingSection.test.tsx new file mode 100644 index 0000000000..45fc82c850 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ModelPricingSection.test.tsx @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { ModelPricingSection } from "./ModelPricingSection"; +import type { SettingsFormState } from "./context"; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback: string, vars?: Record) => { + if (!vars) return fallback; + return Object.entries(vars).reduce((text, [key, value]) => text.replace(`{{${key}}}`, String(value)), fallback); + } }), +})); + +// Mock the API helper so fetch actions stay deterministic and do not hit the dashboard server. +vi.mock("../../../api", () => ({ + api: apiMock, +})); + +function Harness({ initial, addToast = vi.fn() }: { initial: SettingsFormState; addToast?: (message: string, type?: "success" | "error" | "info" | "warning") => void }) { + const [form, setForm] = useState(initial); + return ( + + ); +} + +const initialForm = (): SettingsFormState => ({ + modelPricingOverrides: { + "openai:gpt-4o": { + inputPer1M: 2.5, + outputPer1M: 10, + cacheReadPer1M: 1.25, + cacheWritePer1M: 2.5, + source: "manual", + }, + }, +} as SettingsFormState); + +describe("ModelPricingSection", () => { + beforeEach(() => { + apiMock.mockReset(); + }); + + it("renders existing overrides and edits a row", () => { + render(); + + expect(screen.getByText("openai:gpt-4o")).toBeInTheDocument(); + const inputRate = screen.getByLabelText("openai:gpt-4o input per 1M"); + fireEvent.change(inputRate, { target: { value: "3.75" } }); + + expect(screen.getByLabelText("openai:gpt-4o input per 1M")).toHaveValue(3.75); + }); + + it("adds and deletes pricing rows through form state", () => { + render(); + + fireEvent.change(screen.getByLabelText("New provider:model key"), { target: { value: "Anthropic:Claude-Test" } }); + fireEvent.change(screen.getByLabelText("New input rate"), { target: { value: "1" } }); + fireEvent.change(screen.getByLabelText("New output rate"), { target: { value: "5" } }); + fireEvent.change(screen.getByLabelText("New cache read rate"), { target: { value: "0.1" } }); + fireEvent.change(screen.getByLabelText("New cache write rate"), { target: { value: "1.25" } }); + fireEvent.change(screen.getByLabelText("New source"), { target: { value: "manual-test" } }); + fireEvent.click(screen.getByRole("button", { name: "Add row" })); + + expect(screen.getByText("anthropic:claude-test")).toBeInTheDocument(); + expect(screen.getByLabelText("anthropic:claude-test output per 1M")).toHaveValue(5); + + fireEvent.click(screen.getByRole("button", { name: "Delete" })); + expect(screen.queryByText("anthropic:claude-test")).not.toBeInTheDocument(); + expect(screen.getByText("No model pricing overrides yet. Add one manually or fetch the latest LiteLLM prices.")).toBeInTheDocument(); + }); + + it("shows an error toast and resets loading when pricing fetch fails", async () => { + const addToast = vi.fn(); + let rejectFetch: (error: Error) => void = () => undefined; + apiMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectFetch = reject; + })); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Fetch latest prices" })); + + expect(await screen.findByRole("button", { name: "Fetching…" })).toBeDisabled(); + rejectFetch(new Error("pricing unavailable")); + await waitFor(() => expect(addToast).toHaveBeenCalledWith("pricing unavailable", "error")); + await waitFor(() => expect(screen.getByRole("button", { name: "Fetch latest prices" })).not.toBeDisabled()); + }); + + it("fetch button calls the API and refreshes fetched pricing state", async () => { + apiMock + .mockResolvedValueOnce({ count: 1, fetchedAt: "2026-06-22T00:00:00.000Z", source: "litellm" }) + .mockResolvedValueOnce({ + modelPricingFetchedAt: "2026-06-22T00:00:00.000Z", + modelPricingSource: "litellm", + modelPricingOverrides: { + "openai:gpt-test": { + inputPer1M: 1, + outputPer1M: 2, + cacheReadPer1M: 1, + cacheWritePer1M: 1, + source: "litellm/model_prices_and_context_window.json", + }, + }, + }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Fetch latest prices" })); + + await waitFor(() => expect(apiMock).toHaveBeenCalledWith( + "/command-center/pricing/fetch?projectId=proj-a", + { method: "POST" }, + )); + await waitFor(() => expect(screen.getByText("openai:gpt-test")).toBeInTheDocument()); + expect(apiMock).toHaveBeenCalledTimes(2); + expect(screen.getByText(/Prices as of/)).toBeInTheDocument(); + expect(screen.getByText(/litellm/)).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/app/components/settings/sections/ModelPricingSection.tsx b/packages/dashboard/app/components/settings/sections/ModelPricingSection.tsx new file mode 100644 index 0000000000..c3e2d1745c --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ModelPricingSection.tsx @@ -0,0 +1,193 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { ModelPricing, ModelPricingOverrides } from "@fusion/core"; +import { api } from "../../../api"; +import type { ToastType } from "../../../hooks/useToast"; +import type { SetSettingsForm, SettingsFormState } from "./context"; +import "./ModelPricingSection.css"; + +interface PricingFetchResponse { + count: number; + fetchedAt: string; + source: string; +} + +interface ModelPricingSectionProps { + form: SettingsFormState; + setForm: SetSettingsForm; + addToast: (message: string, type?: ToastType) => void; + projectId?: string; +} + +interface PricingDraft { + key: string; + inputPer1M: number; + outputPer1M: number; + cacheReadPer1M: number; + cacheWritePer1M: number; + source: string; +} + +function pricingToDraft(key: string, pricing: ModelPricing): PricingDraft { + return { key, ...pricing }; +} + +function normalizePricingKey(value: string): string { + return value.trim().toLowerCase(); +} + +function parseRate(value: string): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function pricingPath(projectId?: string): string { + return projectId + ? `/command-center/pricing/fetch?projectId=${encodeURIComponent(projectId)}` + : "/command-center/pricing/fetch"; +} + +function setOverrides(setForm: SetSettingsForm, overrides: ModelPricingOverrides): void { + setForm((current) => ({ + ...current, + modelPricingOverrides: overrides, + })); +} + +/** + * FNXC:Settings 2026-06-22-00:00: + * Global Models needs an editable model-pricing override table plus a one-click LiteLLM refresh. Edits flow through the existing Settings save path, while fetch persists immediately through the Command Center pricing route and then refreshes this form from global settings. + */ +export function ModelPricingSection({ form, setForm, addToast, projectId }: ModelPricingSectionProps) { + const { t } = useTranslation("app"); + const [draft, setDraft] = useState({ + key: "", + inputPer1M: 0, + outputPer1M: 0, + cacheReadPer1M: 0, + cacheWritePer1M: 0, + source: "manual", + }); + const [fetching, setFetching] = useState(false); + + const rows = useMemo( + () => Object.entries(form.modelPricingOverrides ?? {}).sort(([a], [b]) => a.localeCompare(b)), + [form.modelPricingOverrides], + ); + + const updateRow = (key: string, patch: Partial) => { + const current = form.modelPricingOverrides ?? {}; + const existing = current[key]; + if (!existing) return; + setOverrides(setForm, { + ...current, + [key]: { ...existing, ...patch }, + }); + }; + + const deleteRow = (key: string) => { + const next = { ...(form.modelPricingOverrides ?? {}) }; + delete next[key]; + setOverrides(setForm, next); + }; + + const addRow = () => { + const key = normalizePricingKey(draft.key); + if (!key || !key.includes(":")) { + addToast(t("settings.modelPricing.invalidKey", "Use a provider:model key before adding a pricing row."), "error"); + return; + } + setOverrides(setForm, { + ...(form.modelPricingOverrides ?? {}), + [key]: { + inputPer1M: draft.inputPer1M, + outputPer1M: draft.outputPer1M, + cacheReadPer1M: draft.cacheReadPer1M, + cacheWritePer1M: draft.cacheWritePer1M, + source: draft.source || "manual", + }, + }); + setDraft({ key: "", inputPer1M: 0, outputPer1M: 0, cacheReadPer1M: 0, cacheWritePer1M: 0, source: "manual" }); + }; + + const fetchLatestPrices = async () => { + setFetching(true); + try { + const result = await api(pricingPath(projectId), { method: "POST" }); + const settings = await api>("/settings/global"); + setForm((current) => ({ + ...current, + modelPricingOverrides: settings.modelPricingOverrides ?? current.modelPricingOverrides, + modelPricingFetchedAt: settings.modelPricingFetchedAt ?? result.fetchedAt, + modelPricingSource: settings.modelPricingSource ?? result.source, + })); + addToast(t("settings.modelPricing.fetchSuccess", "Fetched {{count}} model prices.", { count: result.count }), "success"); + } catch (error) { + addToast(error instanceof Error ? error.message : t("settings.modelPricing.fetchFailed", "Failed to fetch latest model prices."), "error"); + } finally { + setFetching(false); + } + }; + + return ( +
+
+
+

{t("settings.modelPricing.title", "Model Pricing")}

+

+ {t("settings.modelPricing.description", "Override per-1M token rates used by Command Center cost estimates. Overrides win over the built-in baseline; unlisted models still use the baseline.")} +

+

+ {form.modelPricingFetchedAt + ? t("settings.modelPricing.pricesAsOf", "Prices as of {{date}}", { date: new Date(form.modelPricingFetchedAt).toLocaleString() }) + : t("settings.modelPricing.noFetchYet", "No fetched pricing snapshot yet.")} + {form.modelPricingSource ? ` · ${form.modelPricingSource}` : ""} +

+
+ +
+ +
+
+ {t("settings.modelPricing.modelKey", "provider:model")} + {t("settings.modelPricing.input", "Input / 1M")} + {t("settings.modelPricing.output", "Output / 1M")} + {t("settings.modelPricing.cacheRead", "Cache read / 1M")} + {t("settings.modelPricing.cacheWrite", "Cache write / 1M")} + {t("settings.modelPricing.source", "Source")} + {t("settings.modelPricing.actions", "Actions")} +
+ {rows.length === 0 ? ( +
+ {t("settings.modelPricing.empty", "No model pricing overrides yet. Add one manually or fetch the latest LiteLLM prices.")} +
+ ) : rows.map(([key, pricing]) => { + const row = pricingToDraft(key, pricing); + return ( +
+ {row.key} + updateRow(key, { inputPer1M: parseRate(event.target.value) })} /> + updateRow(key, { outputPer1M: parseRate(event.target.value) })} /> + updateRow(key, { cacheReadPer1M: parseRate(event.target.value) })} /> + updateRow(key, { cacheWritePer1M: parseRate(event.target.value) })} /> + updateRow(key, { source: event.target.value })} /> + +
+ ); + })} +
+ setDraft((current) => ({ ...current, key: event.target.value }))} /> + setDraft((current) => ({ ...current, inputPer1M: parseRate(event.target.value) }))} /> + setDraft((current) => ({ ...current, outputPer1M: parseRate(event.target.value) }))} /> + setDraft((current) => ({ ...current, cacheReadPer1M: parseRate(event.target.value) }))} /> + setDraft((current) => ({ ...current, cacheWritePer1M: parseRate(event.target.value) }))} /> + setDraft((current) => ({ ...current, source: event.target.value }))} /> + +
+
+ {t("settings.modelPricing.saveHint", "Manual edits are saved with the rest of Global settings.")} +
+ ); +} diff --git a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts index 89edf9ede3..0494217358 100644 --- a/packages/dashboard/src/__tests__/register-command-center-routes.test.ts +++ b/packages/dashboard/src/__tests__/register-command-center-routes.test.ts @@ -8,8 +8,8 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { EventEmitter } from "node:events"; -import { Database, emitUsageEvent } from "@fusion/core"; -import type { TaskStore } from "@fusion/core"; +import { Database, emitUsageEvent, LITELLM_PRICING_SOURCE_URL } from "@fusion/core"; +import type { GlobalSettings, TaskStore } from "@fusion/core"; import { request } from "../test-request.js"; import { ApiError } from "../api-error.js"; import { @@ -21,6 +21,14 @@ import { } from "../routes/register-command-center-routes.js"; import type { ApiRoutesContext } from "../routes/types.js"; +const { mockInvalidateAllGlobalSettingsCaches } = vi.hoisted(() => ({ + mockInvalidateAllGlobalSettingsCaches: vi.fn(), +})); + +vi.mock("../project-store-resolver.js", () => ({ + invalidateAllGlobalSettingsCaches: mockInvalidateAllGlobalSettingsCaches, +})); + /** Seed a temp DB with a token-bearing task and a tool-call usage event. */ function seedDb(db: Database, opts: { taskId: string; model: string; tokens: number }): void { db.prepare( @@ -197,9 +205,27 @@ function buildApp(stores: Record, fallback: TaskStore) { } /** A minimal TaskStore exposing only the methods Command Center routes use. */ -function storeFor(db: Database, overrides: Partial = {}): TaskStore { +function storeFor( + db: Database, + overrides: Partial = {}, + globalSettings: Partial = {}, +): TaskStore { const store = new EventEmitter() as unknown as TaskStore & { getDatabase(): Database }; + const settings = { + modelPricingOverrides: undefined, + modelPricingFetchedAt: undefined, + modelPricingSource: undefined, + ...globalSettings, + } as GlobalSettings; store.getDatabase = () => db; + store.getGlobalSettingsStore = () => ({ + getSettings: async () => settings, + invalidateCache: vi.fn(), + } as unknown as ReturnType); + store.updateGlobalSettings = vi.fn(async (patch: Partial) => { + Object.assign(settings, patch); + return settings; + }) as TaskStore["updateGlobalSettings"]; Object.assign(store, overrides); return store; } @@ -227,6 +253,8 @@ describe("register-command-center-routes", () => { }); afterEach(() => { + vi.restoreAllMocks(); + mockInvalidateAllGlobalSettingsCaches.mockClear(); dbA.close(); dbB.close(); rmSync(tmpDir, { recursive: true, force: true }); @@ -263,6 +291,119 @@ describe("register-command-center-routes", () => { expect(body.series?.[0]).toHaveProperty("cost"); }); + it("token analytics applies persisted pricing overrides", async () => { + const storeA = storeFor(dbA, {}, { + modelPricingOverrides: { + "anthropic:claude-sonnet-4-5": { + inputPer1M: 1_000_000, + outputPer1M: 1_000_000, + cacheReadPer1M: 1_000_000, + cacheWritePer1M: 1_000_000, + source: "manual", + }, + }, + }); + app = buildApp({ "proj-a": storeA }, storeA); + + const res = await request( + app, + "GET", + "/api/command-center/tokens?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-a", + ); + + expect(res.status).toBe(200); + expect((res.body as { cost: { usd: number } }).cost.usd).toBeCloseTo(200, 2); + }); + + it("fetches latest pricing and persists merged global overrides", async () => { + const storeA = storeFor(dbA, {}, { + modelPricingOverrides: { + "manual:custom": { + inputPer1M: 9, + outputPer1M: 9, + cacheReadPer1M: 9, + cacheWritePer1M: 9, + source: "manual", + }, + }, + }); + app = buildApp({ "proj-a": storeA }, storeA); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ + "gpt-test": { + litellm_provider: "openai", + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + }, + }), + text: async () => "", + } as Response); + + const res = await request(app, "POST", "/api/command-center/pricing/fetch?projectId=proj-a"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ count: 1, source: LITELLM_PRICING_SOURCE_URL }); + expect((res.body as { fetchedAt: string }).fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(storeA.updateGlobalSettings).toHaveBeenCalledWith(expect.objectContaining({ + modelPricingFetchedAt: expect.any(String), + modelPricingSource: LITELLM_PRICING_SOURCE_URL, + modelPricingOverrides: expect.objectContaining({ + "manual:custom": expect.objectContaining({ source: "manual" }), + "openai:gpt-test": expect.objectContaining({ inputPer1M: 1, outputPer1M: 2 }), + }), + })); + expect(mockInvalidateAllGlobalSettingsCaches).toHaveBeenCalledTimes(1); + }); + + it("pricing fetch failures preserve existing overrides", async () => { + const existing = { + "anthropic:claude-sonnet-4-5": { + inputPer1M: 99, + outputPer1M: 99, + cacheReadPer1M: 99, + cacheWritePer1M: 99, + source: "manual", + }, + }; + const storeA = storeFor(dbA, {}, { modelPricingOverrides: existing }); + app = buildApp({ "proj-a": storeA }, storeA); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("network down")); + + const res = await request(app, "POST", "/api/command-center/pricing/fetch?projectId=proj-a"); + + expect(res.status).toBe(500); + expect(storeA.updateGlobalSettings).not.toHaveBeenCalled(); + expect((await storeA.getGlobalSettingsStore().getSettings()).modelPricingOverrides).toEqual(existing); + expect(mockInvalidateAllGlobalSettingsCaches).not.toHaveBeenCalled(); + }); + + it("pricing fetch rejects empty parsed data without clobbering overrides", async () => { + const existing = { + "anthropic:claude-sonnet-4-5": { + inputPer1M: 99, + outputPer1M: 99, + cacheReadPer1M: 99, + cacheWritePer1M: 99, + source: "manual", + }, + }; + const storeA = storeFor(dbA, {}, { modelPricingOverrides: existing }); + app = buildApp({ "proj-a": storeA }, storeA); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ sample_spec: {}, "embedding-test": { litellm_provider: "openai", mode: "embedding" } }), + text: async () => "", + } as Response); + + const res = await request(app, "POST", "/api/command-center/pricing/fetch?projectId=proj-a"); + + expect(res.status).toBe(502); + expect(storeA.updateGlobalSettings).not.toHaveBeenCalled(); + expect((await storeA.getGlobalSettingsStore().getSettings()).modelPricingOverrides).toEqual(existing); + }); + it("ignores invalid token granularity rather than erroring", async () => { const res = await request( app, @@ -303,6 +444,31 @@ describe("register-command-center-routes", () => { ); }); + it("team analytics applies persisted pricing overrides", async () => { + seedTeamMetrics(dbA, { agentId: "agent-route-a", name: "Route Alpha", tokens: 100, taskId: "FN-A-team-override" }); + const storeA = storeFor(dbA, {}, { + modelPricingOverrides: { + "anthropic:claude-sonnet-4-5": { + inputPer1M: 1_000_000, + outputPer1M: 1_000_000, + cacheReadPer1M: 1_000_000, + cacheWritePer1M: 1_000_000, + source: "manual", + }, + }, + }); + app = buildApp({ "proj-a": storeA }, storeA); + + const res = await request( + app, + "GET", + "/api/command-center/team?from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z&projectId=proj-a", + ); + + expect(res.status).toBe(200); + expect((res.body as { totals: { cost: { usd: number } } }).totals.cost.usd).toBeCloseTo(200, 2); + }); + it("returns the tools / activity / productivity aggregator shapes", async () => { const range = "from=2026-02-01T00:00:00.000Z&to=2026-04-01T00:00:00.000Z"; seedAgentRun(dbA, { id: "run-a1", agentId: "agent-route", startedAt: "2026-03-02T00:00:00.000Z", status: "active" }); diff --git a/packages/dashboard/src/routes/register-command-center-routes.ts b/packages/dashboard/src/routes/register-command-center-routes.ts index 4e56d1bd57..52c2ee711e 100644 --- a/packages/dashboard/src/routes/register-command-center-routes.ts +++ b/packages/dashboard/src/routes/register-command-center-routes.ts @@ -8,6 +8,8 @@ import { aggregateGithubIssueAnalytics, aggregateSignalsAnalytics, composeLiveSnapshot, + LITELLM_PRICING_SOURCE_URL, + parseLiteLLMPricing, type TokenGroupBy, type TokenTimeGranularity, } from "@fusion/core"; @@ -22,6 +24,7 @@ import { githubIssueAnalyticsToTable, type CsvTable, } from "../command-center-csv.js"; +import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js"; import type { ApiRouteRegistrar } from "./types.js"; /** @@ -138,6 +141,22 @@ function sendCsv(res: Response, filename: string, table: CsvTable): void { res.send(serializeCsv(table)); } +const PRICING_FETCH_TIMEOUT_MS = 10_000; + +async function fetchLatestLiteLLMPricing(): Promise { + const response = await fetch(LITELLM_PRICING_SOURCE_URL, { + signal: AbortSignal.timeout(PRICING_FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new ApiError( + 502, + `Failed to fetch pricing source: ${response.status} ${response.statusText}${body ? `: ${body.slice(0, 200)}` : ""}`, + ); + } + return response.json() as Promise; +} + export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { const { router, getScopedStore, rethrowAsApiError } = ctx; @@ -152,12 +171,14 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { const range = resolveRange(req.query); const groupBy = resolveGroupBy(req.query); const granularity = resolveTokenGranularity(req.query); + const settings = await store.getGlobalSettingsStore().getSettings(); const result = aggregateTokenAnalytics(store.getDatabase(), { from: range.from, to: range.to, groupBy, granularity, now: Date.now(), + pricingOverrides: settings.modelPricingOverrides, }); if (wantsCsv(req.query)) { sendCsv(res, "command-center-tokens.csv", tokenAnalyticsToTable(result)); @@ -170,6 +191,41 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { } }); + /** + * POST /api/command-center/pricing/fetch + * Fetch + persist user-editable model-pricing overrides from LiteLLM. + * + * FNXC:CommandCenter 2026-06-22-00:00: + * Operators need a one-click refresh from the pinned LiteLLM JSON dataset without adding HTTP to core pricing. Preserve existing overrides on fetch/parse failures and invalidate global settings caches after a successful write so Command Center cost reads use the refreshed rates immediately. + */ + router.post("/command-center/pricing/fetch", async (req, res) => { + try { + const store = await getScopedStore(req); + const json = await fetchLatestLiteLLMPricing(); + const parsed = parseLiteLLMPricing(json); + if (parsed.count === 0) { + throw new ApiError(502, "No chat-mode pricing entries found in fetched LiteLLM data"); + } + + const settings = await store.getGlobalSettingsStore().getSettings(); + const fetchedAt = new Date().toISOString(); + await store.updateGlobalSettings({ + modelPricingOverrides: { + ...(settings.modelPricingOverrides ?? {}), + ...parsed.overrides, + }, + modelPricingFetchedAt: fetchedAt, + modelPricingSource: LITELLM_PRICING_SOURCE_URL, + }); + invalidateAllGlobalSettingsCaches(); + + res.json({ count: parsed.count, fetchedAt, source: LITELLM_PRICING_SOURCE_URL }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err, "Failed to fetch model pricing"); + } + }); + /** * GET /api/command-center/tools * Tool-usage counts + autonomy ratio (U2) over a date range. @@ -274,10 +330,12 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => { try { const store = await getScopedStore(req); const range = resolveRange(req.query); + const settings = await store.getGlobalSettingsStore().getSettings(); const result = aggregateTeamAnalytics(store.getDatabase(), { from: range.from, to: range.to, now: Date.now(), + pricingOverrides: settings.modelPricingOverrides, }); res.json(result); } catch (err: unknown) {