diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json
index 39c83efbda..7b49f4ef2f 100644
--- a/apps/desktop/e2e-budget.json
+++ b/apps/desktop/e2e-budget.json
@@ -45,6 +45,10 @@
"tests": 6,
"electron": "Git changes re-read on window focus, terminal PTY ownership across Sessions, Side Chat's fork lifecycle, a first send that has to reach the Host, and per-Session collapse persisted across a renderer reload; the composer-usage test is renderer-only and rides along on those windows until app-shell.tsx's composer-to-workbar wiring has a story host"
},
+ "settings-pricing.spec.ts": {
+ "tests": 1,
+ "electron": "the effective pricing snapshot is a main-process round trip to the real embedded Runtime Host (no bridge stub -- an enabled Add proves it loaded), and closing the editor must return real Electron focus to its trigger, which a linkedom story cannot honestly exercise"
+ },
"settings.spec.ts": {
"tests": 4,
"electron": "the preload makaE2eLatch holds the settings chunk mid-load, and the rename it commits is a Host write; the workbar-chrome test rides along on that window and would move to a story the day app-shell.tsx's settings wiring has one"
diff --git a/apps/desktop/e2e/settings-pricing.spec.ts b/apps/desktop/e2e/settings-pricing.spec.ts
new file mode 100644
index 0000000000..8c916f7b66
--- /dev/null
+++ b/apps/desktop/e2e/settings-pricing.spec.ts
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ensureSidebarExpanded, expect, test } from './fixtures';
+
+// Real path: 设置 → 使用统计 → 定价配置. The editable Pricing tab (#2015 / PR #4164,
+// integrated into the features/usage slice) reads ONE Host-backed effective
+// pricing snapshot from the real embedded Runtime Host — no bridge stub. Per the
+// maintainer direction on #2218 the surface is OVERRIDES-ONLY: the table lists
+// only the user's custom rows, and the ~1.4k built-in catalog is reached only
+// through the Add flow's Typeahead picker (never rendered as a table, so nothing
+// heavy renders). This exercises #2015 acceptance #2 (the tab is not time-scoped:
+// the Usage date range/summary toolbar is gone) and #11 (dialogs return focus to
+// a stable control, including when reset/delete removes the opening row action —
+// real Electron focus the linkedom harness cannot honestly exercise), plus the
+// overrides-only shape and the picker/manual Add UI.
+test('pricing tab is overrides-only with a catalog-picker Add flow, is not time-scoped, and restores focus', async ({
+ window: page,
+}) => {
+ await ensureSidebarExpanded(page);
+ await page.getByRole('button', { name: '设置' }).click();
+ await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible();
+
+ await page.getByRole('button', { name: '使用统计', exact: true }).click();
+ // The Usage tabs render as a `navigation` (named by the view's aria-label)
+ // whose tabs are `button`s.
+ await page
+ .getByRole('navigation', { name: '使用统计视图' })
+ .getByRole('button', { name: '定价配置', exact: true })
+ .click();
+
+ // The Pricing panel owns its own explanatory copy and its own Add control,
+ // instead of the Usage range chrome. An enabled Add proves the snapshot loaded.
+ await expect(page.getByText('美元 / 每百万 token。', { exact: false })).toBeVisible();
+ const addButton = page.getByRole('button', { name: '添加定价' });
+ await expect(addButton).toBeEnabled();
+
+ // #2015 acceptance #2: the Usage range + summary toolbar must be absent on the
+ // Pricing tab so the Usage date range cannot read as a Pricing scope.
+ await expect(page.getByRole('group', { name: '使用统计范围与刷新' })).toHaveCount(0);
+ await expect(page.getByRole('group', { name: '使用统计汇总指标' })).toHaveCount(0);
+
+ // Overrides-only: the built-in catalog is never listed as table rows, so no
+ // 来源 = 内置 cell appears anywhere on the panel (holds whether the Host has
+ // zero or many overrides).
+ await expect(page.getByText('内置', { exact: true })).toHaveCount(0);
+
+ // The Add flow opens in catalog mode and offers a manual-entry fallback;
+ // switching to it reveals the free-text key inputs for a model not in the
+ // catalog.
+ await addButton.click();
+ const editor = page.getByRole('dialog', { name: '添加定价' });
+ await expect(editor).toBeVisible();
+ await editor.getByRole('button', { name: '模型不在列表中?手动输入' }).click();
+ await expect(editor.getByRole('textbox', { name: '模型键' })).toBeVisible();
+
+ // #2015 acceptance #11: closing the editor returns focus to the trigger.
+ await editor.getByRole('button', { name: '取消' }).click();
+ await expect(editor).toHaveCount(0);
+ await expect(addButton).toBeFocused();
+
+ // A successful delete removes the row action that opened its dialog. Focus
+ // must land on the stable Add control instead of falling back to
.
+ const focusModelKey = `e2e:pricing-focus-${Date.now()}`;
+ await addButton.click();
+ const addEditor = page.getByRole('dialog', { name: '添加定价' });
+ await addEditor.getByRole('button', { name: '模型不在列表中?手动输入' }).click();
+ await addEditor.getByRole('textbox', { name: /模型键/ }).fill(focusModelKey);
+ await addEditor.getByRole('spinbutton', { name: /输入价格/ }).fill('1');
+ await addEditor.getByRole('spinbutton', { name: /输出价格/ }).fill('2');
+ await addEditor.getByRole('button', { name: '保存' }).click();
+ await expect(addEditor).toHaveCount(0);
+ await expect(page.getByText(focusModelKey, { exact: true })).toBeVisible();
+
+ await page.getByRole('button', { name: `删除「${focusModelKey}」定价` }).click();
+ const deleteDialog = page.getByRole('alertdialog', { name: '删除定价' });
+ await deleteDialog.getByRole('button', { name: '删除', exact: true }).click();
+ await expect(deleteDialog).toHaveCount(0);
+ await expect(page.getByText(focusModelKey, { exact: true })).toHaveCount(0);
+ await expect(addButton).toBeFocused();
+});
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 800dc4b634..4dad21124a 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -78,6 +78,7 @@
"src/renderer/locales/settings-memory-copy.ts",
"src/renderer/locales/settings-navigation-copy.ts",
"src/renderer/locales/settings-preferences-copy.ts",
+ "src/renderer/locales/settings-pricing-copy.ts",
"src/renderer/locales/settings-projects-copy.ts",
"src/renderer/locales/settings-shared-copy.ts",
"src/renderer/locales/settings-subagents-copy.ts",
@@ -1661,6 +1662,15 @@
"actionFactories": [],
"dependencyPaths": {}
},
+ "src/renderer/locales/settings-pricing-copy.ts": {
+ "bridgePaths": {},
+ "environmentCapabilities": {},
+ "hookCalls": {},
+ "lifecycleMethods": {},
+ "unresolvedDependencies": 0,
+ "actionFactories": [],
+ "dependencyPaths": {}
+ },
"src/renderer/locales/settings-projects-copy.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
diff --git a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
index bf762c4b3b..775dab3db1 100644
--- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
+++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts
@@ -232,7 +232,6 @@ test('projects only present Usage Session ids into the Desktop host namespace',
byProvider: [],
byModel: [],
byTool: [],
- pricing: [],
provenance: EMPTY_USAGE_PROVENANCE,
};
diff --git a/apps/desktop/src/main/__tests__/pricing-editor.test.ts b/apps/desktop/src/main/__tests__/pricing-editor.test.ts
new file mode 100644
index 0000000000..81eb885739
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/pricing-editor.test.ts
@@ -0,0 +1,1183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { strict as assert } from 'node:assert';
+import { afterEach, describe, it } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { StrictMode, act, createElement } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui';
+import { createDefaultSettings } from '@maka/core/settings';
+import type {
+ DesktopPricingMutationInput,
+ DesktopPricingMutationOutcome,
+ DesktopPricingSnapshot,
+} from '../../shared/desktop-pricing.js';
+import {
+ getPricingSettingsCopy,
+ PricingEditor,
+ UsagePricingServicesProvider,
+ UsageFeatureScope,
+ formatCache,
+ formatUsd,
+ type UsageHostRef,
+ type UsagePricingServices,
+} from '../../renderer/features/usage/testing.js';
+
+const copy = getPricingSettingsCopy('en');
+
+const TEST_RUNTIME_HOST: UsageHostRef = { profileId: 'test-profile', hostId: 'test-host' };
+
+const SNAPSHOT: DesktopPricingSnapshot = {
+ hostEpoch: 'epoch-1',
+ connectionId: 'conn-1',
+ revision: 5,
+ entries: [
+ { source: 'builtin', pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 2.5, outputUsdPer1M: 10 } },
+ {
+ source: 'custom',
+ resetEffect: 'restore_builtin',
+ pricing: { modelKey: 'anthropic:claude', inputUsdPer1M: 2, outputUsdPer1M: 12 },
+ },
+ ],
+};
+
+const originalGlobals = {
+ document: globalThis.document,
+ window: globalThis.window,
+ matchMedia: globalThis.matchMedia,
+ HTMLElement: globalThis.HTMLElement,
+ HTMLIFrameElement: globalThis.HTMLIFrameElement,
+ getComputedStyle: globalThis.getComputedStyle,
+ requestAnimationFrame: globalThis.requestAnimationFrame,
+ cancelAnimationFrame: globalThis.cancelAnimationFrame,
+ CSS: (globalThis as { CSS?: unknown }).CSS,
+ IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
+ .IS_REACT_ACT_ENVIRONMENT,
+};
+
+afterEach(() => {
+ Object.assign(globalThis, originalGlobals);
+});
+
+describe('PricingEditor', () => {
+ it('renders only the user overrides; built-ins are catalog-only', async () => {
+ const harness = await renderEditor({ load: async () => SNAPSHOT });
+ // The custom override is listed with its 自定义 source label.
+ assert.match(harness.container.textContent ?? '', /anthropic:claude/);
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.sourceCustomFallback));
+ // The built-in is NOT rendered as a table row — it is reachable only through
+ // the Add flow's catalog picker — nor does the 内置 source label appear.
+ assert.doesNotMatch(harness.container.textContent ?? '', /openai:gpt-4o/);
+ assert.doesNotMatch(harness.container.textContent ?? '', /Built-in/);
+ assert.equal(harness.loadCalls(), 1);
+ // The Pricing tab loads against the settings-SELECTED Host it was handed —
+ // not the app's active Host (an omitted arg) — so it stays in lockstep with
+ // the rest of the settings page.
+ assert.deepEqual(harness.loadHosts, [TEST_RUNTIME_HOST]);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('the Add dialog offers a catalog picker with a manual-entry fallback', async () => {
+ const harness = await renderEditor({ load: async () => SNAPSHOT });
+ await click(buttonByText(harness.doc, copy.add));
+ // Catalog mode is the default: no free-text model-key input, plus a toggle to
+ // manual entry (inferred without depending on the Typeahead's internal DOM).
+ assert.equal(
+ inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder),
+ undefined,
+ 'no free-text model-key input in catalog mode',
+ );
+ const toManual = buttonByText(harness.doc, copy.manualEntryToggle);
+ assert.ok(toManual, 'manual-entry toggle present in catalog mode');
+ // Switching to manual reveals one exact model-key input + a toggle
+ // back to the catalog.
+ await click(toManual);
+ assert.ok(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'manual model-key input');
+ assert.ok(buttonByText(harness.doc, copy.catalogToggle), 'catalog toggle present in manual mode');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('selecting a built-in catalog model saves an override', async () => {
+ const committed: DesktopPricingSnapshot = {
+ ...SNAPSHOT,
+ revision: 6,
+ entries: [
+ {
+ source: 'custom',
+ resetEffect: 'restore_builtin',
+ pricing: {
+ modelKey: 'openai:gpt-4o',
+ inputUsdPer1M: 2.5,
+ outputUsdPer1M: 10,
+ },
+ },
+ SNAPSHOT.entries[1]!,
+ ],
+ };
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }),
+ });
+ await click(buttonByText(harness.doc, copy.add));
+ await setInput(inputByPlaceholder(harness.doc, copy.catalogPickerPlaceholder), 'openai');
+ const option = Array.from(harness.doc.querySelectorAll('[role="option"]')).find(
+ (item) => item.textContent?.includes('openai:gpt-4o'),
+ );
+ assert.ok(option, 'catalog search exposes the built-in model');
+ await clickElement(option);
+ await click(buttonByText(harness.doc, copy.save));
+
+ assert.equal(harness.mutations.length, 1);
+ assert.deepEqual(harness.mutations[0]?.mutation, {
+ kind: 'upsert',
+ pricing: {
+ modelKey: 'openai:gpt-4o',
+ inputUsdPer1M: 2.5,
+ outputUsdPer1M: 10,
+ },
+ });
+ await act(async () => harness.root.unmount());
+ });
+
+ it('switching entry modes clears values that would otherwise be hidden', async () => {
+ const harness = await renderEditor({ load: async () => SNAPSHOT });
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:hidden');
+ const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter(
+ (input) => !input.getAttribute('placeholder'),
+ );
+ await setInput(rateInputs[0], '1');
+ await setInput(rateInputs[1], '2');
+ await click(buttonByText(harness.doc, copy.catalogToggle));
+ await click(buttonByText(harness.doc, copy.save));
+
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.errorRequired));
+ assert.equal(harness.mutations.length, 0);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('does not load host-scoped pricing when no Host is selected', async () => {
+ let loadInvoked = false;
+ const harness = await renderEditor({
+ host: null,
+ load: async () => {
+ loadInvoked = true;
+ return SNAPSHOT;
+ },
+ });
+ // No selected Host: the controller resolves an empty state instead of
+ // reaching the bridge (which would otherwise fall back to the active Host).
+ assert.equal(loadInvoked, false);
+ assert.equal(harness.loadCalls(), 0);
+ // Without a loaded snapshot there is no CAS base, so Add is disabled rather
+ // than opening an editor whose save would silently no-op.
+ assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('keeps fresh authority when a StrictMode mount read settles after the replacement read', async () => {
+ const oldRead = deferred();
+ const latest = { ...SNAPSHOT, revision: 6 };
+ let reads = 0;
+ const harness = await renderEditor({
+ strict: true,
+ load: async () => ++reads === 1 ? oldRead.promise : latest,
+ mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: latest }),
+ });
+ assert.equal(harness.loadCalls(), 2, 'StrictMode restarts the initial load');
+ await act(async () => oldRead.resolve({ ...SNAPSHOT, entries: [] }));
+ assert.match(harness.container.textContent ?? '', /anthropic:claude/);
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+ assert.equal(harness.mutations[0]?.base.revision, 6);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('reset sends a delete against the loaded snapshot', async () => {
+ const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] };
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }),
+ });
+
+ const resetButton = buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'));
+ assert.ok(resetButton, 'reset button is present for a custom-with-fallback row');
+ await click(resetButton);
+
+ const confirmButton = buttonByText(harness.doc, copy.confirmReset);
+ assert.ok(confirmButton, 'confirm dialog exposes the reset action');
+ await click(confirmButton);
+
+ assert.equal(harness.mutations.length, 1);
+ const mutation = harness.mutations[0]!;
+ // The renderer carries the snapshot it loaded as the CAS base — same revision
+ // and Host stamp — never a freshly reloaded latest.
+ assert.deepEqual(mutation.base, SNAPSHOT);
+ assert.deepEqual(mutation.mutation, { kind: 'delete', modelKey: 'anthropic:claude' });
+ // The mutation targets the same settings-selected Host as the load.
+ assert.deepEqual(harness.mutateHosts, [TEST_RUNTIME_HOST]);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('moves focus to Add after a successful reset removes its trigger row', async () => {
+ const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] };
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }),
+ });
+ const resetButton = buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'));
+ const addButton = buttonByText(harness.doc, copy.add);
+ assert.ok(resetButton);
+ assert.ok(addButton);
+ let focused: HTMLElement | null = null;
+ resetButton.focus = () => { focused = resetButton; };
+ addButton.focus = () => { focused = addButton; };
+
+ await click(resetButton);
+ await click(buttonByText(harness.doc, copy.confirmReset));
+
+ assert.equal(resetButton.isConnected, false, 'the reset trigger leaves with its override row');
+ assert.equal(focused, addButton, 'the committed row removal falls back to the stable Add action');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('a saved-but-refresh-failed outcome disables further writes', async () => {
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }),
+ });
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.refreshFailedTitle));
+ // #2015: the committed-but-unrefreshed list is now stale — it must not be
+ // shown as authoritative, so the previously-listed override is cleared until
+ // a successful refresh.
+ assert.doesNotMatch(
+ harness.container.querySelector('table')?.textContent ?? '',
+ /anthropic:claude/,
+ );
+ assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.emptyTitle),
+ 'discarded authority must not claim that there are no overrides');
+ const addButton = buttonByText(harness.doc, copy.add);
+ assert.ok(addButton);
+ // A disabled control that carries its reason via tooltip stays focusable and
+ // marks itself with aria-disabled rather than the native disabled attribute
+ // (DESIGN.md §Fields), so the write-block reason stays discoverable.
+ assert.equal(addButton.getAttribute('aria-disabled'), 'true');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('keeps a saved reset reachable from its dialog until pricing refreshes', async () => {
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }),
+ });
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+
+ const dialog = openDialog(harness.doc);
+ assert.ok(dialog, 'the pending reset stays reachable after refresh failure');
+ assert.match(dialog.textContent ?? '', new RegExp(copy.refreshFailedTitle));
+ assert.ok(buttonByText(dialog, copy.refresh), 'recovery is available inside the modal focus trap');
+ assertButtonDisabled(buttonByText(dialog, copy.confirmReset));
+ await act(async () => harness.root.unmount());
+ });
+
+ it('keeps an upsert draft reachable and refreshes from inside the editor', async () => {
+ const committed: DesktopPricingSnapshot = {
+ ...SNAPSHOT,
+ revision: 6,
+ entries: [
+ ...SNAPSHOT.entries,
+ {
+ source: 'custom',
+ resetEffect: 'become_unpriced',
+ pricing: { modelKey: 'acme:draft', inputUsdPer1M: 1, outputUsdPer1M: 2 },
+ },
+ ],
+ };
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return loadCall === 1 ? SNAPSHOT : committed;
+ },
+ mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }),
+ });
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:draft');
+ const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter(
+ (input) => !input.getAttribute('placeholder'),
+ );
+ await setInput(rateInputs[0], '1');
+ await setInput(rateInputs[1], '2');
+ await click(buttonByText(harness.doc, copy.save));
+
+ const dialog = openDialog(harness.doc);
+ assert.ok(dialog, 'the draft stays open after the committed refresh failure');
+ assert.match(dialog.textContent ?? '', new RegExp(copy.refreshFailedTitle));
+ assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:draft');
+ const refresh = buttonByText(dialog, copy.refresh);
+ assert.ok(refresh, 'recovery is available inside the modal focus trap');
+ await click(refresh);
+
+ assert.equal(openDialog(harness.doc), undefined, 'a successful refresh completes the saved draft');
+ assert.match(harness.container.textContent ?? '', /acme:draft/);
+ assert.equal(harness.loadCalls(), 2);
+ assert.equal(harness.mutations.length, 1, 'refresh never replays the upsert');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('a reset conflict keeps the dialog and confirms again against fresh authority', async () => {
+ const latest: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 9 };
+ let calls = 0;
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => {
+ calls += 1;
+ return calls === 1
+ ? { kind: 'review_required', reason: 'revision_conflict', snapshot: latest }
+ : { kind: 'saved', disposition: 'committed', snapshot: latest };
+ },
+ });
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+
+ // The conflict is surfaced inside the modal focus trap and the action label
+ // changes before an explicit second confirm — the mutation is never replayed
+ // blindly while the warning sits behind the dialog.
+ const dialog = openDialog(harness.doc);
+ assert.ok(dialog, 'reset dialog stays open on conflict');
+ assert.match(dialog.textContent ?? '', new RegExp(copy.conflictTitle));
+ const confirmAgain = buttonByText(dialog, copy.reviewReset);
+ assert.ok(confirmAgain, 'the reset action calls out the required review');
+ await click(confirmAgain);
+
+ assert.equal(calls, 2);
+ // The second attempt carries the fresh authority (revision 9) as its base.
+ assert.equal(harness.mutations[1]?.base.revision, 9);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('an uncertain outcome blocks writes and explains the required recovery', async () => {
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }),
+ });
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle));
+ assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('reconciles an unavailable reset against the next authoritative refresh', async () => {
+ const latest: DesktopPricingSnapshot = {
+ ...SNAPSHOT,
+ revision: 6,
+ entries: [
+ SNAPSHOT.entries[0]!,
+ {
+ source: 'custom',
+ resetEffect: 'restore_builtin',
+ pricing: {
+ modelKey: 'anthropic:claude',
+ inputUsdPer1M: 7,
+ outputUsdPer1M: 8,
+ cacheReadUsdPer1M: 0,
+ cacheWriteUsdPer1M: 0.4,
+ },
+ },
+ ],
+ };
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return loadCall === 1 ? SNAPSHOT : latest;
+ },
+ mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }),
+ });
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle));
+
+ const dialog = openDialog(harness.doc);
+ assert.ok(dialog, 'the unreconciled draft stays open');
+ await click(buttonByText(dialog, copy.refresh));
+
+ const text = harness.container.textContent ?? '';
+ assert.match(text, new RegExp(copy.conflictTitleUnknown));
+ assert.match(text, new RegExp(copy.sourceCustomFallback));
+ for (const value of ['$7', '$8', '$0', '$0.4']) assert.ok(text.includes(value));
+ assert.equal(harness.mutations.length, 1, 'refresh reconciles without replaying the reset');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('clears a cancelled reset conflict before opening another editor', async () => {
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'review_required', reason: 'revision_conflict', snapshot: SNAPSHOT }),
+ });
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+ await click(buttonByText(openDialog(harness.doc)!, copy.cancel));
+ await click(buttonByText(harness.doc, copy.add));
+
+ const dialog = openDialog(harness.doc);
+ assert.ok(dialog);
+ assert.doesNotMatch(dialog.textContent ?? '', new RegExp(copy.conflictTitle));
+ assert.ok(buttonByText(dialog, copy.save));
+ await act(async () => harness.root.unmount());
+ });
+
+ it('finishes an unavailable upsert when the refreshed authority matches exactly', async () => {
+ const committed: DesktopPricingSnapshot = {
+ ...SNAPSHOT,
+ revision: 6,
+ entries: [
+ {
+ source: 'custom',
+ resetEffect: 'restore_builtin',
+ pricing: {
+ modelKey: 'openai:gpt-4o',
+ inputUsdPer1M: 2.5,
+ outputUsdPer1M: 10,
+ },
+ },
+ SNAPSHOT.entries[1]!,
+ ],
+ };
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return loadCall === 1 ? SNAPSHOT : committed;
+ },
+ mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }),
+ });
+ await selectCatalogModel(harness.doc, 'openai:gpt-4o');
+ await click(buttonByText(harness.doc, copy.save));
+ const dialog = openDialog(harness.doc);
+ assert.ok(dialog, 'the unreconciled draft stays open');
+ await click(buttonByText(dialog, copy.refresh));
+
+ assert.equal(openDialog(harness.doc), undefined, 'the matched draft is complete');
+ assert.equal(harness.mutations.length, 1, 'reconciliation never replays the upsert');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('blocks form submission while an upsert still needs reconciliation', async () => {
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }),
+ });
+ await selectCatalogModel(harness.doc, 'openai:gpt-4o');
+ await click(buttonByText(harness.doc, copy.save));
+ assertButtonDisabled(buttonByText(harness.doc, copy.save));
+
+ await submitEditor(harness.doc);
+
+ assert.equal(harness.mutations.length, 1, 'form submission must obey the same write blocker');
+ const dialog = openDialog(harness.doc);
+ assert.ok(dialog, 'the unreconciled draft stays open');
+ assert.match(dialog.textContent ?? '', /openai:gpt-4o/, 'the catalog selection stays visible');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('does not mistake equal built-in rates for a reconciled custom override', async () => {
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return SNAPSHOT;
+ },
+ mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }),
+ });
+ await selectCatalogModel(harness.doc, 'openai:gpt-4o');
+ await click(buttonByText(harness.doc, copy.save));
+ await click(buttonByLabel(harness.doc, copy.refresh));
+
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitleUnknown));
+ assert.ok(openDialog(harness.doc), 'the draft remains open for explicit review');
+ assert.equal(harness.mutations.length, 1);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('associates required-field errors with their controls after an empty save', async () => {
+ const harness = await renderEditor({ load: async () => SNAPSHOT });
+ // Open the Add editor and submit it empty.
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.save));
+
+ // The required-field message renders, and at least one control is marked
+ // invalid — the DS wires aria-invalid + aria-describedby to the message, so
+ // the error is announced against its own field rather than floating free.
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.errorRequired));
+ const invalid = harness.doc.querySelector('[aria-invalid="true"]');
+ assert.ok(invalid, 'an empty required field is marked aria-invalid');
+ assert.ok(
+ invalid?.getAttribute('aria-describedby'),
+ 'the invalid field points at its error message via aria-describedby',
+ );
+
+ // No mutation is attempted while the draft is invalid.
+ assert.equal(harness.mutations.length, 0);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('a Host generation change preserves the draft and requires review after reloading authority (P1.1)', async () => {
+ const nextSnapshot: DesktopPricingSnapshot = { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 };
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return loadCall === 1 ? SNAPSHOT : nextSnapshot;
+ },
+ mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: nextSnapshot }),
+ });
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new');
+ const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter(
+ (input) => !input.getAttribute('placeholder'),
+ );
+ await setInput(rateInputs[0], '1');
+ await setInput(rateInputs[1], '2');
+ // A Host generation bump (new epoch) re-renders with a new generationKey.
+ await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`);
+ // The draft remains visible, but the old mutation base is discarded. Even
+ // after the fresh snapshot loads, save stays disabled until explicit review.
+ assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:new');
+ assert.equal(harness.loadCalls(), 2, 'a fresh authority reload ran');
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.hostChangedTitle));
+ assertButtonDisabled(buttonByText(harness.doc, copy.save));
+ await submitEditor(harness.doc);
+ assert.equal(harness.mutations.length, 0, 'form submission cannot bypass Host review');
+ await click(buttonByText(harness.doc, copy.reviewHostChange));
+ assertButtonEnabled(buttonByText(harness.doc, copy.save));
+ await click(buttonByText(harness.doc, copy.save));
+ assert.equal(harness.mutations.length, 1);
+ assert.deepEqual(harness.mutations[0]?.base, nextSnapshot);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('does not allow Host-change review until fresh authority loads', async () => {
+ const secondLoad = deferred();
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return loadCall === 1 ? SNAPSHOT : secondLoad.promise;
+ },
+ });
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new');
+ await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`);
+
+ assert.match(
+ harness.container.querySelector('table')?.getAttribute('aria-label') ?? '',
+ new RegExp(copy.loading),
+ 'the replacement authority renders as loading immediately',
+ );
+ assertButtonDisabled(buttonByText(harness.doc, copy.reviewHostChange));
+ assertButtonDisabled(buttonByText(harness.doc, copy.save));
+ await act(async () => {
+ secondLoad.resolve({ ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ assertButtonEnabled(buttonByText(harness.doc, copy.reviewHostChange));
+ assertButtonDisabled(buttonByText(harness.doc, copy.save));
+ await act(async () => harness.root.unmount());
+ });
+
+ it('preserves the manual draft across the Settings Host gate unmount and rejects its old save', async () => {
+ const previousSave = deferred();
+ const replacement = { ...SNAPSHOT, hostEpoch: 'epoch-2', connectionId: 'conn-2', revision: 1 };
+ let reads = 0;
+ let writes = 0;
+ const harness = await renderEditor({
+ load: async () => ++reads === 1 ? SNAPSHOT : replacement,
+ mutate: async () => ++writes === 1
+ ? previousSave.promise
+ : { kind: 'saved', disposition: 'committed', snapshot: replacement },
+ });
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:retained');
+ const rates = Array.from(harness.doc.querySelectorAll('[role="spinbutton"]'));
+ await setInput(rates[0], '1.25');
+ await setInput(rates[1], '2.75');
+ await clickWithoutSettling(buttonByText(harness.doc, copy.save));
+ await harness.hideView();
+ assert.equal(openDialog(harness.doc), undefined, 'the Settings gate actually unmounts the view');
+ await harness.rerender('replacement-host:epoch-2');
+ await act(async () => previousSave.resolve({ kind: 'saved', disposition: 'committed', snapshot: SNAPSHOT }));
+
+ assert.equal(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder)?.value, 'acme:retained');
+ const restoredRates = Array.from(harness.doc.querySelectorAll('[role="spinbutton"]'));
+ assert.equal(restoredRates[0]?.value, '1.25');
+ assert.equal(restoredRates[1]?.value, '2.75');
+ assertButtonDisabled(buttonByText(harness.doc, copy.save));
+ await click(buttonByText(harness.doc, copy.reviewHostChange));
+ await click(buttonByText(harness.doc, copy.save));
+ assert.equal(harness.mutations.length, 2);
+ assert.deepEqual(harness.mutations[1]?.base, replacement);
+ await act(async () => harness.root.unmount());
+ });
+
+ it('ignores an old-Host save result after the generation changes', async () => {
+ const pendingSave = deferred();
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return loadCall === 1
+ ? SNAPSHOT
+ : { ...SNAPSHOT, hostEpoch: 'epoch-2', revision: 1 };
+ },
+ mutate: async () => pendingSave.promise,
+ });
+ await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude')));
+ const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter(
+ (input) => !input.getAttribute('placeholder'),
+ );
+ await setInput(rateInputs[0], '3');
+ await click(buttonByText(harness.doc, copy.save));
+ await harness.rerender(`${TEST_RUNTIME_HOST.profileId}:${TEST_RUNTIME_HOST.hostId}:e2`);
+ await act(async () => {
+ pendingSave.resolve({
+ kind: 'saved',
+ disposition: 'committed',
+ snapshot: { ...SNAPSHOT, revision: 6 },
+ });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.hostChangedTitle));
+ assert.equal(buttonByText(harness.doc, copy.save)?.textContent, copy.save);
+ assertButtonDisabled(buttonByText(harness.doc, copy.save));
+ await act(async () => harness.root.unmount());
+ });
+
+ it('ignores an old-Host save result when the Host event fences it before React re-renders', async () => {
+ const pendingSave = deferred();
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => pendingSave.promise,
+ });
+ await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude')));
+ await clickWithoutSettling(buttonByText(harness.doc, copy.save));
+ assert.equal(harness.mutations.length, 1, 'the save is in flight before the Host fence');
+ assert.ok(buttonByText(harness.doc, copy.cancel), 'the edit dialog starts open');
+
+ // The settings Host event arrives before React can commit the replacement
+ // generation. Its synchronous fence must already make the in-flight result
+ // stale; otherwise this old Host can close the draft and claim success.
+ harness.fenceTarget();
+ await act(async () => {
+ pendingSave.resolve({
+ kind: 'saved',
+ disposition: 'committed',
+ snapshot: { ...SNAPSHOT, revision: 6 },
+ });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ assert.ok(buttonByText(harness.doc, copy.cancel), 'the old-Host result must not close the preserved draft');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('does not dispatch a write after the Host event fences the still-visible editor', async () => {
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'saved_refresh_failed', disposition: 'committed' }),
+ });
+ await selectCatalogModel(harness.doc, 'openai:gpt-4o');
+ harness.fenceTarget();
+ await click(buttonByText(harness.doc, copy.save));
+
+ assert.equal(harness.mutations.length, 0, 'the stale Host must receive no write');
+ assert.ok(openDialog(harness.doc), 'the draft survives until the replacement Host renders');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('submits once when the form is submitted twice before a render', async () => {
+ const pending = deferred();
+ const harness = await renderEditor({ load: async () => SNAPSHOT, mutate: async () => pending.promise });
+ await selectCatalogModel(harness.doc, 'openai:gpt-4o');
+ const form = openDialog(harness.doc)?.querySelector('form');
+ assert.ok(form);
+ await act(async () => {
+ const EventClass = harness.doc.defaultView!.Event;
+ form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true }));
+ form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true }));
+ await Promise.resolve();
+ });
+ assert.equal(harness.mutations.length, 1);
+ await act(async () => pending.resolve({ kind: 'saved', disposition: 'unchanged', snapshot: SNAPSHOT }));
+ await act(async () => harness.root.unmount());
+ });
+
+ it('a reload landing after a mutation does not overwrite the committed authority (P1.2)', async () => {
+ // The reset commits a claude-less authority; a refresh started earlier is
+ // still in flight and will resolve with the PRE-reset snapshot.
+ const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] };
+ const secondLoad = deferred();
+ let loadCall = 0;
+ const harness = await renderEditor({
+ load: async () => {
+ loadCall += 1;
+ return loadCall === 1 ? SNAPSHOT : secondLoad.promise;
+ },
+ mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }),
+ });
+ // Kick off a manual refresh (reload #2) that stays pending.
+ await click(buttonByLabel(harness.doc, copy.refresh));
+ // Reset the override; the mutation commits the fresh (claude-less) authority.
+ await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude')));
+ await click(buttonByText(harness.doc, copy.confirmReset));
+ assert.doesNotMatch(harness.container.textContent ?? '', /anthropic:claude/, 'committed authority shown');
+ // The stale in-flight refresh resolves with the pre-reset snapshot — it must
+ // be fenced, not resurrect the deleted override.
+ await act(async () => {
+ secondLoad.resolve(SNAPSHOT);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ assert.doesNotMatch(
+ harness.container.textContent ?? '',
+ /anthropic:claude/,
+ 'a stale reload must not overwrite the committed authority',
+ );
+ await act(async () => harness.root.unmount());
+ });
+
+ it('clears an earlier refresh failure when a save returns fresh authority', async () => {
+ const refresh = deferred();
+ const committed = { ...SNAPSHOT, revision: 6 };
+ let reads = 0;
+ const harness = await renderEditor({
+ load: async () => ++reads === 1 ? SNAPSHOT : refresh.promise,
+ mutate: async () => ({ kind: 'saved', disposition: 'unchanged', snapshot: committed }),
+ });
+ await click(buttonByLabel(harness.doc, copy.refresh));
+ await click(buttonByLabel(harness.doc, copy.editAria('anthropic:claude')));
+ await act(async () => refresh.reject(new Error('read disconnected')));
+ await click(buttonByText(harness.doc, copy.save));
+
+ assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.loadFailedTitle));
+ assert.match(harness.container.textContent ?? '', /anthropic:claude/);
+ assertButtonEnabled(buttonByText(harness.doc, copy.add));
+ await act(async () => harness.root.unmount());
+ });
+
+ it('an Add conflict whose key now exists converts to Edit so the second save upserts (P1.3)', async () => {
+ const conflictLatest: DesktopPricingSnapshot = {
+ ...SNAPSHOT,
+ revision: 7,
+ entries: [
+ ...SNAPSHOT.entries,
+ {
+ source: 'custom',
+ resetEffect: 'become_unpriced',
+ pricing: { modelKey: 'acme:new', inputUsdPer1M: 9, outputUsdPer1M: 9 },
+ },
+ ],
+ };
+ let calls = 0;
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => {
+ calls += 1;
+ return calls === 1
+ ? { kind: 'review_required', reason: 'revision_conflict', snapshot: conflictLatest }
+ : { kind: 'saved', disposition: 'committed', snapshot: conflictLatest };
+ },
+ });
+ // Add a brand-new key via the manual fallback.
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'acme:new');
+ // Fill the two required rate NumberInputs (the placeholder-less inputs).
+ const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter(
+ (input) => !input.getAttribute('placeholder'),
+ );
+ await setInput(rateInputs[0], '1');
+ await setInput(rateInputs[1], '2');
+ // First save → conflict: the same key was added elsewhere.
+ await click(buttonByText(harness.doc, copy.save));
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitle));
+ // The Add converted to an Edit locked on the key, so the explicit second save
+ // upserts (not silently blocked by the duplicate check).
+ await click(buttonByText(harness.doc, copy.reviewSave));
+ assert.equal(calls, 2, 'the second save was allowed');
+ assert.equal(harness.mutations.length, 2);
+ assert.equal(harness.mutations[0]!.mutation.kind, 'upsert');
+ assert.deepEqual(harness.mutations[1]!.base, conflictLatest);
+ const second = harness.mutations[1]!.mutation as { kind: 'upsert'; pricing: { modelKey: string } };
+ assert.equal(second.pricing.modelKey, 'acme:new');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('adding a built-in key that is not yet overridden is a new override, not a duplicate', async () => {
+ const committed: DesktopPricingSnapshot = {
+ ...SNAPSHOT,
+ revision: 6,
+ entries: [
+ ...SNAPSHOT.entries,
+ {
+ source: 'custom',
+ resetEffect: 'restore_builtin',
+ pricing: { modelKey: 'openai:gpt-4o', inputUsdPer1M: 5, outputUsdPer1M: 15 },
+ },
+ ],
+ };
+ const harness = await renderEditor({
+ load: async () => SNAPSHOT,
+ mutate: async () => ({ kind: 'saved', disposition: 'committed', snapshot: committed }),
+ });
+ // Enter a key that exists as a BUILT-IN (openai:gpt-4o, catalog-only) but is
+ // not yet overridden. Overriding it is an upsert, not a duplicate — the
+ // catalog picker offers exactly these built-ins, so this must save (a full
+ // built-in ∪ overrides union check would wrongly block it).
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'openai:gpt-4o');
+ const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter(
+ (input) => !input.getAttribute('placeholder'),
+ );
+ await setInput(rateInputs[0], '5');
+ await setInput(rateInputs[1], '15');
+ await click(buttonByText(harness.doc, copy.save));
+
+ assert.doesNotMatch(harness.container.textContent ?? '', new RegExp(copy.errorDuplicate));
+ assert.equal(harness.mutations.length, 1);
+ const mutation = harness.mutations[0]!.mutation as { kind: 'upsert'; pricing: { modelKey: string } };
+ assert.equal(mutation.kind, 'upsert');
+ assert.equal(mutation.pricing.modelKey, 'openai:gpt-4o');
+ await act(async () => harness.root.unmount());
+ });
+
+ it('rejects a key that already has a custom override as a duplicate', async () => {
+ const harness = await renderEditor({ load: async () => SNAPSHOT });
+ await click(buttonByText(harness.doc, copy.add));
+ await click(buttonByText(harness.doc, copy.manualEntryToggle));
+ // 'anthropic:claude' already has a custom override row — re-adding it is a
+ // duplicate; the user is told to edit the existing row instead.
+ await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'anthropic:claude');
+ const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter(
+ (input) => !input.getAttribute('placeholder'),
+ );
+ await setInput(rateInputs[0], '1');
+ await setInput(rateInputs[1], '2');
+ await click(buttonByText(harness.doc, copy.save));
+
+ assert.match(harness.container.textContent ?? '', new RegExp(copy.errorDuplicate));
+ assert.equal(harness.mutations.length, 0);
+ await act(async () => harness.root.unmount());
+ });
+});
+
+describe('pricing display formatting', () => {
+ const copy = getPricingSettingsCopy('en');
+
+ it('round-trips positive rates without collapsing to $0 or losing precision', () => {
+ assert.equal(formatUsd(2.5), '$2.5');
+ assert.equal(formatUsd(10), '$10');
+ // A small positive rate keeps its digits — never rounded to `$0`.
+ assert.equal(formatUsd(0.075), '$0.075');
+ assert.equal(formatUsd(1.23456789), '$1.23456789');
+ // An explicit zero rate (e.g. a free local model) is a real `$0`.
+ assert.equal(formatUsd(0), '$0');
+ });
+
+ it('keeps an omitted cache rate distinct from an explicit zero', () => {
+ assert.equal(formatCache(undefined, copy), copy.cacheNotSet);
+ assert.equal(formatCache(0, copy), '$0');
+ assert.equal(formatCache(0.3, copy), '$0.3');
+ });
+});
+
+async function renderEditor(options: {
+ load: () => Promise;
+ mutate?: (
+ base: DesktopPricingSnapshot,
+ mutation: DesktopPricingMutationInput['mutation'],
+ ) => Promise;
+ // Omitted → the default selected Host; `null` → no Host selected.
+ host?: UsageHostRef | null;
+ strict?: boolean;
+}) {
+ const { document, window } = parseHTML('
');
+ const matchMedia = (media: string) => ({
+ matches: false,
+ media,
+ onchange: null,
+ addListener() {},
+ removeListener() {},
+ addEventListener() {},
+ removeEventListener() {},
+ dispatchEvent: () => false,
+ });
+ Object.assign(window, { matchMedia, scrollTo: () => {} });
+ Object.assign(globalThis, {
+ document,
+ window,
+ matchMedia,
+ HTMLElement: window.HTMLElement,
+ HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {},
+ getComputedStyle: (element: Element) => ({
+ color: (element as HTMLElement).style?.color || 'currentColor',
+ }) as CSSStyleDeclaration,
+ requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0),
+ cancelAnimationFrame: (handle: number) => clearTimeout(handle),
+ // Astryx Dialog probes `CSS.supports` during layout; linkedom has no CSS.
+ CSS: { supports: () => false, escape: (value: string) => value },
+ IS_REACT_ACT_ENVIRONMENT: true,
+ });
+
+ const runtimeHost = options.host === null ? undefined : options.host ?? TEST_RUNTIME_HOST;
+ let loadCalls = 0;
+ const loadHosts: UsageHostRef[] = [];
+ const mutateHosts: UsageHostRef[] = [];
+ const mutations: DesktopPricingMutationInput[] = [];
+ // Host resolution lives in the preload/adapter for the *selected* Host, threaded
+ // to the feature as a prop — so the feature-facing services take the Host as
+ // their first argument (they never resolve it themselves).
+ const services: UsagePricingServices = {
+ loadPricing: async (host) => {
+ loadHosts.push(host);
+ loadCalls += 1;
+ return options.load();
+ },
+ mutatePricing: async (host, base, mutation) => {
+ mutateHosts.push(host);
+ mutations.push({ base, mutation });
+ return (
+ options.mutate?.(base, mutation) ??
+ Promise.reject(new Error('mutate is not used by this test'))
+ );
+ },
+ };
+
+ const container = document.querySelector('#root');
+ assert.ok(container);
+ // linkedom's has no showModal/close; Astryx Dialog/AlertDialog call
+ // them on mount. Patch the element prototype so modal dialogs can render.
+ const dialogProto = Object.getPrototypeOf(document.createElement('dialog')) as {
+ showModal?: () => void;
+ close?: () => void;
+ };
+ dialogProto.showModal = function showModal(this: { open?: boolean }) {
+ this.open = true;
+ };
+ dialogProto.close = function close(this: { open?: boolean }) {
+ this.open = false;
+ };
+ const root = createRoot(container);
+ const defaultGenerationKey = runtimeHost
+ ? `${runtimeHost.profileId}:${runtimeHost.hostId}:e1`
+ : 'no-host';
+ let currentGenerationKey: string | null = defaultGenerationKey;
+ function renderTree(generationKey: string, showView = true): void {
+ currentGenerationKey = generationKey;
+ const editor = createElement(PricingEditor, {
+ describeError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
+ target: runtimeHost
+ ? {
+ host: runtimeHost,
+ generationKey,
+ isCurrent: () => currentGenerationKey === generationKey,
+ }
+ : null,
+ });
+ const scope = createElement(UsageFeatureScope, {
+ targetKey: generationKey,
+ services: {
+ loadUsageStats: async () => null,
+ updateUsageSettings: async () => createDefaultSettings().usage,
+ },
+ loadErrorTitle: 'Usage load failed',
+ describeError: String,
+ children: showView ? editor : null,
+ });
+ const provided = createElement(UsagePricingServicesProvider, { services, children: scope });
+ const toasted = createElement(ToastProvider, { children: provided });
+ const localized = createElement(AstryxLocaleProvider, { children: toasted });
+ const tree = createElement(LocaleProvider, { locale: 'en', children: localized });
+ root.render(options.strict ? createElement(StrictMode, null, tree) : tree);
+ }
+ await act(async () => {
+ renderTree(defaultGenerationKey);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ async function rerender(generationKey: string): Promise {
+ await act(async () => {
+ renderTree(generationKey);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ }
+ return {
+ doc: document as unknown as Document,
+ container,
+ root: root as Root,
+ rerender,
+ hideView: async () => {
+ await act(async () => renderTree(currentGenerationKey ?? defaultGenerationKey, false));
+ },
+ fenceTarget: () => {
+ currentGenerationKey = null;
+ },
+ loadCalls: () => loadCalls,
+ loadHosts,
+ mutateHosts,
+ mutations,
+ };
+}
+
+async function click(button: HTMLButtonElement | undefined) {
+ assert.ok(button, 'expected a clickable button');
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+async function submitEditor(doc: Document): Promise {
+ const form = openDialog(doc)?.querySelector('form');
+ assert.ok(form, 'expected the editor form');
+ await act(async () => {
+ const EventClass = doc.defaultView!.Event;
+ form.dispatchEvent(new EventClass('submit', { bubbles: true, cancelable: true }));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+async function clickWithoutSettling(button: HTMLButtonElement | undefined) {
+ assert.ok(button, 'expected a clickable button');
+ await act(async () => {
+ button.click();
+ await Promise.resolve();
+ });
+}
+
+async function clickElement(element: HTMLElement | undefined) {
+ assert.ok(element, 'expected a clickable element');
+ await act(async () => {
+ element.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+async function selectCatalogModel(doc: Document, modelKey: string): Promise {
+ await click(buttonByText(doc, copy.add));
+ await setInput(inputByPlaceholder(doc, copy.catalogPickerPlaceholder), modelKey);
+ const option = Array.from(doc.querySelectorAll('[role="option"]')).find(
+ (item) => item.textContent?.includes(modelKey),
+ );
+ await clickElement(option);
+}
+
+interface Deferred {
+ promise: Promise;
+ resolve(value: T): void;
+ reject(error: unknown): void;
+}
+function deferred(): Deferred {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+function inputByPlaceholder(doc: Document, placeholder: string): HTMLInputElement | undefined {
+ return doc.querySelector(`input[placeholder="${placeholder}"]`) ?? undefined;
+}
+
+function reactProps(input: HTMLInputElement): {
+ onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void;
+ onBlur?: (event: { target: HTMLInputElement }) => void;
+} {
+ const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$'));
+ assert.ok(propsKey, 'missing React props on the input');
+ return (input as unknown as Record)[propsKey] as ReturnType;
+}
+
+/** Set a controlled input's value and commit it. TextInput commits on change;
+ * NumberInput stages the text and only commits on blur — so fire both, with a
+ * render flush between so the blur handler sees the staged value. */
+async function setInput(input: HTMLInputElement | undefined, value: string): Promise {
+ assert.ok(input, 'expected an input to fill');
+ await act(async () => {
+ input.value = value;
+ reactProps(input).onChange?.({ target: input, defaultPrevented: false });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ reactProps(input).onBlur?.({ target: input });
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+function buttonByText(root: ParentNode, text: string): HTMLButtonElement | undefined {
+ return Array.from(root.querySelectorAll('button')).find(
+ (button) => (button.textContent ?? '').trim() === text,
+ );
+}
+
+function buttonByLabel(doc: Document, label: string): HTMLButtonElement | undefined {
+ return (
+ doc.querySelector(`button[aria-label="${label}"]`) ?? undefined
+ );
+}
+
+function openDialog(doc: Document): HTMLDialogElement | undefined {
+ return Array.from(doc.querySelectorAll('dialog')).find((dialog) => dialog.open);
+}
+
+function assertButtonDisabled(button: HTMLButtonElement | undefined): void {
+ assert.ok(button, 'expected button');
+ assert.ok(button.disabled || button.getAttribute('aria-disabled') === 'true');
+}
+
+function assertButtonEnabled(button: HTMLButtonElement | undefined): void {
+ assert.ok(button, 'expected button');
+ assert.equal(button.disabled, false);
+ assert.notEqual(button.getAttribute('aria-disabled'), 'true');
+}
diff --git a/apps/desktop/src/main/__tests__/pricing-view-model.test.ts b/apps/desktop/src/main/__tests__/pricing-view-model.test.ts
new file mode 100644
index 0000000000..35d508b04f
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/pricing-view-model.test.ts
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ draftFromPricing,
+ validatePricingDraft,
+ type PricingDraft,
+} from "../../renderer/features/usage/testing.js";
+
+const EMPTY: PricingDraft = {
+ modelKey: "",
+ input: null,
+ output: null,
+ cacheRead: null,
+ cacheWrite: null,
+};
+
+test("prefilled drafts round-trip precision, omitted cache and explicit zero", () => {
+ const pricing = {
+ modelKey: "acme:coder-v2",
+ inputUsdPer1M: 0.000000123456789,
+ outputUsdPer1M: 2.123456789012345,
+ cacheReadUsdPer1M: 0,
+ };
+ const { draft, cacheOpen } = draftFromPricing(pricing);
+ assert.equal(cacheOpen, true);
+ assert.equal(draft.cacheRead, 0);
+ assert.equal(draft.cacheWrite, null);
+ assert.deepEqual(validatePricingDraft(draft, { mode: "add", existingKeys: [] }).config, pricing);
+ assert.equal(draftFromPricing({ ...pricing, cacheReadUsdPer1M: undefined }).cacheOpen, false);
+});
+
+test("validatePricingDraft add flags an empty model key", () => {
+ const result = validatePricingDraft(EMPTY, { mode: "add", existingKeys: [] });
+ assert.equal(result.errors.modelKey, "required");
+ assert.equal(result.errors.input, "required");
+ assert.equal(result.errors.output, "required");
+ assert.equal(result.hasErrors, true);
+ assert.equal(result.config, null);
+});
+
+test("validatePricingDraft add flags a duplicate key against existing rows", () => {
+ const draft: PricingDraft = { ...EMPTY, modelKey: "openai:gpt-4o", input: 1, output: 2 };
+ const result = validatePricingDraft(draft, {
+ mode: "add",
+ existingKeys: ["openai:gpt-4o"],
+ });
+ assert.equal(result.errors.modelKey, "duplicate");
+ assert.equal(result.config, null);
+});
+
+test("validatePricingDraft add builds a canonical config; blank cache is omitted", () => {
+ const draft: PricingDraft = {
+ modelKey: " DeepInfra:org/Model:Preview ",
+ input: 0.8,
+ output: 2.4,
+ cacheRead: null,
+ cacheWrite: null,
+ };
+ const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] });
+ assert.equal(result.hasErrors, false);
+ assert.deepEqual(result.config, {
+ modelKey: "DeepInfra:org/Model:Preview",
+ inputUsdPer1M: 0.8,
+ outputUsdPer1M: 2.4,
+ });
+ assert.equal(Object.hasOwn(result.config!, "cacheReadUsdPer1M"), false);
+});
+
+test("validatePricingDraft keeps an explicit 0 cache rate distinct from blank", () => {
+ const draft: PricingDraft = {
+ modelKey: "acme:coder-v2",
+ input: 1,
+ output: 2,
+ cacheRead: 0,
+ cacheWrite: null,
+ };
+ const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] });
+ assert.equal(result.config?.cacheReadUsdPer1M, 0);
+ assert.equal(Object.hasOwn(result.config!, "cacheWriteUsdPer1M"), false);
+});
+
+test("validatePricingDraft rejects a negative rate", () => {
+ const draft: PricingDraft = { ...EMPTY, modelKey: "a:b", input: -1, output: 2 };
+ const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] });
+ assert.equal(result.errors.input, "invalid_rate");
+ assert.equal(result.config, null);
+});
+
+test("validatePricingDraft edit locks the key and ignores the draft key", () => {
+ const draft: PricingDraft = {
+ modelKey: "ignored",
+ input: 3,
+ output: 4,
+ cacheRead: null,
+ cacheWrite: null,
+ };
+ const result = validatePricingDraft(draft, {
+ mode: "edit",
+ existingKeys: ["openai:gpt-4o"],
+ lockedModelKey: "openai:gpt-4o",
+ });
+ assert.equal(result.hasErrors, false);
+ assert.equal(result.config?.modelKey, "openai:gpt-4o");
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts
index 040f2ffb06..1fa784bb15 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-client-pricing.test.ts
@@ -32,8 +32,8 @@ import type {
import {
DesktopRuntimeHostClient,
DesktopRuntimeHostClientError,
- type DesktopPricingSnapshot,
} from '../runtime-host-client.js';
+import type { DesktopPricingSnapshot } from '../../shared/desktop-pricing.js';
test('restarts a paginated Pricing read instead of mixing revisions', async () => {
const stale = builtin('provider:stale', 1);
diff --git a/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts
new file mode 100644
index 0000000000..1145c13c66
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts
@@ -0,0 +1,193 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import type { Result } from "@maka/core/result";
+import type {
+ DesktopPricingMutationInput,
+ DesktopPricingMutationOutcome,
+ DesktopPricingSnapshot,
+} from "../../shared/desktop-pricing.js";
+import type { IpcHandler } from "../ipc-reconnect-policy.js";
+import type { DesktopRuntimeHostClient } from "../runtime-host-client.js";
+import { registerRuntimeHostPricingIpc } from "../runtime-host-pricing-ipc-main.js";
+import { registerRuntimeHostUsageIpc } from "../runtime-host-usage-ipc-main.js";
+
+function recordingIpc() {
+ const handlers = new Map();
+ return {
+ handlers,
+ ipcMain: {
+ handle: (channel: string, listener: IpcHandler) => handlers.set(channel, listener),
+ handleReconnectableRead: (channel: string, listener: IpcHandler) =>
+ handlers.set(channel, listener),
+ },
+ };
+}
+
+const SNAPSHOT: DesktopPricingSnapshot = {
+ hostEpoch: "epoch-1",
+ connectionId: "conn-1",
+ revision: 7,
+ entries: [
+ { source: "builtin", pricing: { modelKey: "openai:gpt-4o", inputUsdPer1M: 2.5, outputUsdPer1M: 10 } },
+ ],
+};
+
+test("pricing IPC registers the two capabilities and fences the legacy handlers", () => {
+ const { handlers, ipcMain } = recordingIpc();
+ registerRuntimeHostUsageIpc({
+ ipcMain,
+ client: {} as unknown as DesktopRuntimeHostClient,
+ });
+ registerRuntimeHostPricingIpc({ ipcMain, client: {} as unknown as DesktopRuntimeHostClient });
+
+ assert.ok(handlers.has("usage:pricing:load"));
+ assert.ok(handlers.has("usage:pricing:mutate"));
+ // Acceptance #12: the retired direct-Store routes must not coexist.
+ assert.equal(handlers.has("usage:pricing:list"), false);
+ assert.equal(handlers.has("usage:pricing:put"), false);
+ assert.equal(handlers.has("usage:pricing:reset"), false);
+});
+
+test("pricing load returns the full snapshot as a Result", async () => {
+ const { handlers, ipcMain } = recordingIpc();
+ registerRuntimeHostPricingIpc({
+ ipcMain,
+ client: { loadPricingSnapshot: async () => SNAPSHOT } as unknown as DesktopRuntimeHostClient,
+ });
+ const handler = handlers.get("usage:pricing:load");
+ assert.ok(handler);
+ const result = (await handler({} as never)) as Result;
+ assert.equal(result.ok, true);
+ assert.ok(result.ok && result.data.revision === 7);
+ assert.ok(result.ok && result.data.entries.length === 1);
+});
+
+test("pricing mutate passes the renderer-supplied base straight through (no re-read)", async () => {
+ let received: DesktopPricingMutationInput | undefined;
+ let loadCalls = 0;
+ const outcome: DesktopPricingMutationOutcome = {
+ kind: "saved",
+ disposition: "committed",
+ snapshot: { ...SNAPSHOT, revision: 8 },
+ };
+ const { handlers, ipcMain } = recordingIpc();
+ registerRuntimeHostPricingIpc({
+ ipcMain,
+ client: {
+ loadPricingSnapshot: async () => {
+ loadCalls += 1;
+ return SNAPSHOT;
+ },
+ applyPricingMutation: async (input: DesktopPricingMutationInput) => {
+ received = input;
+ return outcome;
+ },
+ } as unknown as DesktopRuntimeHostClient,
+ });
+ const handler = handlers.get("usage:pricing:mutate");
+ assert.ok(handler);
+
+ const result = (await handler({} as never, SNAPSHOT, {
+ kind: "upsert",
+ pricing: { modelKey: "acme:coder", inputUsdPer1M: 1, outputUsdPer1M: 2 },
+ })) as Result;
+
+ assert.equal(result.ok, true);
+ assert.ok(result.ok && result.data.kind === "saved");
+ // The base carries the revision the renderer was viewing — the handler must
+ // NOT reload the latest snapshot to synthesize a base (the retired-path bug).
+ assert.equal(received?.base.revision, 7);
+ assert.deepEqual(received?.base, SNAPSHOT);
+ assert.deepEqual(received?.mutation, {
+ kind: "upsert",
+ pricing: { modelKey: "acme:coder", inputUsdPer1M: 1, outputUsdPer1M: 2 },
+ });
+ assert.equal(loadCalls, 0);
+});
+
+test("pricing mutate rejects a malformed base as a failed Result", async () => {
+ let applyCalls = 0;
+ const { handlers, ipcMain } = recordingIpc();
+ registerRuntimeHostPricingIpc({
+ ipcMain,
+ client: {
+ applyPricingMutation: async () => {
+ applyCalls += 1;
+ return { kind: "saved_refresh_failed", disposition: "committed" } as const;
+ },
+ } as unknown as DesktopRuntimeHostClient,
+ });
+ const handler = handlers.get("usage:pricing:mutate");
+ assert.ok(handler);
+
+ const result = (await handler({} as never, { revision: "nope" }, {
+ kind: "delete",
+ modelKey: "acme:coder",
+ })) as Result;
+
+ assert.equal(result.ok, false);
+ assert.equal(applyCalls, 0);
+});
+
+test("pricing mutate reconciles (no replay) when the dispatch outcome is unknown", async () => {
+ let reconciled: DesktopPricingMutationInput | undefined;
+ let reconciledReason: string | undefined;
+ const { handlers, ipcMain } = recordingIpc();
+ registerRuntimeHostPricingIpc({
+ ipcMain,
+ client: {
+ // The initial dispatch could not confirm its outcome on its own
+ // (likely-lost) connection — and it was a confirmed revision conflict.
+ applyPricingMutation: async () =>
+ ({ kind: "reconciliation_unavailable", reason: "revision_conflict" }) as const,
+ // The reconciled-control path reloads fresh authority and compares intent
+ // WITHOUT re-dispatching the mutation, preserving the original reason.
+ reconcilePricingMutation: async (
+ input: DesktopPricingMutationInput,
+ reason: "revision_conflict" | "outcome_unknown",
+ ) => {
+ reconciled = input;
+ reconciledReason = reason;
+ return {
+ kind: "review_required",
+ reason,
+ snapshot: { ...SNAPSHOT, revision: 8 },
+ } as const;
+ },
+ } as unknown as DesktopRuntimeHostClient,
+ });
+ const handler = handlers.get("usage:pricing:mutate");
+ assert.ok(handler);
+
+ const result = (await handler({} as never, SNAPSHOT, {
+ kind: "delete",
+ modelKey: "acme:coder",
+ })) as Result;
+
+ // The synchronous fallback runs dispatch → reconcile; the reconcile carries
+ // the renderer's base and the original reason (not a blanket "unknown").
+ assert.equal(result.ok, true);
+ assert.ok(result.ok && result.data.kind === "review_required");
+ assert.equal(reconciledReason, "revision_conflict");
+ assert.deepEqual(reconciled?.base, SNAPSHOT);
+ assert.deepEqual(reconciled?.mutation, { kind: "delete", modelKey: "acme:coder" });
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts
index 3dcd30173d..f2b76b6068 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-usage-ipc-main.test.ts
@@ -87,24 +87,7 @@ test("settings usage stats use the canonical model-call total and load every act
nextOffset: offset === 0 ? 100 : null,
} satisfies UsageQueryResult;
},
- loadPricingSnapshot: async () => ({
- hostEpoch: "host-epoch",
- connectionId: "connection-id",
- revision: 1,
- entries: [
- {
- source: "custom",
- resetEffect: "become_unpriced",
- pricing: {
- modelKey: "provider-a:model-a",
- inputUsdPer1M: 1,
- outputUsdPer1M: 2,
- },
- },
- ],
- }),
} as unknown as DesktopRuntimeHostClient,
- sendToRenderer: () => undefined,
});
const handler = handlers.get("settings:usageStats");
@@ -135,14 +118,6 @@ test("settings usage stats use the canonical model-call total and load every act
assert.deepEqual(stats.byTool, [
{ tool: "Read", calls: 171, success: 170, errors: 0, avgDurationMs: 25 },
]);
- assert.deepEqual(stats.pricing, [
- {
- provider: "provider-a",
- model: "model-a",
- inputPerMTokUsd: 1,
- outputPerMTokUsd: 2,
- },
- ]);
// The canonical summary provenance is carried through so the page can qualify
// a cost that reads low; the full range fit under the cap, so not truncated.
assert.deepEqual(stats.provenance, provenance());
@@ -209,7 +184,6 @@ test("settings usage stats reject a non-advancing activity page", async () => {
entries: [],
}),
} as unknown as DesktopRuntimeHostClient,
- sendToRenderer: () => undefined,
});
const handler = handlers.get("settings:usageStats");
@@ -277,7 +251,6 @@ test("settings usage stats degrade instead of erroring when logs disagree with t
entries: [],
}),
} as unknown as DesktopRuntimeHostClient,
- sendToRenderer: () => undefined,
});
const handler = handlers.get("settings:usageStats");
@@ -355,7 +328,6 @@ test("settings usage stats group the provider breakdown by connection", async ()
entries: [],
}),
} as unknown as DesktopRuntimeHostClient,
- sendToRenderer: () => undefined,
});
const handler = handlers.get("settings:usageStats");
@@ -434,7 +406,6 @@ test("settings usage stats truncate the activity log at the cap instead of error
entries: [],
}),
} as unknown as DesktopRuntimeHostClient,
- sendToRenderer: () => undefined,
});
const handler = handlers.get("settings:usageStats");
@@ -519,7 +490,6 @@ test("settings usage stats name each row from the Host-resolved session title",
entries: [],
}),
} as unknown as DesktopRuntimeHostClient,
- sendToRenderer: () => undefined,
});
const handler = handlers.get("settings:usageStats");
diff --git a/apps/desktop/src/main/__tests__/usage-settings-view.test.ts b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts
index 15323e86d3..cb4ea825b6 100644
--- a/apps/desktop/src/main/__tests__/usage-settings-view.test.ts
+++ b/apps/desktop/src/main/__tests__/usage-settings-view.test.ts
@@ -57,7 +57,6 @@ function statsWithRequests(totalRequests: number): UsageStats {
byProvider: [],
byModel: [],
byTool: [],
- pricing: [],
provenance: EMPTY_USAGE_PROVENANCE,
};
}
@@ -149,6 +148,7 @@ function tree(opts: {
? createElement(UsageSettingsView, {
settings: opts.settings.usage,
describeError: (error: unknown) => String(error),
+ runtimeHost: undefined,
})
: null,
}),
@@ -446,6 +446,7 @@ describe('Usage feature scope', () => {
: createElement(UsageSettingsView, {
settings: base.usage,
describeError: (error: unknown) => String(error),
+ runtimeHost: undefined,
}),
}),
}),
@@ -513,6 +514,7 @@ describe('Usage feature scope', () => {
children: createElement(UsageSettingsView, {
settings: base.usage,
describeError: (error: unknown) => String(error),
+ runtimeHost: undefined,
}),
}),
}),
diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts
index a82669b29a..76b00f481d 100644
--- a/apps/desktop/src/main/runtime-host-boot.ts
+++ b/apps/desktop/src/main/runtime-host-boot.ts
@@ -251,6 +251,7 @@ import {
} from "./runtime-host-settings-ipc-main.js";
import { registerRuntimeHostSkillsIpc } from "./runtime-host-skills-ipc-main.js";
import { registerRuntimeHostUsageIpc } from "./runtime-host-usage-ipc-main.js";
+import { registerRuntimeHostPricingIpc } from "./runtime-host-pricing-ipc-main.js";
import { registerRuntimeHostWorkspaceIpc } from "./runtime-host-workspace-ipc-main.js";
import { resolveShellEnv } from "./shell-env.js";
import {
@@ -1729,8 +1730,8 @@ function registerHostClientIpc(
registerRuntimeHostUsageIpc({
ipcMain: scopedIpc,
client,
- sendToRenderer,
});
+ registerRuntimeHostPricingIpc({ ipcMain: scopedIpc, client });
registerRuntimeHostWorkspaceIpc({
ipcMain: scopedIpc,
client,
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index 7fcf4a57e8..759c535a88 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -38,11 +38,7 @@ import type {
RuntimePolicy,
RuntimePolicyMutation,
} from "@maka/core/runtime-policy";
-import {
- canonicalPricingConfigsEqual,
- comparePricingModelKeys,
-} from "@maka/core/usage-stats/pricing";
-import type { PricingConfig } from "@maka/core/usage-stats/types";
+import { comparePricingModelKeys } from "@maka/core/usage-stats/pricing";
import {
type ClientCapabilityProvider,
type DecodedSessionTranscriptPage,
@@ -67,6 +63,7 @@ import {
} from "@maka/runtime-host/client";
import {
ARTIFACT_INGEST_CHUNK_MAX_BYTES,
+ createPricingReconciliationTarget,
decodePricingMutateInput,
type ArtifactBinaryPreview,
type ArtifactProjection,
@@ -90,8 +87,9 @@ import {
type OperationOutput,
type PlanProjectionItem,
type PlanQueryResult,
- type PricingMutation,
type PricingQueryResult,
+ pricingReconciliationTargetMatches,
+ type PricingReconciliationTarget,
type ProjectCatalogMutateInput,
type ProjectCatalogMutateResult,
type ProjectCatalogProject,
@@ -154,6 +152,11 @@ import {
type TurnMessageSubmitResult,
type WorkspaceProjection,
} from "@maka/runtime-host/protocol";
+import type {
+ DesktopPricingMutationInput,
+ DesktopPricingMutationOutcome,
+ DesktopPricingSnapshot,
+} from "../shared/desktop-pricing.js";
const decodeStoredMessage = (value: unknown): StoredMessage =>
decodePersistedStoredMessage(markPersisted(value));
@@ -227,13 +230,6 @@ export interface DesktopRuntimeHostSession {
close(): Promise;
}
-export interface DesktopPricingSnapshot {
- readonly hostEpoch: string;
- readonly connectionId: string;
- readonly revision: number;
- readonly entries: readonly EffectivePricingEntry[];
-}
-
export interface DesktopSkillCatalogSnapshot {
readonly revision: SkillCatalogRevision;
readonly view: SkillCatalogView;
@@ -241,39 +237,6 @@ export interface DesktopSkillCatalogSnapshot {
readonly workspace: WorkspaceProjection;
}
-export interface DesktopPricingMutationInput {
- readonly base: DesktopPricingSnapshot;
- readonly mutation: PricingMutation;
-}
-
-export type DesktopPricingMutationOutcome =
- | {
- readonly kind: "saved";
- readonly disposition: "committed" | "unchanged";
- readonly snapshot: DesktopPricingSnapshot;
- }
- | {
- readonly kind: "saved_refresh_failed";
- readonly disposition: "committed" | "unchanged";
- }
- | {
- readonly kind: "synchronized" | "review_required";
- readonly reason: "revision_conflict" | "outcome_unknown";
- readonly snapshot: DesktopPricingSnapshot;
- }
- | {
- readonly kind: "reconciliation_unavailable";
- readonly reason: "revision_conflict" | "outcome_unknown";
- };
-
-type PricingReconciliationTarget =
- | { readonly kind: "upsert"; readonly pricing: Readonly }
- | {
- readonly kind: "delete";
- readonly modelKey: string;
- readonly expected: "builtin" | "unpriced" | "no_override";
- };
-
export class DesktopRuntimeHostClient {
readonly #sessions = new Set();
#closeTask: Promise | undefined;
@@ -657,7 +620,7 @@ export class DesktopRuntimeHostClient {
mutation: input.mutation,
});
const reconciliationTarget = createPricingReconciliationTarget(
- input.base,
+ input.base.entries,
request.mutation,
);
let result: OperationOutput<"pricing.mutate">;
@@ -694,6 +657,27 @@ export class DesktopRuntimeHostClient {
}
}
+ /**
+ * Reconcile a pricing write whose outcome the dispatching connection could not
+ * settle, run against a replacement Host by the Desktop reconciled-control IPC
+ * after a response-losing disconnect. It reloads fresh authority and compares
+ * the intended end state; it must never replay the write, and it deliberately
+ * skips the connection stale-guard because `base` legitimately belongs to the
+ * previous connection.
+ */
+ async reconcilePricingMutation(
+ input: DesktopPricingMutationInput,
+ reason: "revision_conflict" | "outcome_unknown",
+ ): Promise {
+ this.#assertOpen();
+ const request = decodePricingMutateInput({
+ expectedRevision: input.base.revision,
+ mutation: input.mutation,
+ });
+ const target = createPricingReconciliationTarget(input.base.entries, request.mutation);
+ return this.#reconcilePricingMutation(target, reason);
+ }
+
async listSessions(): Promise {
this.#assertOpen();
try {
@@ -1739,7 +1723,7 @@ export class DesktopRuntimeHostClient {
try {
const snapshot = await this.loadPricingSnapshot();
return {
- kind: pricingTargetMatchesSnapshot(target, snapshot)
+ kind: pricingReconciliationTargetMatches(target, snapshot.entries)
? "synchronized"
: "review_required",
reason,
@@ -1993,51 +1977,6 @@ function unstableProjection(
);
}
-function createPricingReconciliationTarget(
- base: DesktopPricingSnapshot,
- mutation: PricingMutation,
-): PricingReconciliationTarget {
- if (mutation.kind === "upsert")
- return { kind: "upsert", pricing: mutation.pricing };
- const baseEntry = base.entries.find(
- ({ pricing }) => pricing.modelKey === mutation.modelKey,
- );
- const expected =
- baseEntry?.source === "custom"
- ? baseEntry.resetEffect === "restore_builtin"
- ? "builtin"
- : "unpriced"
- : "no_override";
- return { kind: "delete", modelKey: mutation.modelKey, expected };
-}
-
-function pricingTargetMatchesSnapshot(
- target: PricingReconciliationTarget,
- snapshot: DesktopPricingSnapshot,
-): boolean {
- const current = snapshot.entries.find(
- ({ pricing }) => pricing.modelKey === pricingTargetModelKey(target),
- );
- if (target.kind === "upsert") {
- return (
- current?.source === "custom" &&
- canonicalPricingConfigsEqual(current.pricing, target.pricing)
- );
- }
- switch (target.expected) {
- case "builtin":
- return current?.source === "builtin";
- case "unpriced":
- return current === undefined;
- case "no_override":
- return current === undefined || current.source === "builtin";
- }
-}
-
-function pricingTargetModelKey(target: PricingReconciliationTarget): string {
- return target.kind === "upsert" ? target.pricing.modelKey : target.modelKey;
-}
-
function pricingEntriesAreCanonical(
entries: readonly EffectivePricingEntry[],
): boolean {
diff --git a/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts b/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts
new file mode 100644
index 0000000000..9d52d449e2
--- /dev/null
+++ b/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Pricing Settings IPC — the renderer's two capabilities from #2015:
+ *
+ * - `usage:pricing:load` → one complete effective snapshot (built-in ∪
+ * overrides), revision- and connection-stamped.
+ * - `usage:pricing:mutate` → apply one upsert/delete against the revision the
+ * renderer was viewing.
+ *
+ * The renderer round-trips the exact snapshot it loaded back as the CAS `base`;
+ * this handler passes it straight to the adapter and never re-reads the latest
+ * snapshot to synthesize a base (the bug in the retired `usage:pricing:put`
+ * path, which defeated conflict detection). CAS + reconciliation live entirely
+ * in `DesktopRuntimeHostClient.applyPricingMutation`.
+ */
+
+import type { Result } from "@maka/core/result";
+import {
+ normalizePricingConfig,
+ normalizePricingModelKey,
+} from "@maka/core/usage-stats/pricing";
+import type { PricingMutation } from "@maka/runtime-host/protocol";
+import { decodeDesktopPricingSnapshot } from "../shared/desktop-pricing-decode.js";
+import type {
+ DesktopPricingMutationInput,
+ DesktopPricingMutationOutcome,
+} from "../shared/desktop-pricing.js";
+import {
+ handleReconciledControl,
+ handleReconnectableRead,
+ rethrowReconnectableReadFailure,
+ type ReconnectableReadIpcMain,
+ tryReconnectableReadResult,
+} from "./ipc-reconnect-policy.js";
+import type { DesktopRuntimeHostClient } from "./runtime-host-client.js";
+
+interface RuntimeHostPricingIpcDeps {
+ readonly ipcMain: ReconnectableReadIpcMain;
+ readonly client: DesktopRuntimeHostClient;
+}
+
+type PricingMutateResult = Result;
+type PricingReconcileReason = "revision_conflict" | "outcome_unknown";
+interface PricingReconcileContext {
+ readonly input: DesktopPricingMutationInput;
+ readonly reason: PricingReconcileReason;
+}
+
+export function registerRuntimeHostPricingIpc(
+ deps: RuntimeHostPricingIpcDeps,
+): void {
+ handleReconnectableRead(deps.ipcMain, "usage:pricing:load", () =>
+ tryReconnectableReadResult(
+ () => deps.client.loadPricingSnapshot(),
+ "USAGE_PRICING_LOAD_FAILED",
+ ),
+ );
+ // Reconciled control (like `goal:arm`): when the write's outcome is unknown
+ // (a response-losing disconnect), defer to the harness to wait for a
+ // replacement Host and reconcile against it — reload fresh authority and
+ // compare the intended end state, never replaying the mutation. The original
+ // conflict reason rides along so a confirmed revision conflict is not later
+ // reported as merely uncertain.
+ handleReconciledControl(
+ deps.ipcMain,
+ "usage:pricing:mutate",
+ {
+ dispatch: async (_event, base: unknown, mutation: unknown) => {
+ let input: DesktopPricingMutationInput;
+ try {
+ input = {
+ base: decodeDesktopPricingSnapshot(base),
+ mutation: decodePricingMutation(mutation),
+ };
+ } catch (error) {
+ return { kind: "completed", value: mutateFailure(error) };
+ }
+ try {
+ const outcome = await deps.client.applyPricingMutation(input);
+ // The adapter could not reload on its own (likely-lost) connection;
+ // wait for a replacement Host and reconcile there instead of
+ // returning "unavailable" immediately.
+ if (outcome.kind === "reconciliation_unavailable") {
+ return { kind: "reconcile", context: { input, reason: outcome.reason } };
+ }
+ return { kind: "completed", value: { ok: true, data: outcome } };
+ } catch (error) {
+ return { kind: "completed", value: mutateFailure(error) };
+ }
+ },
+ reconcile: async (context) => {
+ try {
+ return {
+ ok: true,
+ data: await deps.client.reconcilePricingMutation(context.input, context.reason),
+ };
+ } catch (error) {
+ rethrowReconnectableReadFailure(error);
+ return mutateFailure(error);
+ }
+ },
+ reconciliationUnavailable: async (context) => ({
+ ok: true,
+ data: { kind: "reconciliation_unavailable", reason: context.reason },
+ }),
+ },
+ );
+}
+
+function mutateFailure(error: unknown): PricingMutateResult {
+ return {
+ ok: false,
+ error: {
+ code: "USAGE_PRICING_MUTATE_FAILED",
+ message: error instanceof Error ? error.message : String(error),
+ details: error,
+ },
+ };
+}
+
+/**
+ * Shape-guard the renderer-supplied mutation for an early, user-facing error.
+ * The Host is still the authoritative validator — the adapter re-decodes this
+ * before dispatch — but rejecting a malformed payload here beats throwing deep
+ * inside the adapter.
+ */
+function decodePricingMutation(value: unknown): PricingMutation {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new Error("Pricing mutation must be an object");
+ }
+ const record = value as Record;
+ if (record.kind === "upsert") {
+ const normalized = normalizePricingConfig(record.pricing);
+ if (!normalized.ok) throw new Error(normalized.error);
+ return { kind: "upsert", pricing: normalized.value };
+ }
+ if (record.kind === "delete") {
+ const normalized = normalizePricingModelKey(record.modelKey);
+ if (!normalized.ok) throw new Error(normalized.error);
+ return { kind: "delete", modelKey: normalized.value };
+ }
+ throw new Error('Pricing mutation kind must be "upsert" or "delete"');
+}
diff --git a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts
index d3b18b6cf3..5b89676c94 100644
--- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts
@@ -18,14 +18,8 @@
*/
import { resolveUsageRange } from "@maka/core/model-call-usage-projection";
-import { tryResult } from "@maka/core/result";
import type { UsageRange, UsageStats } from "@maka/core/settings";
-import {
- normalizePricingConfig,
- normalizePricingModelKey,
-} from "@maka/core/usage-stats/pricing";
import type {
- PricingConfig,
TimeRange,
UsageGroupBy,
UsageQuery,
@@ -45,7 +39,6 @@ import type { DesktopRuntimeHostClient } from "./runtime-host-client.js";
interface RuntimeHostUsageIpcDeps {
readonly ipcMain: ReconnectableReadIpcMain;
readonly client: DesktopRuntimeHostClient;
- readonly sendToRenderer: (channel: string, ...args: unknown[]) => void;
}
const MAX_ACTIVITY_RECORDS = 50_000;
@@ -53,16 +46,6 @@ const MAX_ACTIVITY_RECORDS = 50_000;
export function registerRuntimeHostUsageIpc(
deps: RuntimeHostUsageIpcDeps,
): void {
- let pricingMutationQueue: Promise = Promise.resolve();
- const enqueuePricingMutation = (operation: () => Promise): Promise => {
- const result = pricingMutationQueue.then(operation);
- pricingMutationQueue = result.then(
- () => undefined,
- () => undefined,
- );
- return result;
- };
-
handleReconnectableRead(
deps.ipcMain,
"settings:usageStats",
@@ -115,45 +98,6 @@ export function registerRuntimeHostUsageIpc(
};
}, "USAGE_LOGS_FAILED"),
);
- handleReconnectableRead(deps.ipcMain, "usage:pricing:list", () =>
- tryReconnectableReadResult(async () => {
- const snapshot = await deps.client.loadPricingSnapshot();
- return snapshot.entries
- .filter((entry) => entry.source === "custom")
- .map((entry) => entry.pricing);
- }, "USAGE_PRICING_LIST_FAILED"),
- );
- deps.ipcMain.handle("usage:pricing:put", (_event, pricing: unknown) =>
- tryResult(
- () =>
- enqueuePricingMutation(async () => {
- const normalized = normalizePricingConfig(pricing);
- if (!normalized.ok) throw new Error(normalized.error);
- await applyPricingMutation(deps.client, {
- kind: "upsert",
- pricing: normalized.value,
- });
- deps.sendToRenderer("usage:pricing:changed");
- return normalized.value;
- }),
- "USAGE_PRICING_PUT_FAILED",
- ),
- );
- deps.ipcMain.handle("usage:pricing:reset", (_event, modelKey: unknown) =>
- tryResult(
- () =>
- enqueuePricingMutation(async () => {
- const normalized = normalizePricingModelKey(modelKey);
- if (!normalized.ok) throw new Error(normalized.error);
- await applyPricingMutation(deps.client, {
- kind: "delete",
- modelKey: normalized.value,
- });
- deps.sendToRenderer("usage:pricing:changed");
- }),
- "USAGE_PRICING_RESET_FAILED",
- ),
- );
}
async function loadUsageStats(
@@ -161,11 +105,10 @@ async function loadUsageStats(
range: UsageRange,
): Promise {
const query = { range: resolveUsageRange(range, Date.now()) } satisfies UsageQuery;
- const [summaryResult, llmResult, toolResult, pricing] = await Promise.all([
+ const [summaryResult, llmResult, toolResult] = await Promise.all([
client.queryUsage({ kind: "summary", query }),
loadAllLogs(client, "llm", query),
loadAllLogs(client, "tool", query),
- client.loadPricingSnapshot(),
]);
if (summaryResult.kind !== "summary") throw invalidUsageProjection();
const llmLogs = llmResult.rows;
@@ -200,13 +143,6 @@ async function loadUsageStats(
byProvider: aggregateModelLogs(llmLogs, "provider"),
byModel: aggregateModelLogs(llmLogs, "model"),
byTool: aggregateToolLogs(toolLogs),
- pricing: pricing.entries
- .filter((entry) => entry.source === "custom")
- .map(({ pricing: entry }) => projectPricing(entry))
- .sort(
- (left, right) =>
- left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model),
- ),
provenance: summaryResult.provenance,
...(logsTruncated ? { logsTruncated: true } : {}),
};
@@ -380,16 +316,6 @@ function aggregateToolLogs(logs: readonly ToolUsageLogProjection[]): UsageStats[
.sort((left, right) => right.calls - left.calls || left.tool.localeCompare(right.tool));
}
-function projectPricing(pricing: PricingConfig): UsageStats["pricing"][number] {
- const separator = pricing.modelKey.indexOf(":");
- return {
- provider: separator < 0 ? "" : pricing.modelKey.slice(0, separator),
- model: separator < 0 ? pricing.modelKey : pricing.modelKey.slice(separator + 1),
- inputPerMTokUsd: pricing.inputUsdPer1M,
- outputPerMTokUsd: pricing.outputUsdPer1M,
- };
-}
-
async function loadAllBuckets(
client: DesktopRuntimeHostClient,
query: UsageQuery & { groupBy: UsageGroupBy },
@@ -442,26 +368,6 @@ function toToolQuery(query: UsageQuery) {
};
}
-async function applyPricingMutation(
- client: DesktopRuntimeHostClient,
- mutation:
- | { readonly kind: "upsert"; readonly pricing: PricingConfig }
- | { readonly kind: "delete"; readonly modelKey: string },
-): Promise {
- const outcome = await client.applyPricingMutation({
- base: await client.loadPricingSnapshot(),
- mutation,
- });
- if (
- outcome.kind === "saved" ||
- outcome.kind === "saved_refresh_failed" ||
- outcome.kind === "synchronized"
- ) {
- return;
- }
- throw new Error("Pricing changed concurrently; reload it before retrying");
-}
-
function invalidUsageProjection(): Error {
return new Error("Runtime Host returned an invalid Usage projection");
}
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index e389b888b4..b755182ee1 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -144,6 +144,11 @@ import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol';
import type { TestProxyInput } from '@maka/core/settings/network-settings';
import type { ExternalSessionImportIpcResult } from './external-session-import-result.js';
import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js';
+import type {
+ DesktopPricingMutationOutcome,
+ DesktopPricingSnapshot,
+} from '../shared/desktop-pricing.js';
+import type { PricingMutation } from '@maka/runtime-host/protocol';
import type {
SessionCollaborationCancelResult,
SessionCollaborationImportPhase,
@@ -1410,6 +1415,14 @@ export interface MakaBridge {
testNetworkProxy(input?: TestProxyInput, host?: DesktopRuntimeHostRef): Promise;
testBotChannel(provider: BotProvider): Promise;
usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise;
+ pricing: {
+ load(host: DesktopRuntimeHostRef): Promise;
+ mutate(
+ base: DesktopPricingSnapshot,
+ mutation: PricingMutation,
+ host: DesktopRuntimeHostRef,
+ ): Promise;
+ };
bots: {
listStatuses(): Promise>;
restart(provider: BotProvider): Promise;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 57d097c7e1..70ff50ffb4 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -215,6 +215,11 @@ import {
type TestProxyInput,
} from '@maka/core/settings/network-settings';
import type { Result } from '@maka/core/result';
+import type {
+ DesktopPricingMutationOutcome,
+ DesktopPricingSnapshot,
+} from '../shared/desktop-pricing.js';
+import type { PricingMutation } from '@maka/runtime-host/protocol';
import type { CreateSessionRequestInput } from '@maka/core/runtime-inputs';
import type {
McpConfigAddResult,
@@ -3309,6 +3314,34 @@ const makaBridge = {
const stats = await ipcRenderer.invoke('settings:usageStats', scope, range) as UsageStats;
return projectDesktopUsageStats(scope, stats);
},
+ pricing: {
+ // Load one complete effective snapshot (built-in ∪ overrides), stamped to
+ // its Host connection/revision; the renderer round-trips it as the CAS base.
+ async load(host: DesktopRuntimeHostRef): Promise {
+ const result = await invokeSelectedRuntimeHost>(
+ host,
+ 'usage:pricing:load',
+ );
+ if (!result.ok) throw new Error(result.error.message);
+ return result.data;
+ },
+ // Apply one upsert/delete against the viewed revision (`base`). The adapter
+ // owns CAS + reconciliation; the outcome encodes committed/conflict/uncertain.
+ async mutate(
+ base: DesktopPricingSnapshot,
+ mutation: PricingMutation,
+ host: DesktopRuntimeHostRef,
+ ): Promise {
+ const result = await invokeSelectedRuntimeHost>(
+ host,
+ 'usage:pricing:mutate',
+ base,
+ mutation,
+ );
+ if (!result.ok) throw new Error(result.error.message);
+ return result.data;
+ },
+ },
bots: {
listStatuses(): Promise> {
return ipcRenderer.invoke('settings:bots:listStatuses');
diff --git a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
index 5c21481fdc..8abc59129d 100644
--- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
+++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx
@@ -31,6 +31,7 @@ import { SessionCollaborationServicesProvider } from '../features/session-collab
import { SessionNavigationServicesProvider } from '../features/session-navigation';
import { SessionSettingsServicesProvider } from '../features/session-settings';
import { TaskEntryServicesProvider } from '../features/task-entry';
+import { UsagePricingServicesProvider } from '../features/usage';
import { WorkbarServicesProvider } from '../features/workbar';
import { createDesktopAppUpdateServices } from '../platform/desktop/create-app-update-services';
import { createDesktopGoalServices } from '../platform/desktop/create-goal-services';
@@ -41,6 +42,7 @@ import { createDesktopSessionCollaborationServices } from '../platform/desktop/c
import { createDesktopSessionNavigationServices } from '../platform/desktop/create-session-navigation-services';
import { createDesktopSessionSettingsServices } from '../platform/desktop/create-session-settings-services';
import { createDesktopTaskEntryServices } from '../platform/desktop/create-task-entry-services';
+import { createDesktopUsagePricingServices } from '../platform/desktop/create-usage-pricing-services';
import { createDesktopWorkbarServices } from '../platform/desktop/create-workbar-services';
import { observeReactPerformanceMeasures } from '../platform/desktop/react-performance-measures';
@@ -62,6 +64,7 @@ export function createDesktopFeatureServices() {
sessionNavigation: createDesktopSessionNavigationServices(),
sessionSettings: createDesktopSessionSettingsServices(),
taskEntry: createDesktopTaskEntryServices(),
+ usagePricing: createDesktopUsagePricingServices(),
workbar: createDesktopWorkbarServices(),
};
}
@@ -82,7 +85,11 @@ export function DesktopFeatureServicesProvider(props: {
- {props.children}
+
+
+ {props.children}
+
+
diff --git a/apps/desktop/src/renderer/features/usage/README.md b/apps/desktop/src/renderer/features/usage/README.md
index 48c7e7f5b7..0422207f8a 100644
--- a/apps/desktop/src/renderer/features/usage/README.md
+++ b/apps/desktop/src/renderer/features/usage/README.md
@@ -39,10 +39,22 @@ to a thin wrapper).
- `ports.ts` — `UsageServices`: `loadUsageStats(range)` and
`updateUsageSettings(patch)`. Both narrow — the feature consumes only
`UsageSettings`/`UsageStats`, never the whole `AppSettings`.
+- `pricing-ports.ts` + `pricing-services-context.tsx` — the two Host-backed
+ Pricing capabilities: load one complete effective snapshot and apply one CAS
+ mutation against the settings-selected Host.
+- `controller/pricing-controller.ts` — disposable Pricing authority, conflict,
+ mutation execution, and Host-generation fencing. A Host change or view remount
+ recovers only the scope-owned draft, reloads authority, and requires explicit
+ review before the next save. If reconciliation was temporarily unavailable,
+ it retains the exact mutation intent and compares it with the next successful
+ snapshot via the shared pure reconciliation rules in `@maka/runtime-host/protocol`.
- `services-context.tsx` — `UsageFeatureScope`, the persistent state owner
(single tagged `{ range, value }` snapshot, reload ticket, unmount isolation,
Host/generation invalidation, load-failure toast), plus `useUsageServices()`
- and `useUsageStats(range)`.
+ and `useUsageStats(range)`. It also keeps the Pricing editor's input (mode,
+ draft, and cache-section state) through `usePricingEditorDraft()`, so Settings'
+ Host-keyed content and loading gate cannot discard the user's work. Pricing
+ snapshots and in-flight operations never persist in this scope.
- `ui/usage-settings-view.tsx` — the surface (overview + tabs + per-tab panels).
A disposable view: it unmounts on a section change and reads the snapshot from
the scope via `useUsageStats`, so leaving/returning re-displays the last
@@ -50,13 +62,12 @@ to a thin wrapper).
- `ui/usage-stats-table.tsx`, `ui/metric-card.tsx`, `controller/*` — feature-owned
presentational + framework helpers (external-only deps).
-## Wiring (one deviation from the composition-feature pattern, forced by the ratchet)
+## Wiring
-Unlike the composition-wired features, `settings-surface.tsx` is itself a frozen
+Usage stats remain a transitional exception because `settings-surface.tsx` is a frozen
legacy closure file, so it cannot import the feature or a `platform/` adapter, and
usage stats are scoped to the *settings-selected* Runtime Host (a settings concept
-the app-global composition root does not have). So there is **no `platform/desktop`
-adapter / no composition registration — a transitional seam.** `settings-surface.tsx`
+the app-global composition root does not have). `settings-surface.tsx`
builds a host-bound `loadUsageStats` (via its existing `window.maka.settings.usageStats`
call) plus an `updateUsageSettings` that projects the app-settings update down to
`UsageSettings`, bundles them as `UsageServices`, and mounts the legacy shim
@@ -65,13 +76,18 @@ call) plus an `updateUsageSettings` that projects the app-settings update down t
survives a Skeleton/Banner state or a section change; the disposable
`UsageSettingsPage` view is rendered in the section content slot and reads the scope
via context. The scope takes a `host:epoch` `targetKey` as a **prop** (not a React
-`key`): on a change it clears the snapshot and fences the in-flight load *in place*,
-so a Host change never remounts the rest of the Settings surface. The Host-change
+`key`): on a change it clears the snapshot and fences the in-flight load *in place*.
+The scope survives even when Settings replaces its Host-keyed page content. The Host-change
handler also calls the scope's imperative `fenceTarget()` *synchronously* (alongside
the other Host-scoped resources), rejecting an in-flight old-Host load before React
-re-renders the new target. When #4425's composition step lands, only this mounting
-seam moves to `composition/desktop-feature-services.tsx` + a stateless
-`platform/desktop` adapter — the scope stays feature-owned.
+re-renders the new target. That same fence is exposed to the Pricing controller as
+an `isCurrent` witness, so an old-Host mutation result cannot land in the event-to-
+render gap. Pricing itself is already composition-wired through
+`platform/desktop/create-usage-pricing-services.ts` and
+`composition/desktop-feature-services.tsx`; only the selected Host is threaded
+from the settings surface. When #4425's remaining composition step lands, only
+the Usage-stats mounting seam moves to composition plus a stateless Desktop
+adapter; the scope stays feature-owned.
Copy is **not** a deviation: the view imports `getUsageSettingsCopy` +
`UsageSettingsCopy` from `locales/settings-usage-copy.ts` directly. A feature import
@@ -82,17 +98,11 @@ shim, since `settings-error-copy` is not a copy catalog.
## Follow-up
-- Add a `SettingsSurface` integration test for the mount seam this PR moves.
- `usage-settings-view.test.ts` mounts `UsageFeatureScope` + `UsageSettingsView`
- directly and drives `fenceTarget()` / `targetKey` by hand; it does not load
- `settings-surface.tsx`, so the surface's fence call sites
- (`commitSelectedRuntimeHostProfile`, the generation-change handler) and the
- `usageTargetKey` derivation are not exercised end-to-end. Those three lifecycle
- obligations are exactly what a stale head had regressed with every test green, so
- a surface-level test guarding them is the real coverage; it is deferred to keep
- this extraction PR contained.
-- Add the editable pricing tab (#2015 / PR #4164) as a feature-internal tab,
- replacing the read-only pricing tab preserved here.
+- The `UsagePricingHostSwitch` Settings story now exercises a real Host lifecycle
+ event, page unmount, profile selection, recovered draft, and save against the
+ replacement Host's CAS base. `usage-settings-view.test.ts` still drives the
+ stats scope's fence and target key directly; broader stats integration coverage
+ remains a follow-up.
- De-duplicate the controllers. `controller/action-guard.ts` and
`controller/optimistic-settings-draft.ts` are feature-local copies of the legacy
`settings/` helpers (which keep ~9 consumers and their own tests). They are
diff --git a/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts
new file mode 100644
index 0000000000..54cf226feb
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts
@@ -0,0 +1,484 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useToast, useUiLocale } from '@maka/ui';
+import {
+ createPricingReconciliationTarget,
+ pricingReconciliationTargetMatches,
+ pricingReconciliationTargetModelKey,
+ type EffectivePricingEntry,
+ type PricingMutation,
+ type PricingReconciliationTarget,
+} from '@maka/runtime-host/protocol';
+import { useUsagePricingServices } from '../pricing-services-context.js';
+import { usePricingEditorDraft } from '../services-context.js';
+import type { UsagePricingServices, UsagePricingTarget } from '../pricing-ports.js';
+import { getPricingSettingsCopy } from '../../../locales/settings-pricing-copy.js';
+import { useActionGuard } from './action-guard.js';
+import {
+ draftFromPricing,
+ validatePricingDraft,
+ type PricingDraft,
+} from '../pricing-view-model.js';
+
+// Derive the controller's authority/outcome types from its injected port so the
+// port remains the test seam even though it is expressed with shared contracts.
+type DesktopPricingSnapshot = Awaited>;
+type DesktopPricingMutationOutcome = Awaited>;
+type PricingOverride = Extract;
+
+/**
+ * Write blockers from #2015: after a save whose post-commit reload failed, or an
+ * outcome we could not reconcile, further writes are disabled until a fresh
+ * snapshot loads. `conflict` keeps the draft and allows an explicit second save
+ * against the latest snapshot.
+ */
+export type PricingWriteState =
+ | { readonly kind: 'idle' }
+ | {
+ readonly kind: 'conflict';
+ readonly reason: 'revision_conflict' | 'outcome_unknown';
+ readonly intent: PricingReconciliationTarget;
+ }
+ | {
+ readonly kind: 'refresh_failed';
+ readonly intent: PricingReconciliationTarget;
+ }
+ | {
+ readonly kind: 'reconcile_unavailable';
+ readonly reason: 'revision_conflict' | 'outcome_unknown';
+ readonly intent: PricingReconciliationTarget;
+ };
+
+const EMPTY_DRAFT: PricingDraft = {
+ modelKey: '',
+ input: null,
+ output: null,
+ cacheRead: null,
+ cacheWrite: null,
+};
+
+/** Owns disposable pricing authority and outcomes; the Usage scope keeps the draft. */
+export function usePricingController(props: {
+ readonly describeError: (error: unknown) => string;
+ /** Settings-selected Host plus its lifecycle generation (`host:epoch`). */
+ readonly target: UsagePricingTarget | null;
+}) {
+ const services = useUsagePricingServices();
+ const { describeError } = props;
+ const locale = useUiLocale();
+ const copy = getPricingSettingsCopy(locale);
+ const toast = useToast();
+
+ const [snapshot, setSnapshot] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+ const [editor, setEditor] = usePricingEditorDraft();
+ const draft = editor?.draft ?? EMPTY_DRAFT;
+ const cacheOpen = editor?.cacheOpen ?? false;
+ const [writeState, setWriteState] = useState({ kind: 'idle' });
+ // A remounted view may recover a draft, but never its former mutation base.
+ const [needsReview, setNeedsReview] = useState(editor !== null);
+ const [pendingMutation, setPendingMutation] = useState(null);
+ const [resetTarget, setResetTarget] = useState(null);
+ const saving = pendingMutation === 'upsert';
+ const resetBusy = pendingMutation === 'delete';
+ const triggerRef = useRef(null);
+ const addButtonRef = useRef(null);
+ const focusRestorePendingRef = useRef(false);
+
+ const guard = useActionGuard();
+ // A single lifecycle generation fences Host replacement and unmount,
+ // including StrictMode's effect cleanup/restart.
+ const lifecycleRef = useRef(0);
+ // Authority sequence: bumped by a reload start (a newer reload supersedes an
+ // older one) AND by a committed mutation (`applyOutcome`). A reload captures
+ // it and drops its result if it changed while in flight — so a slow refresh
+ // started before a save can never land back on top of the saved authority, nor
+ // reset a `refresh_failed`/`reconcile` write-block to idle.
+ const reloadTicketRef = useRef(0);
+ const targetKey = props.target?.generationKey ?? 'no-host';
+ const [renderedTargetKey, setRenderedTargetKey] = useState(targetKey);
+
+ // Fence a changed Host during render, before an old asynchronous result can
+ // land in the event-to-effect gap. React immediately restarts this component
+ // with the new target key. Keep the editor draft, but discard all authority
+ // and require review after the replacement snapshot arrives.
+ if (targetKey !== renderedTargetKey) {
+ setRenderedTargetKey(targetKey);
+ lifecycleRef.current += 1;
+ reloadTicketRef.current += 1;
+ setSnapshot(null);
+ setLoading(true);
+ setLoadError(null);
+ setWriteState({ kind: 'idle' });
+ setNeedsReview(editor !== null);
+ setResetTarget(null);
+ setPendingMutation(null);
+ guard.finish();
+ }
+
+ useEffect(() => () => {
+ lifecycleRef.current += 1;
+ reloadTicketRef.current += 1;
+ }, []);
+
+ function isCurrent(
+ lifecycle: number,
+ target = props.target,
+ ): boolean {
+ return (
+ lifecycleRef.current === lifecycle &&
+ (target === null || target.isCurrent())
+ );
+ }
+
+ async function reload(): Promise {
+ const host = props.target?.host;
+ const pendingWrite =
+ writeState.kind === 'refresh_failed' || writeState.kind === 'reconcile_unavailable'
+ ? writeState
+ : undefined;
+ const lifecycle = lifecycleRef.current;
+ const ticket = ++reloadTicketRef.current;
+ setLoading(true);
+ // No selected Host: nothing Host-scoped to load. Resolve to an empty state
+ // (like the usage stats loader's no-Host path) rather than letting the bridge
+ // fall back to a *different* (active) Host than the settings page shows.
+ if (!host) {
+ if (isCurrent(lifecycle) && ticket === reloadTicketRef.current) {
+ setSnapshot(null);
+ setLoadError(null);
+ setWriteState({ kind: 'idle' });
+ setLoading(false);
+ }
+ return;
+ }
+ try {
+ const next = await services.loadPricing(host);
+ if (!isCurrent(lifecycle) || ticket !== reloadTicketRef.current) return;
+ setLoadError(null);
+ if (pendingWrite) {
+ applyOutcome(
+ {
+ kind: pricingReconciliationTargetMatches(pendingWrite.intent, next.entries)
+ ? 'synchronized'
+ : 'review_required',
+ snapshot: next,
+ reason:
+ pendingWrite.kind === 'reconcile_unavailable'
+ ? pendingWrite.reason
+ : 'revision_conflict',
+ },
+ pendingWrite.intent,
+ );
+ } else {
+ setSnapshot(next);
+ setWriteState({ kind: 'idle' });
+ }
+ } catch (error) {
+ if (!isCurrent(lifecycle) || ticket !== reloadTicketRef.current) return;
+ setLoadError(describeError(error));
+ } finally {
+ if (isCurrent(lifecycle) && ticket === reloadTicketRef.current) setLoading(false);
+ }
+ }
+
+ // Load on mount and whenever the selected Host generation changes. Settings
+ // may also remount this view on a Host identity change or a loading gate.
+ // Either path reloads authority and keeps only the scope-owned user draft;
+ // lifecycle and target checks reject responses from the discarded view/Host.
+ useEffect(() => {
+ void reload();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [targetKey]);
+
+ // Overrides-only surface (#2015 / maintainer direction on #2218): the table
+ // shows only the user's custom rows, and adding one picks from the built-in
+ // catalog. The Host collapses an overridden built-in into a single `custom`
+ // entry, so `catalogRows` is naturally the built-ins NOT yet overridden.
+ const overrideRows = useMemo(
+ () => snapshot?.entries.filter((row) => row.source === 'custom') ?? [],
+ [snapshot],
+ );
+ const catalogRows = useMemo(
+ () => snapshot?.entries.filter((row) => row.source === 'builtin') ?? [],
+ [snapshot],
+ );
+ // Duplicate detection is over the OVERRIDES only (the visible list): picking or
+ // typing a built-in that is not yet overridden is a NEW override (an upsert),
+ // not a duplicate — only a key that already has a custom row is rejected
+ // ("edit its row instead"). Checking the full built-in ∪ overrides union here
+ // would wrongly flag every catalog pick (all built-ins) as a duplicate and
+ // block its save.
+ const overrideKeys = useMemo(() => overrideRows.map((row) => row.pricing.modelKey), [overrideRows]);
+ const validation = useMemo(
+ () =>
+ validatePricingDraft(draft, {
+ mode: editor?.mode === 'edit' ? 'edit' : 'add',
+ existingKeys: overrideKeys,
+ lockedModelKey: editor?.mode === 'edit' ? draft.modelKey : undefined,
+ }),
+ [draft, editor, overrideKeys],
+ );
+
+ const writesBlocked =
+ needsReview ||
+ writeState.kind === 'refresh_failed' ||
+ writeState.kind === 'reconcile_unavailable';
+
+ // Restore focus only after React has committed the dialog/row removal. A
+ // timer can run while the row action is still connected, focus that doomed
+ // trigger, and then leave focus on when the commit removes it. The
+ // layout effect observes the committed DOM and chooses the stable fallback
+ // when a successful reset/delete removed the opening row.
+ useLayoutEffect(() => {
+ if (!focusRestorePendingRef.current) return;
+ focusRestorePendingRef.current = false;
+ const trigger = triggerRef.current;
+ triggerRef.current = null;
+ if (trigger?.isConnected) trigger.focus();
+ else addButtonRef.current?.focus();
+ }, [editor, resetTarget]);
+
+ // On a conflict, the fresh-authority row for whatever the user is editing or
+ // resetting — so the notice can show the latest value beside their draft
+ // rather than only claiming one exists.
+ const conflictLatestEntry = useMemo(() => {
+ if (writeState.kind !== 'conflict') return null;
+ const key = pricingReconciliationTargetModelKey(writeState.intent);
+ return snapshot?.entries.find(({ pricing }) => pricing.modelKey === key) ?? null;
+ }, [writeState, snapshot]);
+
+ function restoreTriggerFocus() {
+ focusRestorePendingRef.current = true;
+ }
+
+ function openAdd(trigger: HTMLElement | null) {
+ // No loaded authority (no selected Host, or a load still pending/failed)
+ // means a save would have no CAS base and silently no-op — so the editor
+ // must not open. The Add control is disabled for the same reason.
+ if (writesBlocked || snapshot === null) return;
+ triggerRef.current = trigger;
+ setEditor({ mode: 'catalog', draft: EMPTY_DRAFT, cacheOpen: false });
+ }
+
+ function openEdit(row: PricingOverride, trigger: HTMLElement | null) {
+ if (writesBlocked) return;
+ triggerRef.current = trigger;
+ setEditor({ mode: 'edit', ...draftFromPricing(row.pricing) });
+ }
+
+ /**
+ * Pre-fill the open Add draft from a chosen built-in catalog row: the built-in
+ * price is the starting point for the override, and the cache section opens iff
+ * the built-in carries cache rates. Stays in add mode — the row is a built-in
+ * not yet overridden, so its key validates as a new override.
+ */
+ function pickCatalogModel(row: EffectivePricingEntry) {
+ setEditor({ mode: 'catalog', ...draftFromPricing(row.pricing) });
+ }
+
+ function clearModel(mode: 'catalog' | 'manual' = 'catalog') {
+ setEditor({ mode, draft: EMPTY_DRAFT, cacheOpen: false });
+ }
+
+ function setCacheOpen(open: boolean) {
+ setEditor((current) => current ? { ...current, cacheOpen: open } : null);
+ }
+
+ function reviewHostChange() {
+ if (needsReview && snapshot !== null) setNeedsReview(false);
+ }
+
+ function closeEditor() {
+ if (saving) return;
+ setEditor(null);
+ setNeedsReview(false);
+ if (writeState.kind === 'conflict') {
+ setWriteState({ kind: 'idle' });
+ }
+ restoreTriggerFocus();
+ }
+
+ const setField = (key: K, value: PricingDraft[K]) =>
+ setEditor((current) => current && !(current.mode === 'edit' && key === 'modelKey')
+ ? { ...current, draft: { ...current.draft, [key]: value } }
+ : current);
+
+ function finishReconciledIntent(intent: PricingReconciliationTarget): void {
+ if (intent.kind === 'upsert') setEditor(null);
+ else setResetTarget(null);
+ restoreTriggerFocus();
+ }
+
+ function restoreReconciledIntent(
+ intent: PricingReconciliationTarget,
+ latest: DesktopPricingSnapshot,
+ ): void {
+ const key = pricingReconciliationTargetModelKey(intent);
+ const latestRow = latest.entries.find(({ pricing }) => pricing.modelKey === key);
+ if (intent.kind === 'upsert') {
+ if (latestRow) setEditor((current) => current
+ ? { ...current, mode: 'edit', draft: { ...current.draft, modelKey: key } }
+ : null);
+ return;
+ }
+ if (latestRow?.source === 'custom') setResetTarget(latestRow);
+ }
+
+ /** Adopt one settled outcome using the same authority and intent as a reload. */
+ function applyOutcome(
+ outcome: DesktopPricingMutationOutcome,
+ intent: PricingReconciliationTarget,
+ ): void {
+ // Fence any reload that was in flight when this mutation committed, so a
+ // stale refresh can't overwrite the authority we're about to set (nor reset
+ // a write-block to idle). Clear its loading indicator too — the fenced
+ // reload's own `finally` will no longer run.
+ reloadTicketRef.current += 1;
+ setLoading(false);
+ if ('snapshot' in outcome) {
+ setSnapshot(outcome.snapshot);
+ setLoadError(null);
+ }
+ switch (outcome.kind) {
+ case 'saved':
+ setWriteState({ kind: 'idle' });
+ finishReconciledIntent(intent);
+ toast.success(copy.saved, outcome.disposition === 'unchanged' ? copy.synchronized : undefined);
+ return;
+ case 'synchronized':
+ setWriteState({ kind: 'idle' });
+ finishReconciledIntent(intent);
+ toast.success(copy.synchronized);
+ return;
+ case 'review_required':
+ // Adopt fresh authority into the list so it is no longer speculative,
+ // keep the draft, and require an explicit second save against `latest`.
+ setWriteState({ kind: 'conflict', reason: outcome.reason, intent });
+ // If this was an Add and the fresh authority now already has that key
+ // (added elsewhere), the duplicate check would leave `validation.config`
+ // null and silently block the required second save. Convert the Add into
+ // an Edit locked on that key so the explicit re-save upserts against the
+ // latest revision (the draft's rates are preserved).
+ restoreReconciledIntent(intent, outcome.snapshot);
+ return;
+ case 'saved_refresh_failed':
+ // The write committed but the post-commit reload failed — the loaded list
+ // is now definitely stale. Drop it (#2015: show no speculative final
+ // list); retain both the draft and intended end state so an in-dialog
+ // refresh can confirm the committed write without replaying it.
+ setSnapshot(null);
+ setWriteState({ kind: 'refresh_failed', intent });
+ return;
+ case 'reconciliation_unavailable':
+ setWriteState({ kind: 'reconcile_unavailable', reason: outcome.reason, intent });
+ return;
+ }
+ }
+
+ /** Every submit path shares the write blockers, CAS base, and lifecycle fence. */
+ async function mutate(mutation: PricingMutation): Promise {
+ const base = snapshot;
+ const target = props.target;
+ if (writesBlocked || !base || !target?.isCurrent()) return;
+ if (!guard.begin('write')) return;
+ const lifecycle = lifecycleRef.current;
+ const intent = createPricingReconciliationTarget(base.entries, mutation);
+ setPendingMutation(mutation.kind);
+ try {
+ const outcome = await services.mutatePricing(target.host, base, mutation);
+ if (!isCurrent(lifecycle, target)) return;
+ applyOutcome(outcome, intent);
+ } catch (error) {
+ if (isCurrent(lifecycle, target)) {
+ toast.error(mutation.kind === 'upsert' ? copy.saveFailed : copy.resetFailed, describeError(error));
+ }
+ } finally {
+ if (isCurrent(lifecycle, target)) {
+ guard.finish();
+ setPendingMutation(null);
+ }
+ }
+ }
+
+ async function save(): Promise {
+ if (validation.config) await mutate({ kind: 'upsert', pricing: validation.config });
+ }
+
+ function openReset(
+ row: PricingOverride,
+ trigger: HTMLElement | null,
+ ) {
+ if (writesBlocked) return;
+ triggerRef.current = trigger;
+ setResetTarget(row);
+ }
+
+ function cancelReset() {
+ if (resetBusy) return;
+ setResetTarget(null);
+ if (writeState.kind === 'conflict') setWriteState({ kind: 'idle' });
+ restoreTriggerFocus();
+ }
+
+ async function confirmReset(): Promise {
+ if (resetTarget) await mutate({ kind: 'delete', modelKey: resetTarget.pricing.modelKey });
+ }
+
+ return {
+ copy,
+ addButtonRef,
+ loading,
+ loadError,
+ // A write needs a loaded snapshot as its CAS base; without one (no Host, or
+ // a load pending/failed) the Add flow is disabled rather than silently
+ // no-opping on save.
+ hasAuthority: snapshot !== null,
+ // Overrides-only table + catalog picker for the Add flow.
+ overrideRows,
+ catalogRows,
+ pickCatalogModel,
+ clearModel,
+ editor,
+ draft,
+ setField,
+ cacheOpen,
+ setCacheOpen,
+ validation,
+ writeState,
+ needsReview,
+ writesBlocked,
+ reviewHostChange,
+ conflictLatestEntry,
+ saving,
+ resetTarget,
+ resetBusy,
+ reload,
+ openAdd,
+ openEdit,
+ closeEditor,
+ save,
+ openReset,
+ cancelReset,
+ confirmReset,
+ };
+}
diff --git a/apps/desktop/src/renderer/features/usage/index.ts b/apps/desktop/src/renderer/features/usage/index.ts
index bb59e3d785..461ec90bf1 100644
--- a/apps/desktop/src/renderer/features/usage/index.ts
+++ b/apps/desktop/src/renderer/features/usage/index.ts
@@ -23,3 +23,9 @@
export { UsageSettingsView } from './ui/usage-settings-view.js';
export { UsageFeatureScope, type UsageScopeHandle } from './services-context.js';
export type { UsageServices } from './ports.js';
+// The editable Pricing surface (#2015) is a Usage tab, but its services are
+// assembled in `composition/desktop-feature-services.tsx` (not the legacy
+// settings-surface that assembles `UsageServices`), so its bridge access stays
+// out of the frozen legacy-AppShell closure.
+export { UsagePricingServicesProvider } from './pricing-services-context.js';
+export type { UsagePricingServices } from './pricing-ports.js';
diff --git a/apps/desktop/src/renderer/features/usage/ports.ts b/apps/desktop/src/renderer/features/usage/ports.ts
index a672b6225d..0a20393bd2 100644
--- a/apps/desktop/src/renderer/features/usage/ports.ts
+++ b/apps/desktop/src/renderer/features/usage/ports.ts
@@ -19,6 +19,18 @@
import type { UsageRange, UsageSettings, UsageStats } from '@maka/core/settings';
+/**
+ * Minimal Runtime Host identity the feature threads for Host-scoped reads/writes
+ * (pricing overrides are per-Host / root-scoped). It is structurally compatible
+ * with the preload `DesktopRuntimeHostRef`, so the legacy surface can pass its
+ * `selectedRuntimeHost` straight through as a prop — without the feature
+ * importing the preload type.
+ */
+export interface UsageHostRef {
+ readonly profileId: string;
+ readonly hostId: string;
+}
+
// Dependency-inversion boundary for the Usage settings feature (issue #4425).
// The feature controller owns draft/state and reads these ports; it never
// touches `window.maka` or legacy settings helpers directly. Both are narrow —
diff --git a/apps/desktop/src/renderer/features/usage/pricing-ports.ts b/apps/desktop/src/renderer/features/usage/pricing-ports.ts
new file mode 100644
index 0000000000..9c8ac0a056
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/pricing-ports.ts
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Dependency-inversion boundary for the editable Pricing surface (#2015),
+// hosted inside the Usage settings feature (#4425). The pricing controller owns
+// the draft/CAS state and reads these ports; it never touches `window.maka`.
+//
+// Unlike the range-scoped `UsageServices` (still assembled inline in the legacy
+// `settings/settings-surface.tsx`), pricing services are assembled in
+// `composition/desktop-feature-services.tsx` via a `platform/desktop` adapter —
+// the composition ownership #4425 targets. Pricing is net-new, so routing its
+// `window.maka.settings.pricing` bridge access through the platform adapter is
+// what keeps a new bridge path out of the frozen legacy-AppShell closure files
+// (the renderer-architecture ratchet forbids growing their bridge paths).
+//
+import type { PricingMutation } from '@maka/runtime-host/protocol';
+import type {
+ DesktopPricingMutationOutcome,
+ DesktopPricingSnapshot,
+} from '../../../shared/desktop-pricing.js';
+import type { UsageHostRef } from './ports.js';
+
+export interface UsagePricingTarget {
+ readonly host: UsageHostRef;
+ readonly generationKey: string;
+ /** True only while this exact Host generation remains authoritative. */
+ readonly isCurrent: () => boolean;
+}
+
+export interface UsagePricingServices {
+ /**
+ * One complete effective pricing snapshot (built-in ∪ overrides) for the given
+ * Runtime Host. The `host` is the settings-*selected* Host (threaded from the
+ * legacy surface), not the app's active Host — pricing overrides are per-Host,
+ * so the Pricing tab must read/write the same Host as the rest of the settings
+ * page. The renderer round-trips the snapshot as the CAS base for a mutation.
+ */
+ loadPricing(host: UsageHostRef): Promise;
+ /** Apply one pricing upsert/delete against the viewed snapshot (the CAS base). */
+ mutatePricing(
+ host: UsageHostRef,
+ base: DesktopPricingSnapshot,
+ mutation: PricingMutation,
+ ): Promise;
+}
diff --git a/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx
new file mode 100644
index 0000000000..0759a3414f
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createServicesContext } from '../../application/contracts/feature-services.js';
+import type { UsagePricingServices } from './pricing-ports.js';
+
+// One app-root provider supplies the transport adapter. Each operation receives
+// the settings-selected Host explicitly; the controller separately receives its
+// generation key so it can fence stale work without remounting the provider.
+const { Provider, useServices } =
+ createServicesContext('UsagePricingServicesProvider');
+
+export const UsagePricingServicesProvider = Provider;
+
+export function useUsagePricingServices(): UsagePricingServices {
+ return useServices();
+}
diff --git a/apps/desktop/src/renderer/features/usage/pricing-view-model.ts b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts
new file mode 100644
index 0000000000..fdb3d79e29
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/**
+ * Pricing draft conversion and field validation. The UI reads the Host's
+ * effective entries directly; only editable drafts need a different shape.
+ */
+
+import { normalizePricingModelKey } from '@maka/core/usage-stats/pricing';
+import type { PricingConfig } from '@maka/core/usage-stats/types';
+
+export interface PricingDraft {
+ readonly modelKey: string;
+ /** `null` = the field is empty (a cleared NumberInput). */
+ readonly input: number | null;
+ readonly output: number | null;
+ readonly cacheRead: number | null;
+ readonly cacheWrite: number | null;
+}
+
+/** In-memory user input survives the Settings Host/loading gates remounting the view. */
+export interface PricingEditorDraft {
+ readonly mode: 'catalog' | 'manual' | 'edit';
+ readonly draft: PricingDraft;
+ readonly cacheOpen: boolean;
+}
+
+/**
+ * The editor draft pre-filled from an existing row — shared by the Edit flow and
+ * the Add flow's catalog pick. `cacheOpen` is true iff the row carries either
+ * cache rate (an explicit `0` counts; only `undefined` is "Not set").
+ */
+export function draftFromPricing(pricing: PricingConfig): {
+ readonly draft: PricingDraft;
+ readonly cacheOpen: boolean;
+} {
+ return {
+ draft: {
+ modelKey: pricing.modelKey,
+ input: pricing.inputUsdPer1M,
+ output: pricing.outputUsdPer1M,
+ cacheRead: pricing.cacheReadUsdPer1M ?? null,
+ cacheWrite: pricing.cacheWriteUsdPer1M ?? null,
+ },
+ cacheOpen: pricing.cacheReadUsdPer1M !== undefined || pricing.cacheWriteUsdPer1M !== undefined,
+ };
+}
+
+export type PricingRateErrorCode = 'required' | 'invalid_rate';
+export type PricingKeyErrorCode = 'required' | 'key_too_long' | 'duplicate';
+
+export interface PricingDraftErrors {
+ modelKey?: PricingKeyErrorCode;
+ input?: PricingRateErrorCode;
+ output?: PricingRateErrorCode;
+ cacheRead?: 'invalid_rate';
+ cacheWrite?: 'invalid_rate';
+}
+
+export interface PricingDraftValidation {
+ readonly errors: PricingDraftErrors;
+ readonly hasErrors: boolean;
+ /** The canonical config to send, present iff `hasErrors` is false. */
+ readonly config: PricingConfig | null;
+}
+
+export function validatePricingDraft(
+ draft: PricingDraft,
+ options: {
+ readonly mode: 'add' | 'edit';
+ readonly existingKeys: readonly string[];
+ /** Required in edit mode — the fixed identity key. */
+ readonly lockedModelKey?: string;
+ },
+): PricingDraftValidation {
+ const errors: PricingDraftErrors = {};
+
+ let modelKey: string | null = null;
+ if (options.mode === 'edit') {
+ modelKey = options.lockedModelKey ?? null;
+ } else {
+ const normalized = normalizePricingModelKey(draft.modelKey);
+ if (!normalized.ok) {
+ errors.modelKey = draft.modelKey.trim() === '' ? 'required' : 'key_too_long';
+ } else if (options.existingKeys.includes(normalized.value)) {
+ errors.modelKey = 'duplicate';
+ } else {
+ modelKey = normalized.value;
+ }
+ }
+
+ const input = validateRequiredRate(draft.input);
+ if (input !== 'ok') errors.input = input;
+ const output = validateRequiredRate(draft.output);
+ if (output !== 'ok') errors.output = output;
+ if (draft.cacheRead !== null && !isValidRate(draft.cacheRead)) {
+ errors.cacheRead = 'invalid_rate';
+ }
+ if (draft.cacheWrite !== null && !isValidRate(draft.cacheWrite)) {
+ errors.cacheWrite = 'invalid_rate';
+ }
+
+ const hasErrors = Object.keys(errors).length > 0;
+ const config: PricingConfig | null =
+ !hasErrors && modelKey !== null && draft.input !== null && draft.output !== null
+ ? {
+ modelKey,
+ inputUsdPer1M: draft.input,
+ outputUsdPer1M: draft.output,
+ ...(draft.cacheRead !== null ? { cacheReadUsdPer1M: draft.cacheRead } : {}),
+ ...(draft.cacheWrite !== null ? { cacheWriteUsdPer1M: draft.cacheWrite } : {}),
+ }
+ : null;
+
+ return { errors, hasErrors, config };
+}
+
+function validateRequiredRate(value: number | null): 'ok' | PricingRateErrorCode {
+ if (value === null) return 'required';
+ return isValidRate(value) ? 'ok' : 'invalid_rate';
+}
+
+function isValidRate(value: number): boolean {
+ return Number.isFinite(value) && value >= 0;
+}
diff --git a/apps/desktop/src/renderer/features/usage/services-context.tsx b/apps/desktop/src/renderer/features/usage/services-context.tsx
index 5993bc2327..b8bd4c018d 100644
--- a/apps/desktop/src/renderer/features/usage/services-context.tsx
+++ b/apps/desktop/src/renderer/features/usage/services-context.tsx
@@ -26,11 +26,14 @@ import {
useMemo,
useRef,
useState,
+ type Dispatch,
type ReactNode,
+ type SetStateAction,
} from 'react';
import { useMountedRef, useToast } from '@maka/ui';
import type { UsageRange, UsageStats } from '@maka/core/settings';
import type { UsageServices } from './ports.js';
+import type { PricingEditorDraft } from './pricing-view-model.js';
interface UsageSnapshot {
readonly range: UsageRange;
@@ -53,6 +56,10 @@ interface UsageScopeValue {
/** The current Host generation (`host:epoch`); changes when the target does. */
readonly targetKey: string;
reload(range: UsageRange): Promise;
+ /** True only while the rendered Host generation is still authoritative. */
+ isCurrentTarget(): boolean;
+ readonly pricingEditor: PricingEditorDraft | null;
+ readonly setPricingEditor: Dispatch>;
}
const UsageScopeContext = createContext(null);
@@ -91,9 +98,16 @@ export const UsageFeatureScope = forwardRef<
const toast = useToast();
const mountedRef = useMountedRef();
const [snapshot, setSnapshot] = useState(null);
+ const [pricingEditor, setPricingEditor] = useState(null);
const [renderedTargetKey, setRenderedTargetKey] = useState(props.targetKey);
const reloadTicketRef = useRef(0);
+ const targetCurrentRef = useRef(true);
+ const renderedTargetKeyRef = useRef(props.targetKey);
const { targetKey, services, loadErrorTitle, describeError } = props;
+ const isCurrentTarget = useCallback(
+ () => targetCurrentRef.current && renderedTargetKeyRef.current === targetKey,
+ [targetKey],
+ );
// Reset on a target (Host generation) change without remounting the subtree:
// drop the previous Host's snapshot and invalidate its in-flight load so it
@@ -101,8 +115,10 @@ export const UsageFeatureScope = forwardRef<
// render" pattern; it runs once because `renderedTargetKey` then matches.
if (targetKey !== renderedTargetKey) {
setRenderedTargetKey(targetKey);
+ renderedTargetKeyRef.current = targetKey;
setSnapshot(null);
reloadTicketRef.current += 1;
+ targetCurrentRef.current = true;
}
// Last-write-wins across concurrent reloads: a superseded (newer reload or a
@@ -136,6 +152,7 @@ export const UsageFeatureScope = forwardRef<
() => ({
fenceTarget: () => {
reloadTicketRef.current += 1;
+ targetCurrentRef.current = false;
setSnapshot(null);
},
}),
@@ -143,8 +160,16 @@ export const UsageFeatureScope = forwardRef<
);
const value = useMemo(
- () => ({ services, snapshot, targetKey, reload }),
- [services, snapshot, targetKey, reload],
+ () => ({
+ services,
+ snapshot,
+ targetKey,
+ reload,
+ isCurrentTarget,
+ pricingEditor,
+ setPricingEditor,
+ }),
+ [services, snapshot, targetKey, reload, isCurrentTarget, pricingEditor],
);
return {props.children} ;
@@ -161,6 +186,12 @@ export function useUsageServices(): UsageServices {
return useUsageScope().services;
}
+/** Only user input persists above the Host gate; pricing authority stays view-owned. */
+export function usePricingEditorDraft() {
+ const { pricingEditor, setPricingEditor } = useUsageScope();
+ return [pricingEditor, setPricingEditor] as const;
+}
+
/**
* Read the stats for `range` plus the scope's `reload` and `targetKey`. Stats are
* surfaced only when the held snapshot was loaded for the requested range; during
@@ -173,8 +204,9 @@ export function useUsageStats(range: UsageRange): {
readonly stats: UsageStats | null;
readonly targetKey: string;
reload(range: UsageRange): Promise;
+ isCurrentTarget(): boolean;
} {
- const { snapshot, targetKey, reload } = useUsageScope();
+ const { snapshot, targetKey, reload, isCurrentTarget } = useUsageScope();
const stats = snapshot && snapshot.range === range ? snapshot.value : null;
- return { stats, targetKey, reload };
+ return { stats, targetKey, reload, isCurrentTarget };
}
diff --git a/apps/desktop/src/renderer/features/usage/testing.ts b/apps/desktop/src/renderer/features/usage/testing.ts
new file mode 100644
index 0000000000..1bd5f7d053
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/testing.ts
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Test-only entry point for the Usage feature (issue #4425 / #2015). External
+// test consumers import feature internals through this barrel so the
+// renderer-architecture ratchet's "features import via index/testing/stories"
+// rule stays satisfied.
+
+export {
+ draftFromPricing,
+ validatePricingDraft,
+ type PricingDraft,
+} from './pricing-view-model.js';
+export { PricingEditor, formatCache, formatUsd } from './ui/pricing-editor.js';
+export { UsagePricingServicesProvider } from './pricing-services-context.js';
+export { UsageFeatureScope } from './services-context.js';
+export type { UsagePricingServices } from './pricing-ports.js';
+export type { UsageHostRef } from './ports.js';
+export { getPricingSettingsCopy } from '../../locales/settings-pricing-copy.js';
diff --git a/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx
new file mode 100644
index 0000000000..893e2c97bb
--- /dev/null
+++ b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx
@@ -0,0 +1,621 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useId, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+import { EmptyState, Heading, Skeleton, Text } from '@astryxdesign/core';
+import { Collapsible } from '@astryxdesign/core/Collapsible';
+import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
+import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
+import { Typeahead, createStaticSource, type SearchableItem } from '@astryxdesign/core/Typeahead';
+import { Banner, Button, HStack, NumberInput, TextInput, VStack } from '@maka/ui';
+import { ICON_SIZE, BarChart3, Pencil, Plus, RefreshCcw, RotateCcw, Search, Trash2 } from '@maka/ui/icons';
+import type { PricingSettingsCopy } from '../../../locales/settings-pricing-copy.js';
+import type { EffectivePricingEntry } from '@maka/runtime-host/protocol';
+import { usePricingController } from '../controller/pricing-controller.js';
+import type { UsagePricingTarget } from '../pricing-ports.js';
+import type { PricingDraftErrors } from '../pricing-view-model.js';
+import { UsageStatsTable, type UsageColumn } from './usage-stats-table.js';
+
+/** A built-in catalog row as a Typeahead item (its `label` is the model key). */
+type CatalogItem = SearchableItem<{ row: EffectivePricingEntry }>;
+
+export function PricingEditor(props: {
+ readonly describeError: (error: unknown) => string;
+ readonly target: UsagePricingTarget | null;
+}) {
+ const c = usePricingController({
+ describeError: props.describeError,
+ target: props.target,
+ });
+ const { copy } = c;
+
+ const columns: UsageColumn[] = [
+ { header: copy.headers[0], width: 300 },
+ { header: copy.headers[1], width: 152 },
+ // Rate columns carry a "… / 1M" header; the shared table's default numeric
+ // width (88) truncates the wider ones (esp. the cache headers, and even more
+ // so in English: "Cache read / 1M" / "Cache write / 1M"). Size each to show
+ // its full header — the panel is wide enough that this adds no scroll.
+ { header: copy.headers[2], numeric: true, width: 116 },
+ { header: copy.headers[3], numeric: true, width: 116 },
+ { header: copy.headers[4], numeric: true, width: 140 },
+ { header: copy.headers[5], numeric: true, width: 140 },
+ { header: copy.actionsHeader, width: 104 },
+ ];
+
+ // The table shows only the user's overrides (#2015 / #2218 direction). The
+ // ~1.4k built-in catalog is never rendered as a table — it is reachable only
+ // through the Add flow's Typeahead picker.
+ const rows = c.overrideRows.map((row) => [
+ row.pricing.modelKey,
+ pricingSourceLabel(row, copy),
+ formatUsd(row.pricing.inputUsdPer1M),
+ formatUsd(row.pricing.outputUsdPer1M),
+ formatCache(row.pricing.cacheReadUsdPer1M, copy),
+ formatCache(row.pricing.cacheWriteUsdPer1M, copy),
+ c.openEdit(row, trigger)}
+ onReset={(trigger) => c.openReset(row, trigger)}
+ />,
+ ]);
+
+ return (
+
+
+
+ {copy.title}
+ {copy.subtitle}
+
+
+ void c.reload()}
+ icon={ }
+ />
+ }
+ label={copy.add}
+ isDisabled={c.writesBlocked || c.loadError !== null || !c.hasAuthority}
+ tooltip={
+ c.writesBlocked
+ ? copy.writeBlockedReason
+ : !c.hasAuthority
+ ? copy.addNeedsSnapshot
+ : undefined
+ }
+ onClick={(event) => c.openAdd(event.currentTarget)}
+ />
+
+
+
+ {/* Keep notices inside whichever modal owns the pending intent. The panel
+ notice is only for a blocked state with no open editor/reset dialog. */}
+ {c.editor === null && c.resetTarget === null ? (
+
void c.reload()}
+ refreshBusy={c.loading}
+ />
+ ) : null}
+
+
+ {c.loadError !== null ? (
+ }
+ title={copy.loadFailedTitle}
+ description={copy.loadFailedBody}
+ actions={ void c.reload()} />}
+ className="settingsUsageEmpty"
+ />
+ ) : c.loading && !c.hasAuthority ? (
+ // Reserve the ready table geometry with skeleton rows so the real
+ // rows land with zero layout shift (DESIGN.md §Loading).
+
+ ) : c.hasAuthority ? (
+
+ ) : null}
+
+
+ {c.editor !== null ? : null}
+
+ {c.resetTarget !== null ? : null}
+
+ );
+}
+
+function PricingRowActions(props: {
+ row: Extract;
+ copy: PricingSettingsCopy;
+ disabled: boolean;
+ onEdit(trigger: HTMLElement | null): void;
+ onReset(trigger: HTMLElement | null): void;
+}) {
+ const { row, copy } = props;
+ // Every rendered row is a user override (overrides-only surface): a
+ // `restore_builtin` row can be reset to its bundled price, a `become_unpriced`
+ // row can only be deleted (Custom-only).
+ const isDelete = row.resetEffect === 'become_unpriced';
+ return (
+
+ }
+ onClick={(event) => props.onEdit(event.currentTarget)}
+ />
+
+ ) : (
+
+ )
+ }
+ onClick={(event) => props.onReset(event.currentTarget)}
+ />
+
+ );
+}
+
+function PricingResetDialog(props: {
+ controller: ReturnType;
+}) {
+ const c = props.controller;
+ const { copy, resetTarget } = c;
+ const descriptionId = useId();
+ const dialogRef = usePricingDialogFocus();
+ if (resetTarget === null) return null;
+ const isDelete = resetTarget.resetEffect === 'become_unpriced';
+ const hasConflict = c.writeState.kind === 'conflict';
+
+ return (
+ {
+ if (!open) c.cancelReset();
+ }}
+ role="alertdialog"
+ aria-describedby={descriptionId}
+ purpose="form"
+ width={400}
+ >
+ }
+ content={
+
+
+
+ {isDelete ? copy.deleteBody(resetTarget.pricing.modelKey) : copy.resetBody(resetTarget.pricing.modelKey)}
+
+ void c.reload()}
+ refreshBusy={c.loading}
+ />
+
+
+ }
+ footer={
+
+
+
+ void c.confirmReset()}
+ />
+
+
+ }
+ />
+
+ );
+}
+
+function PricingEditorDialog(props: {
+ controller: ReturnType;
+}) {
+ const c = props.controller;
+ const { copy, draft, validation, editor } = c;
+ const dialogRef = usePricingDialogFocus();
+ const isEdit = editor?.mode === 'edit';
+ const title = isEdit ? copy.editTitle : copy.addTitle;
+ // Show field errors only after a save attempt so a fresh Add form is quiet.
+ const [attempted, setAttempted] = useState(false);
+ // Selection is the draft's identity, including while a Host reload has no
+ // catalog yet. Keeping a second selected-item state lets the two drift.
+ const picked = useMemo(
+ () => draft.modelKey ? { id: draft.modelKey, label: draft.modelKey } : null,
+ [draft.modelKey],
+ );
+ const catalogSource = useMemo(
+ () =>
+ createStaticSource(
+ c.catalogRows.map((row) => ({ id: row.pricing.modelKey, label: row.pricing.modelKey, auxiliaryData: { row } })),
+ ),
+ [c.catalogRows],
+ );
+
+ function close() {
+ if (!c.saving) c.closeEditor();
+ }
+ function submit() {
+ setAttempted(true);
+ void c.save();
+ }
+ const fieldStatus = (message: string | undefined) =>
+ attempted && message ? ({ type: 'error' as const, message }) : undefined;
+ const errorMessage = (code: PricingDraftErrors[keyof PricingDraftErrors]): string | undefined =>
+ code === undefined
+ ? undefined
+ : code === 'required'
+ ? copy.errorRequired
+ : code === 'invalid_rate'
+ ? copy.errorInvalidRate
+ : code === 'key_too_long'
+ ? copy.errorKeyTooLong
+ : copy.errorDuplicate;
+
+ return (
+ {
+ if (!open) close();
+ }}
+ aria-label={title}
+ purpose="form"
+ width={480}
+ maxHeight="calc(100dvh - 64px)"
+ >
+ { if (!open) close(); }} />}
+ content={
+
+ { event.preventDefault(); submit(); }}>
+ {isEdit ? (
+ // Editing an existing override: the key is fixed, shown read-only.
+ c.setField('modelKey', value)}
+ label={copy.modelKeyLabel}
+ isReadOnly
+ width="100%"
+ />
+ ) : editor?.mode === 'catalog' ? (
+ // Add via the built-in catalog: Typeahead renders only the top
+ // matches (never the ~1.4k-row list), and a pick pre-fills the
+ // built-in price. Its `value.label` is the model key it commits.
+
+
+ label={copy.catalogPickerLabel}
+ searchSource={catalogSource}
+ value={picked}
+ onChange={(item) => {
+ if (item) {
+ c.pickCatalogModel(item.auxiliaryData!.row);
+ } else {
+ c.clearModel();
+ }
+ }}
+ placeholder={copy.catalogPickerPlaceholder}
+ emptySearchResultsText={copy.catalogEmptyResults}
+ startIcon={ }
+ minQueryLength={1}
+ debounceMs={0}
+ maxMenuItems={12}
+ hasClear
+ width="100%"
+ status={fieldStatus(errorMessage(validation.errors.modelKey))}
+ />
+ {picked ? (
+ {copy.builtinPrefillHint}
+ ) : null}
+
+ {
+ c.clearModel('manual');
+ }}
+ />
+
+
+ ) : (
+ // Manual fallback: paste the exact runtime lookup key.
+
+ c.setField('modelKey', value)}
+ label={copy.modelKeyLabel}
+ placeholder={copy.modelKeyPlaceholder}
+ description={copy.keyHelp}
+ isRequired
+ hasAutoFocus
+ width="100%"
+ status={fieldStatus(errorMessage(validation.errors.modelKey))}
+ />
+
+ {
+ // Clear the manually-typed key and its rates so catalog
+ // mode cannot save values hidden behind the picker.
+ c.clearModel();
+ }}
+ />
+
+
+ )}
+ c.setField('input', value)}
+ label={copy.inputLabel}
+ description={copy.rateHelp}
+ min={0}
+ step={0.01}
+ hasClear
+ isRequired
+ width="100%"
+ status={fieldStatus(errorMessage(validation.errors.input))}
+ />
+ c.setField('output', value)}
+ label={copy.outputLabel}
+ min={0}
+ step={0.01}
+ hasClear
+ isRequired
+ width="100%"
+ status={fieldStatus(errorMessage(validation.errors.output))}
+ />
+
+
+ c.setField('cacheRead', value)}
+ label={copy.cacheReadLabel}
+ description={copy.cacheHelp}
+ min={0}
+ step={0.01}
+ hasClear
+ isOptional
+ width="100%"
+ status={fieldStatus(errorMessage(validation.errors.cacheRead))}
+ />
+ c.setField('cacheWrite', value)}
+ label={copy.cacheWriteLabel}
+ min={0}
+ step={0.01}
+ hasClear
+ isOptional
+ width="100%"
+ status={fieldStatus(errorMessage(validation.errors.cacheWrite))}
+ />
+
+
+ void c.reload()}
+ refreshBusy={c.loading}
+ />
+ {c.needsReview ? (
+
+ }
+ />
+ ) : null}
+
+
+ }
+ footer={
+
+
+
+
+
+
+ }
+ />
+
+ );
+}
+
+/** Keep focus in an open dialog when a pending action disables its button or
+ * review removes the focused control. Preserve any focus the user moved. */
+function usePricingDialogFocus() {
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ const dialog = ref.current;
+ if (dialog?.open && dialog.ownerDocument.activeElement === dialog.ownerDocument.body) {
+ dialog.focus();
+ }
+ });
+ return ref;
+}
+
+function PricingWriteNotice(props: {
+ writeState: ReturnType['writeState'];
+ latestEntry: EffectivePricingEntry | null;
+ copy: PricingSettingsCopy;
+ onRefresh(): void;
+ refreshBusy: boolean;
+}) {
+ const { writeState, latestEntry, copy } = props;
+ switch (writeState.kind) {
+ case 'conflict': {
+ // An `outcome_unknown` conflict is uncertain, not a confirmed external
+ // change — it must not be described as one.
+ const uncertain = writeState.reason === 'outcome_unknown';
+ const latest = latestEntry
+ ? ` ${copy.conflictLatest(
+ pricingSourceLabel(latestEntry, copy),
+ formatUsd(latestEntry.pricing.inputUsdPer1M),
+ formatUsd(latestEntry.pricing.outputUsdPer1M),
+ formatCache(latestEntry.pricing.cacheReadUsdPer1M, copy),
+ formatCache(latestEntry.pricing.cacheWriteUsdPer1M, copy),
+ )}`
+ : '';
+ return (
+
+ );
+ }
+ case 'refresh_failed':
+ return (
+ }
+ />
+ );
+ case 'reconcile_unavailable':
+ return (
+ }
+ />
+ );
+ case 'idle':
+ return null;
+ }
+}
+
+/** Skeleton rows that mirror the real table's column count for a zero-shift load.
+ * Height 16 (a DESIGN.md-allowed bar height) and a small row count matching the
+ * overrides surface's typical ready state (a handful of custom rows). */
+function pricingSkeletonRows(columnCount: number): Array> {
+ return Array.from({ length: 3 }, () =>
+ Array.from({ length: columnCount }, (_unused, column) => (
+
+ )),
+ );
+}
+
+function pricingSourceLabel(row: EffectivePricingEntry, copy: PricingSettingsCopy): string {
+ if (row.source === 'builtin') return copy.sourceBuiltin;
+ return row.resetEffect === 'restore_builtin' ? copy.sourceCustomFallback : copy.sourceCustomOnly;
+}
+
+// Display formatting must round-trip the canonical value without losing
+// precision, and a positive rate must never render as `$0` (#2015). Raw
+// interpolation uses JS shortest-round-trip `Number.toString`, so `2.5` stays
+// `$2.5` and `0.075` stays `$0.075` — never `.toFixed`-collapsed to `$0`.
+export function formatUsd(value: number): string {
+ return `$${value}`;
+}
+
+// An omitted cache rate ("not set", no cache charge) stays distinct from an
+// explicit `0` (#2015): only `undefined` maps to the not-set copy.
+export function formatCache(value: number | undefined, copy: PricingSettingsCopy): string {
+ return value === undefined ? copy.cacheNotSet : `$${value}`;
+}
diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
index 61df3dcdd8..9f83d1dab4 100644
--- a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
+++ b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
@@ -31,6 +31,7 @@ import type { UsageRange, UsageSettings, UsageStats } from '@maka/core/settings'
import { estimatedUsageCost, hasUnavailableUsage } from '@maka/core/usage-ledger-merge';
import { Button, TextInput, Selector, Switch, useToast, useUiLocale, Banner } from '@maka/ui';
import { ICON_SIZE, Activity, BarChart3, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons';
+import { PricingEditor } from './pricing-editor.js';
import {
getUsageSettingsCopy,
type UsageSettingsCopy,
@@ -40,6 +41,7 @@ import { UsageStatsTable } from './usage-stats-table.js';
import { useActionGuard } from '../controller/action-guard.js';
import { useOptimisticSettingsDraft } from '../controller/optimistic-settings-draft.js';
import { useUsageServices, useUsageStats } from '../services-context.js';
+import type { UsageHostRef } from '../ports.js';
type UsageActiveTab = UsageSettings['activeTab'];
@@ -55,6 +57,8 @@ type UsageActiveTab = UsageSettings['activeTab'];
export function UsageSettingsView(props: {
settings: UsageSettings;
describeError(error: unknown): string;
+ /** Settings-selected Runtime Host, threaded to the Pricing tab (per-Host overrides). */
+ runtimeHost: UsageHostRef | undefined;
onOpenSession?(sessionId: string): void;
}) {
const services = useUsageServices();
@@ -67,7 +71,7 @@ export function UsageSettingsView(props: {
// switch. `stats` is non-null only when the scope's snapshot was loaded for the
// persisted range — during a range switch (or after a late/failed load) the
// panels read `null` (loading/empty) rather than the previous range's numbers.
- const { stats, reload, targetKey } = useUsageStats(persistedUsage.range);
+ const { stats, reload, targetKey, isCurrentTarget } = useUsageStats(persistedUsage.range);
const [refreshing, setRefreshing] = useState(false);
const usageRefreshGuard = useActionGuard<'refresh'>();
const {
@@ -108,12 +112,11 @@ export function UsageSettingsView(props: {
);
}, [stats, usageDraft.status, normalizedModelFilter]);
- const tabCounts: Record = {
+ const tabCounts: Record, number> = {
requests: stats?.logs.length ?? 0,
providers: stats?.byProvider.length ?? 0,
models: stats?.byModel.length ?? 0,
tools: stats?.byTool.length ?? 0,
- pricing: stats?.pricing.length ?? 0,
};
function updateUsage(patch: Partial): Promise {
@@ -152,7 +155,7 @@ export function UsageSettingsView(props: {
return (
<>
- {usageIncomplete ? (
+ {usageIncomplete && usageDraft.activeTab !== 'pricing' ? (
) : null}
-
-
- void setRange(value as UsageRange)}
- >
- {(['24h', '7d', '30d', 'all'] as const).map((value, index) => (
-
- ))}
-
- void refresh()}
- icon={ }
- />
-
+ {/* #2015 acceptance #2: the Pricing tab is not time-scoped, so the Usage
+ range/summary toolbar is hidden there — the date range cannot be
+ mistaken for a Pricing scope. */}
+ {usageDraft.activeTab !== 'pricing' ? (
+
+
+ void setRange(value as UsageRange)}
+ >
+ {(['24h', '7d', '30d', 'all'] as const).map((value, index) => (
+
+ ))}
+
+ void refresh()}
+ icon={ }
+ />
+
-
-
+ ) : null}
@@ -203,7 +211,7 @@ export function UsageSettingsView(props: {
{tabCounts.providers}} />
{tabCounts.models}} />
{tabCounts.tools}} />
- {tabCounts.pricing}} />
+
@@ -249,7 +257,12 @@ export function UsageSettingsView(props: {
{usageDraft.activeTab === 'pricing' ? (
) : null}
@@ -420,22 +433,6 @@ function UsageToolsPanel(props: { stats: UsageStats | null; copy: UsageSettingsC
);
}
-function UsagePricingPanel(props: { stats: UsageStats | null; copy: UsageSettingsCopy }) {
- return (
-
[row.provider, row.model, `$${row.inputPerMTokUsd}`, `$${row.outputPerMTokUsd}`])}
- empty={{ Icon: BarChart3, title: props.copy.tables.noPricing, body: props.copy.tables.pricingEmptyBody }}
- />
- );
-}
-
// ── Request-log cell helpers ────────────────────────────────────────────────
function usageRequestKindLabel(kind: UsageStats['logs'][number]['kind'], copy: UsageSettingsCopy) {
diff --git a/apps/desktop/src/renderer/locales/settings-pricing-copy.ts b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts
new file mode 100644
index 0000000000..421f3ede1f
--- /dev/null
+++ b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts
@@ -0,0 +1,355 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { UiCatalog, UiLocale } from '@maka/core/ui-locale';
+
+/** User-visible copy for the editable Pricing settings surface. */
+export type PricingSettingsCopy = {
+ title: string;
+ subtitle: string;
+ refresh: string;
+ add: string;
+ addNeedsSnapshot: string;
+ loading: string;
+ loadFailedTitle: string;
+ loadFailedBody: string;
+ retry: string;
+ emptyTitle: string;
+ emptyBody: string;
+ tableAria: string;
+ // catalog picker (Add flow)
+ catalogPickerLabel: string;
+ catalogPickerPlaceholder: string;
+ catalogEmptyResults: string;
+ manualEntryToggle: string;
+ catalogToggle: string;
+ builtinPrefillHint: string;
+ headers: readonly [string, string, string, string, string, string];
+ actionsHeader: string;
+ sourceBuiltin: string;
+ sourceCustomFallback: string;
+ sourceCustomOnly: string;
+ cacheNotSet: string;
+ edit: string;
+ reset: string;
+ delete: string;
+ editAria(modelKey: string): string;
+ resetAria(modelKey: string): string;
+ deleteAria(modelKey: string): string;
+ // editor
+ addTitle: string;
+ editTitle: string;
+ modelKeyLabel: string;
+ modelKeyPlaceholder: string;
+ keyHelp: string;
+ inputLabel: string;
+ outputLabel: string;
+ rateHelp: string;
+ cacheSection: string;
+ cacheReadLabel: string;
+ cacheWriteLabel: string;
+ cacheHelp: string;
+ cancel: string;
+ save: string;
+ // field errors
+ errorRequired: string;
+ errorInvalidRate: string;
+ errorKeyTooLong: string;
+ errorDuplicate: string;
+ // outcomes
+ saved: string;
+ synchronized: string;
+ conflictTitle: string;
+ conflictTitleUnknown: string;
+ conflictBody: string;
+ conflictBodyUnknown: string;
+ conflictLatest(
+ source: string,
+ input: string,
+ output: string,
+ cacheRead: string,
+ cacheWrite: string,
+ ): string;
+ reviewSave: string;
+ hostChangedTitle: string;
+ hostChangedBody: string;
+ reviewHostChange: string;
+ refreshFailedTitle: string;
+ refreshFailedBody: string;
+ reconcileTitle: string;
+ reconcileBody: string;
+ writeBlockedReason: string;
+ saveFailed: string;
+ // reset / delete confirm
+ resetTitle: string;
+ resetBody(modelKey: string): string;
+ deleteTitle: string;
+ deleteBody(modelKey: string): string;
+ confirmReset: string;
+ confirmDelete: string;
+ reviewReset: string;
+ reviewDelete: string;
+ resetFailed: string;
+};
+
+const SETTINGS_PRICING_COPY = {
+ 'zh-CN': {
+ title: '定价配置',
+ subtitle:
+ '美元 / 每百万 token。用于新激活的模型调用;进行中的运行沿用其开始时的价格。历史费用不会重算,最终以供应商结算为准。',
+ refresh: '刷新',
+ add: '添加定价',
+ addNeedsSnapshot: '需先加载定价后才能添加。',
+ loading: '正在加载定价…',
+ loadFailedTitle: '无法加载定价',
+ loadFailedBody: '读取运行时主机的定价快照失败,请重试。',
+ retry: '重试',
+ emptyTitle: '暂无自定义定价',
+ emptyBody: '尚未覆盖任何模型价格。点击「添加定价」,从内置目录中选择一个模型。',
+ tableAria: '自定义模型定价表',
+ catalogPickerLabel: '选择模型',
+ catalogPickerPlaceholder: '搜索模型名…',
+ catalogEmptyResults: '无匹配的内置模型',
+ manualEntryToggle: '模型不在列表中?手动输入',
+ catalogToggle: '从目录选择',
+ builtinPrefillHint: '已按内置价预填,可按需修改。',
+ headers: ['模型', '来源', '输入 / 1M', '输出 / 1M', '缓存读 / 1M', '缓存写 / 1M'],
+ actionsHeader: '操作',
+ sourceBuiltin: '内置',
+ sourceCustomFallback: '自定义 · 可回退',
+ sourceCustomOnly: '仅自定义',
+ cacheNotSet: '未设置(Maka 估算不计缓存费用)',
+ edit: '编辑',
+ reset: '重置',
+ delete: '删除',
+ editAria: (modelKey: string) => `编辑「${modelKey}」定价`,
+ resetAria: (modelKey: string) => `重置「${modelKey}」定价`,
+ deleteAria: (modelKey: string) => `删除「${modelKey}」定价`,
+ addTitle: '添加定价',
+ editTitle: '编辑定价',
+ modelKeyLabel: '模型键',
+ modelKeyPlaceholder: '例如 anthropic:claude-sonnet-4-5',
+ keyHelp: '粘贴用量记录中的精确运行时查找键(区分大小写,不要用连接别名)。',
+ inputLabel: '输入价格',
+ outputLabel: '输出价格',
+ rateHelp: '美元 / 每百万 token;0 表示免费(如本地模型)。',
+ cacheSection: '缓存价格(可选)',
+ cacheReadLabel: '缓存读取',
+ cacheWriteLabel: '缓存写入',
+ cacheHelp: '留空表示未设置(不计缓存费用),与显式填 0 不同。',
+ cancel: '取消',
+ save: '保存',
+ errorRequired: '必填',
+ errorInvalidRate: '请输入有效价格(≥ 0)',
+ errorKeyTooLong: '模型键过长(上限 128 字符)',
+ errorDuplicate: '该模型已在列表中,请直接编辑对应行',
+ saved: '定价已保存',
+ synchronized: '当前定价已与你的修改一致',
+ conflictTitle: '定价已被其他修改更新',
+ conflictTitleUnknown: '无法确认上次修改的结果',
+ conflictBody: '该模型的价格已被其他修改更新。请核对最新值后,基于最新版本再次保存。',
+ conflictBodyUnknown: '上次修改可能已生效、也可能未生效。请核对最新值后,再决定是否基于最新版本重新保存。',
+ conflictLatest: (source, input, output, cacheRead, cacheWrite) =>
+ `当前最新:${source};输入 ${input} / 输出 ${output} / 缓存读 ${cacheRead} / 缓存写 ${cacheWrite}`,
+ reviewSave: '核对并保存',
+ hostChangedTitle: '运行时主机已变化',
+ hostChangedBody: '草稿已保留。请等待新主机的定价加载完成,核对后再继续保存。',
+ reviewHostChange: '已核对新主机定价',
+ refreshFailedTitle: '已保存,但无法加载最新定价',
+ refreshFailedBody: '保存已完成,但未能读取最新定价。请刷新后再进行修改。',
+ reconcileTitle: '无法确认结果',
+ reconcileBody: '未能确认这次修改的结果。请刷新定价后再进行修改。',
+ writeBlockedReason: '需先刷新最新定价后才能修改。',
+ saveFailed: '保存定价失败',
+ resetTitle: '重置定价',
+ resetBody: (modelKey: string) => `将删除「${modelKey}」的自定义价格,恢复为内置定价。`,
+ deleteTitle: '删除定价',
+ deleteBody: (modelKey: string) =>
+ `将删除「${modelKey}」的定价;新激活的调用将变为未定价(不计入 Maka 的费用估算,与显式填 0 不同),进行中的运行沿用其开始时的快照。`,
+ confirmReset: '重置',
+ confirmDelete: '删除',
+ reviewReset: '核对并重置',
+ reviewDelete: '核对并删除',
+ resetFailed: '操作失败',
+ },
+ 'zh-TW': {
+ title: '定價設定',
+ subtitle:
+ '美元 / 每百萬 token。用於新啟用的模型呼叫;進行中的執行沿用其開始時的價格。歷史費用不會重算,最終以供應商結算為準。',
+ refresh: '重新整理',
+ add: '新增定價',
+ addNeedsSnapshot: '需先載入定價後才能新增。',
+ loading: '正在載入定價…',
+ loadFailedTitle: '無法載入定價',
+ loadFailedBody: '讀取執行時主機的定價快照失敗,請重試。',
+ retry: '重試',
+ emptyTitle: '暫無自訂定價',
+ emptyBody: '尚未覆寫任何模型價格。點選「新增定價」,從內建目錄中選擇一個模型。',
+ tableAria: '自訂模型定價表',
+ catalogPickerLabel: '選擇模型',
+ catalogPickerPlaceholder: '搜尋模型名稱…',
+ catalogEmptyResults: '無相符的內建模型',
+ manualEntryToggle: '模型不在清單中?手動輸入',
+ catalogToggle: '從目錄選擇',
+ builtinPrefillHint: '已依內建價格預填,可視需要修改。',
+ headers: ['模型', '來源', '輸入 / 1M', '輸出 / 1M', '快取讀 / 1M', '快取寫 / 1M'],
+ actionsHeader: '操作',
+ sourceBuiltin: '內建',
+ sourceCustomFallback: '自訂 · 可回退',
+ sourceCustomOnly: '僅自訂',
+ cacheNotSet: '未設定(Maka 估算不計快取費用)',
+ edit: '編輯',
+ reset: '重設',
+ delete: '刪除',
+ editAria: (modelKey: string) => `編輯「${modelKey}」定價`,
+ resetAria: (modelKey: string) => `重設「${modelKey}」定價`,
+ deleteAria: (modelKey: string) => `刪除「${modelKey}」定價`,
+ addTitle: '新增定價',
+ editTitle: '編輯定價',
+ modelKeyLabel: '模型鍵',
+ modelKeyPlaceholder: '例如 anthropic:claude-sonnet-4-5',
+ keyHelp: '貼上用量記錄中的精確執行時查找鍵(區分大小寫,請勿使用連線別名)。',
+ inputLabel: '輸入價格',
+ outputLabel: '輸出價格',
+ rateHelp: '美元 / 每百萬 token;0 表示免費(如本機模型)。',
+ cacheSection: '快取價格(選填)',
+ cacheReadLabel: '快取讀取',
+ cacheWriteLabel: '快取寫入',
+ cacheHelp: '留空表示未設定(不計快取費用),與明確填入 0 不同。',
+ cancel: '取消',
+ save: '儲存',
+ errorRequired: '必填',
+ errorInvalidRate: '請輸入有效價格(≥ 0)',
+ errorKeyTooLong: '模型鍵過長(上限 128 個字元)',
+ errorDuplicate: '該模型已在清單中,請直接編輯對應的項目',
+ saved: '定價已儲存',
+ synchronized: '目前定價已與你的修改一致',
+ conflictTitle: '定價已被其他修改更新',
+ conflictTitleUnknown: '無法確認上次修改的結果',
+ conflictBody: '該模型的價格已被其他修改更新。請核對最新值後,基於最新版本再次儲存。',
+ conflictBodyUnknown: '上次修改可能已生效,也可能未生效。請核對最新值後,再決定是否基於最新版本重新儲存。',
+ conflictLatest: (source, input, output, cacheRead, cacheWrite) =>
+ `目前最新:${source};輸入 ${input} / 輸出 ${output} / 快取讀 ${cacheRead} / 快取寫 ${cacheWrite}`,
+ reviewSave: '核對並儲存',
+ hostChangedTitle: '執行時主機已變更',
+ hostChangedBody: '草稿已保留。請等待新主機的定價載入完成,核對後再繼續儲存。',
+ reviewHostChange: '已核對新主機定價',
+ refreshFailedTitle: '已儲存,但無法載入最新定價',
+ refreshFailedBody: '儲存已完成,但無法讀取最新定價。請重新整理後再進行修改。',
+ reconcileTitle: '無法確認結果',
+ reconcileBody: '無法確認這次修改的結果。請重新整理定價後再進行修改。',
+ writeBlockedReason: '需先重新整理最新定價後才能修改。',
+ saveFailed: '儲存定價失敗',
+ resetTitle: '重設定價',
+ resetBody: (modelKey: string) => `將刪除「${modelKey}」的自訂價格,還原為內建定價。`,
+ deleteTitle: '刪除定價',
+ deleteBody: (modelKey: string) =>
+ `將刪除「${modelKey}」的定價;新啟用的呼叫將變為未定價(不計入 Maka 的費用估算,與明確填入 0 不同),進行中的執行沿用其開始時的快照。`,
+ confirmReset: '重設',
+ confirmDelete: '刪除',
+ reviewReset: '核對並重設',
+ reviewDelete: '核對並刪除',
+ resetFailed: '操作失敗',
+ },
+ en: {
+ title: 'Pricing',
+ subtitle:
+ 'USD per 1M tokens. Applies to newly activated model work; an active run keeps its starting prices. Historical costs are not recalculated. Provider billing is authoritative.',
+ refresh: 'Refresh',
+ add: 'Add price',
+ addNeedsSnapshot: 'Pricing must load before you can add an override.',
+ loading: 'Loading pricing…',
+ loadFailedTitle: 'Could not load pricing',
+ loadFailedBody: 'Reading the Runtime Host pricing snapshot failed. Try again.',
+ retry: 'Retry',
+ emptyTitle: 'No custom pricing',
+ emptyBody:
+ 'You haven’t overridden any model prices yet. Click "Add price" and pick a model from the built-in catalog.',
+ tableAria: 'Custom model pricing table',
+ catalogPickerLabel: 'Select model',
+ catalogPickerPlaceholder: 'Search models…',
+ catalogEmptyResults: 'No matching built-in models',
+ manualEntryToggle: 'Model not listed? Enter it manually',
+ catalogToggle: 'Choose from catalog',
+ builtinPrefillHint: 'Pre-filled with the built-in price; adjust as needed.',
+ headers: ['Model', 'Source', 'Input / 1M', 'Output / 1M', 'Cache read / 1M', 'Cache write / 1M'],
+ actionsHeader: 'Actions',
+ sourceBuiltin: 'Built-in',
+ sourceCustomFallback: 'Custom · has fallback',
+ sourceCustomOnly: 'Custom-only',
+ cacheNotSet: 'Not set (no cache charge in Maka estimates)',
+ edit: 'Edit',
+ reset: 'Reset',
+ delete: 'Delete',
+ editAria: (modelKey: string) => `Edit pricing for ${modelKey}`,
+ resetAria: (modelKey: string) => `Reset pricing for ${modelKey}`,
+ deleteAria: (modelKey: string) => `Delete pricing for ${modelKey}`,
+ addTitle: 'Add price',
+ editTitle: 'Edit price',
+ modelKeyLabel: 'Model key',
+ modelKeyPlaceholder: 'e.g. anthropic:claude-sonnet-4-5',
+ keyHelp:
+ 'Paste the exact Runtime lookup key from your usage records (case-sensitive; not the connection slug).',
+ inputLabel: 'Input price',
+ outputLabel: 'Output price',
+ rateHelp: 'USD per 1M tokens; 0 means free (e.g. local models).',
+ cacheSection: 'Cache prices (optional)',
+ cacheReadLabel: 'Cache read',
+ cacheWriteLabel: 'Cache write',
+ cacheHelp: 'Leave blank for "Not set" (no cache charge) — distinct from an explicit 0.',
+ cancel: 'Cancel',
+ save: 'Save',
+ errorRequired: 'Required',
+ errorInvalidRate: 'Enter a valid price (≥ 0)',
+ errorKeyTooLong: 'Model key is too long (128 characters max)',
+ errorDuplicate: 'This model is already listed — edit its row instead',
+ saved: 'Pricing saved',
+ synchronized: 'Pricing already matches your change',
+ conflictTitle: 'Pricing changed elsewhere',
+ conflictTitleUnknown: "Couldn't confirm the last change",
+ conflictBody: "This model's price was changed elsewhere. Review the latest value, then save again against the latest revision.",
+ conflictBodyUnknown: 'The last change may or may not have applied. Review the latest value, then decide whether to save again against the latest revision.',
+ conflictLatest: (source, input, output, cacheRead, cacheWrite) =>
+ `Latest: ${source}; input ${input} / output ${output} / cache read ${cacheRead} / cache write ${cacheWrite}`,
+ reviewSave: 'Review & save',
+ hostChangedTitle: 'Runtime Host changed',
+ hostChangedBody: 'Your draft was preserved. Wait for pricing from the new Host, then review it before saving.',
+ reviewHostChange: 'Reviewed new Host pricing',
+ refreshFailedTitle: 'Saved, but the latest pricing could not be loaded',
+ refreshFailedBody: 'The save completed but the latest prices could not be loaded. Refresh before changing pricing again.',
+ reconcileTitle: "Couldn't confirm the result",
+ reconcileBody: "The result of this change could not be confirmed. Reload pricing before changing it again.",
+ writeBlockedReason: 'Refresh the latest pricing before making changes.',
+ saveFailed: 'Failed to save pricing',
+ resetTitle: 'Reset pricing',
+ resetBody: (modelKey: string) => `This removes the custom price for ${modelKey} and restores its built-in pricing.`,
+ deleteTitle: 'Delete pricing',
+ deleteBody: (modelKey: string) =>
+ `This deletes pricing for ${modelKey}; newly activated work becomes unpriced (excluded from Maka's cost estimates — distinct from an explicit $0), while an active run keeps its starting snapshot.`,
+ confirmReset: 'Reset',
+ confirmDelete: 'Delete',
+ reviewReset: 'Review & reset',
+ reviewDelete: 'Review & delete',
+ resetFailed: 'Action failed',
+ },
+} satisfies UiCatalog;
+
+export function getPricingSettingsCopy(locale: UiLocale): PricingSettingsCopy {
+ return SETTINGS_PRICING_COPY[locale];
+}
diff --git a/apps/desktop/src/renderer/locales/settings-usage-copy.ts b/apps/desktop/src/renderer/locales/settings-usage-copy.ts
index 75cf3752ad..d2486e041c 100644
--- a/apps/desktop/src/renderer/locales/settings-usage-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-usage-copy.ts
@@ -28,11 +28,11 @@ export type UsageSettingsCopy = {
summaryOnly: string; showDetails: string; filteredEmpty: string; filteredEmptyHelp: string; requestEmpty: string;
costUnavailable: string; incompleteTitle: string; incompleteBody: string;
tables: {
- providersAria: string; modelsAria: string; toolsAria: string; pricingAria: string; requestsAria: string;
- providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; pricingHeaders: string[]; requestHeaders: string[];
- noPricing: string; modelKind: string; toolKind: string; unknown: string; untitledSession: string; openSession(label: string): string; success: string; error: string; aborted: string;
+ providersAria: string; modelsAria: string; toolsAria: string; requestsAria: string;
+ providerHeaders: string[]; modelHeaders: string[]; toolHeaders: string[]; requestHeaders: string[];
+ modelKind: string; toolKind: string; unknown: string; untitledSession: string; openSession(label: string): string; success: string; error: string; aborted: string;
providerEmptyTitle: string; providerEmptyBody: string; modelEmptyTitle: string; modelEmptyBody: string;
- toolEmptyTitle: string; toolEmptyBody: string; pricingEmptyBody: string;
+ toolEmptyTitle: string; toolEmptyBody: string;
};
};
@@ -49,14 +49,13 @@ const SETTINGS_USAGE_COPY = {
costUnavailable: '费用未知', incompleteTitle: '统计可能不完整',
incompleteBody: '部分记录未能读取、尚未纳入统计或超出展示上限,实际用量可能高于此处显示。',
tables: {
- providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', pricingAria: '使用统计定价配置表', requestsAria: '使用统计活动记录表',
+ providersAria: '使用统计供应商统计表', modelsAria: '使用统计模型统计表', toolsAria: '使用统计工具统计表', requestsAria: '使用统计活动记录表',
providerHeaders: ['供应商', '调用', 'Token', '费用'], modelHeaders: ['模型', '调用', 'Token', '费用'], toolHeaders: ['工具', '调用', '成功', '错误', '平均耗时'],
- pricingHeaders: ['供应商', '模型', '输入 / 1M', '输出 / 1M'], requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'],
- noPricing: '暂无定价覆盖配置', modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', openSession: (label) => `打开会话「${label}」`, success: '成功', error: '错误', aborted: '已中止',
+ requestHeaders: ['时间', '类型', '对象', '任务', 'Token', '费用', '延迟', '状态'],
+ modelKind: '模型', toolKind: '工具', unknown: '未知', untitledSession: '未命名会话', openSession: (label) => `打开会话「${label}」`, success: '成功', error: '错误', aborted: '已中止',
providerEmptyTitle: '暂无供应商用量', providerEmptyBody: '完成一次模型调用后,这里会按供应商聚合调用数、Token 与费用。',
modelEmptyTitle: '暂无模型用量', modelEmptyBody: '完成一次模型调用后,这里会按模型聚合调用数、Token 与费用。',
toolEmptyTitle: '暂无工具调用', toolEmptyBody: '智能体调用工具后,这里会按工具聚合调用次数、成功、错误与平均耗时。',
- pricingEmptyBody: '未配置定价覆盖时,费用按内置模型定价表结算;在此可为特定模型登记自定义价格。',
},
},
'zh-TW': {
@@ -71,14 +70,13 @@ const SETTINGS_USAGE_COPY = {
incompleteBody: '部分記錄可能無法讀取、尚未納入統計或超出顯示上限,實際用量可能高於此處顯示。',
showDetails: '顯示明細', filteredEmpty: '沒有符合篩選條件的請求記錄', filteredEmptyHelp: '調整或清除篩選條件後可檢視全部請求記錄。', requestEmpty: '暫無請求記錄',
tables: {
- providersAria: '使用統計供應商統計表', modelsAria: '使用統計模型統計表', toolsAria: '使用統計工具統計表', pricingAria: '使用統計定價設定表', requestsAria: '使用統計請求記錄表',
+ providersAria: '使用統計供應商統計表', modelsAria: '使用統計模型統計表', toolsAria: '使用統計工具統計表', requestsAria: '使用統計請求記錄表',
providerHeaders: ['供應商', '請求', 'Token', '費用'], modelHeaders: ['模型', '請求', 'Token', '費用'], toolHeaders: ['工具', '呼叫', '成功', '錯誤', '平均耗時'],
- pricingHeaders: ['供應商', '模型', '輸入 / 1M', '輸出 / 1M'], requestHeaders: ['時間', '型別', '物件', '任務', 'Token', '費用', '延遲', '狀態'],
- noPricing: '暫無定價覆蓋設定', modelKind: '模型', toolKind: '工具', unknown: '未知', openSession: (label) => `開啟 ${label}`, untitledSession: '未命名會話', success: '成功', error: '錯誤', aborted: '已中止',
+ requestHeaders: ['時間', '型別', '物件', '任務', 'Token', '費用', '延遲', '狀態'],
+ modelKind: '模型', toolKind: '工具', unknown: '未知', openSession: (label) => `開啟 ${label}`, untitledSession: '未命名會話', success: '成功', error: '錯誤', aborted: '已中止',
providerEmptyTitle: '暫無供應商用量', providerEmptyBody: '完成一次模型請求後,這裡會按供應商聚合請求數、Token 與費用。',
modelEmptyTitle: '暫無模型用量', modelEmptyBody: '完成一次模型請求後,這裡會按模型聚合請求數、Token 與費用。',
toolEmptyTitle: '暫無工具呼叫', toolEmptyBody: '智慧體呼叫工具後,這裡會按工具聚合呼叫次數、成功、錯誤與平均耗時。',
- pricingEmptyBody: '未設定定價覆蓋時,費用按內建模型定價表結算;在此可為特定模型登記自訂價格。',
},
},
en: {
@@ -93,14 +91,13 @@ const SETTINGS_USAGE_COPY = {
costUnavailable: 'Cost unavailable', incompleteTitle: 'These numbers may be incomplete',
incompleteBody: 'Some records could not be read, are not folded in yet, or exceed the display limit, so real usage may be higher than shown.',
tables: {
- providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', pricingAria: 'Usage pricing configuration', requestsAria: 'Usage activity log',
+ providersAria: 'Usage by provider', modelsAria: 'Usage by model', toolsAria: 'Usage by tool', requestsAria: 'Usage activity log',
providerHeaders: ['Provider', 'Calls', 'Tokens', 'Cost'], modelHeaders: ['Model', 'Calls', 'Tokens', 'Cost'], toolHeaders: ['Tool', 'Calls', 'Success', 'Errors', 'Average duration'],
- pricingHeaders: ['Provider', 'Model', 'Input / 1M', 'Output / 1M'], requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'],
- noPricing: 'No pricing overrides', modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', untitledSession: 'Untitled session', openSession: (label) => `Open session "${label}"`, success: 'Success', error: 'Error', aborted: 'Aborted',
+ requestHeaders: ['Time', 'Type', 'Target', 'Task', 'Tokens', 'Cost', 'Latency', 'Status'],
+ modelKind: 'Model', toolKind: 'Tool', unknown: 'Unknown', untitledSession: 'Untitled session', openSession: (label) => `Open session "${label}"`, success: 'Success', error: 'Error', aborted: 'Aborted',
providerEmptyTitle: 'No provider usage', providerEmptyBody: 'After a model call, provider call counts, tokens, and costs appear here.',
modelEmptyTitle: 'No model usage', modelEmptyBody: 'After a model call, call counts, tokens, and costs appear here by model.',
toolEmptyTitle: 'No tool calls', toolEmptyBody: 'After an agent calls a tool, calls, successes, errors, and average duration appear here by tool.',
- pricingEmptyBody: 'Without pricing overrides, costs use the built-in model pricing table. Add custom prices here for specific models.',
},
},
} satisfies UiCatalog;
diff --git a/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts b/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts
new file mode 100644
index 0000000000..5cd6e67f22
--- /dev/null
+++ b/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { MakaBridge } from '../../../preload/bridge-contract.js';
+import type { UsagePricingServices } from '../../features/usage';
+
+export type DesktopUsagePricingBridge = Pick;
+
+// Desktop adapter for the editable Pricing surface (#2015). It owns the only
+// `window.maka.settings.pricing` bridge access, keeping it in the platform zone
+// so no new bridge path lands in a frozen legacy-closure file. The `host` is the
+// settings-selected Runtime Host threaded from the feature, so pricing reads and
+// writes target the same Host as the rest of the settings page. The pricing
+// bridge requires that Host explicitly, so this path cannot fall back to the
+// app's active Host.
+export function createDesktopUsagePricingServices(
+ bridge: DesktopUsagePricingBridge = window.maka,
+): UsagePricingServices {
+ return {
+ loadPricing: (host) => bridge.settings.pricing.load(host),
+ mutatePricing: (host, base, mutation) => bridge.settings.pricing.mutate(base, mutation, host),
+ };
+}
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index 7cc39da9aa..d517caf04b 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -1131,7 +1131,7 @@ function SettingsPageBody(props: {
case 'usage':
// State lives in the persistent `UsageScopeMount` above the loading gate;
// this view is disposable and reads it from context.
- return ;
+ return ;
case 'bot-chat':
return (
settingsActionErrorMessage(error, locale)}
+ runtimeHost={props.runtimeHost}
onOpenSession={props.onOpenSession}
/>
diff --git a/apps/desktop/src/renderer/styles/settings/usage.css b/apps/desktop/src/renderer/styles/settings/usage.css
index a4979eff6a..a19b2c7cbc 100644
--- a/apps/desktop/src/renderer/styles/settings/usage.css
+++ b/apps/desktop/src/renderer/styles/settings/usage.css
@@ -95,6 +95,29 @@
min-width: 0;
}
+/* Pricing panel: a vertical stack (header, optional write notice, table). */
+.settingsPricing {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ min-width: 0;
+}
+
+/* Header row: heading/subtitle on the left, the Add control on the right. */
+.settingsPricingHeader {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.settingsPricingHeading {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ min-width: 0;
+}
+
/* 任务 column: the session-name link truncates inside its fixed column width and
surfaces the full name via the button tooltip. StyleX owns the button's own
layout; these rules only cap its width and ellipsis the label text (descendant
diff --git a/apps/desktop/src/shared/desktop-pricing-decode.ts b/apps/desktop/src/shared/desktop-pricing-decode.ts
new file mode 100644
index 0000000000..8c05daeb3a
--- /dev/null
+++ b/apps/desktop/src/shared/desktop-pricing-decode.ts
@@ -0,0 +1,109 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Runtime guard for the Pricing Settings types declared in `desktop-pricing.d.ts`.
+// It is imported only by the Main IPC layer (never the renderer), so it stays a
+// plain `.ts` outside the renderer-root/legacy-AppShell closure the architecture
+// ratchet tracks — the types themselves are declaration-only for that reason.
+
+import {
+ comparePricingModelKeys,
+ validateCanonicalPricingConfig,
+} from '@maka/core/usage-stats/pricing';
+import type { EffectivePricingEntry } from '@maka/runtime-host/protocol';
+import type { DesktopPricingSnapshot } from './desktop-pricing.js';
+
+export class DesktopPricingSnapshotDecodeError extends Error {
+ constructor(message: string) {
+ super(`Invalid pricing snapshot: ${message}`);
+ this.name = 'DesktopPricingSnapshotDecodeError';
+ }
+}
+
+/**
+ * Validate a renderer-supplied `base` snapshot at the Main IPC boundary. The
+ * renderer must not synthesize `revision`/`hostEpoch`/`connectionId`; this only
+ * proves the shape it round-trips is well formed. A well-formed but *foreign*
+ * base (wrong Host epoch/connection) is still rejected downstream by the
+ * adapter's stale guard, and a merely stale revision degrades to a
+ * `revision_conflict` — both intended, not errors.
+ */
+export function decodeDesktopPricingSnapshot(value: unknown): DesktopPricingSnapshot {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new DesktopPricingSnapshotDecodeError('snapshot must be an object');
+ }
+ const record = value as Record;
+ if (typeof record.hostEpoch !== 'string' || record.hostEpoch === '') {
+ throw new DesktopPricingSnapshotDecodeError('hostEpoch must be a non-empty string');
+ }
+ if (typeof record.connectionId !== 'string' || record.connectionId === '') {
+ throw new DesktopPricingSnapshotDecodeError('connectionId must be a non-empty string');
+ }
+ if (
+ typeof record.revision !== 'number' ||
+ !Number.isInteger(record.revision) ||
+ record.revision < 0
+ ) {
+ throw new DesktopPricingSnapshotDecodeError('revision must be a non-negative integer');
+ }
+ if (!Array.isArray(record.entries)) {
+ throw new DesktopPricingSnapshotDecodeError('entries must be an array');
+ }
+ const entries = record.entries.map(decodeEffectivePricingEntry);
+ for (let index = 1; index < entries.length; index += 1) {
+ if (
+ comparePricingModelKeys(
+ entries[index - 1]!.pricing.modelKey,
+ entries[index]!.pricing.modelKey,
+ ) !== -1
+ ) {
+ throw new DesktopPricingSnapshotDecodeError('entries must be in canonical key order');
+ }
+ }
+ return {
+ hostEpoch: record.hostEpoch,
+ connectionId: record.connectionId,
+ revision: record.revision,
+ entries,
+ };
+}
+
+function decodeEffectivePricingEntry(value: unknown): EffectivePricingEntry {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new DesktopPricingSnapshotDecodeError('entry must be an object');
+ }
+ const record = value as Record;
+ const pricing = validateCanonicalPricingConfig(record.pricing);
+ if (!pricing.ok) {
+ throw new DesktopPricingSnapshotDecodeError(`entry pricing is invalid (${pricing.error})`);
+ }
+ if (record.source === 'builtin') {
+ return { pricing: pricing.value, source: 'builtin' };
+ }
+ if (record.source === 'custom') {
+ if (
+ record.resetEffect !== 'restore_builtin' &&
+ record.resetEffect !== 'become_unpriced'
+ ) {
+ throw new DesktopPricingSnapshotDecodeError('custom entry has an invalid resetEffect');
+ }
+ return { pricing: pricing.value, source: 'custom', resetEffect: record.resetEffect };
+ }
+ throw new DesktopPricingSnapshotDecodeError('entry source must be "builtin" or "custom"');
+}
diff --git a/apps/desktop/src/shared/desktop-pricing.d.ts b/apps/desktop/src/shared/desktop-pricing.d.ts
new file mode 100644
index 0000000000..d4f6c5333a
--- /dev/null
+++ b/apps/desktop/src/shared/desktop-pricing.d.ts
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Cross-boundary Pricing Settings *types* shared by the Desktop adapter (main),
+// the preload bridge, and the renderer. They live here — not in `main/` — so the
+// renderer and preload can name them without importing the Runtime Host client.
+//
+// This is a declaration-only file (`.d.ts`) on purpose: the preload bridge
+// contract (`bridge-contract.d.ts`) reaches these types from the renderer's
+// import graph, and a declaration file is excluded from the legacy-AppShell /
+// renderer-root transitive closure the architecture ratchet tracks (a runtime
+// `.ts` in `shared/` would be pulled in as new closure debt). The runtime guard
+// that validates a round-tripped snapshot lives beside it in
+// `desktop-pricing-decode.ts`, imported only by the Main IPC layer.
+//
+// The adapter (`runtime-host-client.ts`) is the sole owner of the snapshot's
+// `revision`/`hostEpoch`/`connectionId`; the renderer only ever round-trips a
+// snapshot it loaded back as the CAS `base`.
+
+import type { EffectivePricingEntry, PricingMutation } from '@maka/runtime-host/protocol';
+
+/** One revision-consistent page of effective pricing, stamped to its Host connection. */
+export interface DesktopPricingSnapshot {
+ readonly hostEpoch: string;
+ readonly connectionId: string;
+ readonly revision: number;
+ readonly entries: readonly EffectivePricingEntry[];
+}
+
+export interface DesktopPricingMutationInput {
+ readonly base: DesktopPricingSnapshot;
+ readonly mutation: PricingMutation;
+}
+
+/**
+ * Every terminal state of a pricing mutation. `saved`/`synchronized`/
+ * `review_required` carry a fresh authoritative snapshot; `saved_refresh_failed`
+ * and `reconciliation_unavailable` cannot, so the renderer keeps its draft and
+ * disables further writes until it can reload.
+ */
+export type DesktopPricingMutationOutcome =
+ | {
+ readonly kind: 'saved';
+ readonly disposition: 'committed' | 'unchanged';
+ readonly snapshot: DesktopPricingSnapshot;
+ }
+ | {
+ readonly kind: 'saved_refresh_failed';
+ readonly disposition: 'committed' | 'unchanged';
+ }
+ | {
+ readonly kind: 'synchronized' | 'review_required';
+ readonly reason: 'revision_conflict' | 'outcome_unknown';
+ readonly snapshot: DesktopPricingSnapshot;
+ }
+ | {
+ readonly kind: 'reconciliation_unavailable';
+ readonly reason: 'revision_conflict' | 'outcome_unknown';
+ };
diff --git a/apps/desktop/stories/settings/pricing-editor.stories.tsx b/apps/desktop/stories/settings/pricing-editor.stories.tsx
new file mode 100644
index 0000000000..81d69e7a94
--- /dev/null
+++ b/apps/desktop/stories/settings/pricing-editor.stories.tsx
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { userEvent, within } from 'storybook/test';
+import { ToastProvider } from '@maka/ui';
+import { createDefaultSettings } from '@maka/core/settings';
+import {
+ PricingEditor,
+ UsagePricingServicesProvider,
+ UsageFeatureScope,
+ type UsageHostRef,
+ type UsagePricingServices,
+} from '../../src/renderer/features/usage/testing';
+import type { DesktopPricingSnapshot } from '../../src/shared/desktop-pricing';
+
+// The Pricing tab (#2015 / PR #4164) is per-Host: it loads against the settings-
+// SELECTED Runtime Host threaded to it as a prop, not the app's active Host. A
+// concrete Host is required — with none selected the tab shows its no-Host state
+// (covered by the feature unit tests, not a reachable settings-surface state).
+const STORY_HOST: UsageHostRef = { profileId: 'story-profile', hostId: 'story-host' };
+const GENERATION_KEY = `${STORY_HOST.profileId}:${STORY_HOST.hostId}:e1`;
+const USAGE_SERVICES = {
+ loadUsageStats: async () => null,
+ updateUsageSettings: async () => createDefaultSettings().usage,
+};
+
+const describeError = (error: unknown): string =>
+ error instanceof Error ? error.message : String(error);
+
+// A revision-consistent effective snapshot: two user overrides (可回退 =
+// restore_builtin, and become_unpriced = Delete) — the only rows the
+// overrides-only table shows — plus two built-ins that feed the Add flow's
+// catalog picker (never the table). One override is a free local model (0/0) so
+// the zero-rate formatting renders. Sources/reset effects are the raw Host
+// fields — the editor derives the labels and action set, so this fixture shows
+// the classification rather than asserting it.
+const MIXED_SNAPSHOT: DesktopPricingSnapshot = {
+ hostEpoch: 'story-epoch',
+ connectionId: 'story-connection',
+ revision: 7,
+ entries: [
+ { source: 'builtin', pricing: { modelKey: 'openai:gpt-5', inputUsdPer1M: 1.25, outputUsdPer1M: 10 } },
+ {
+ source: 'builtin',
+ pricing: {
+ modelKey: 'anthropic:claude-opus-4',
+ inputUsdPer1M: 15,
+ outputUsdPer1M: 75,
+ cacheReadUsdPer1M: 1.5,
+ cacheWriteUsdPer1M: 18.75,
+ },
+ },
+ {
+ source: 'custom',
+ resetEffect: 'restore_builtin',
+ pricing: { modelKey: 'zai:glm-4.7', inputUsdPer1M: 0.6, outputUsdPer1M: 2.2 },
+ },
+ {
+ source: 'custom',
+ resetEffect: 'become_unpriced',
+ pricing: { modelKey: 'local:qwen3-coder', inputUsdPer1M: 0, outputUsdPer1M: 0 },
+ },
+ ],
+};
+
+const EMPTY_SNAPSHOT: DesktopPricingSnapshot = {
+ hostEpoch: 'story-epoch',
+ connectionId: 'story-connection',
+ revision: 1,
+ entries: [],
+};
+
+// A mutate never runs at mount (CI does not autoplay), but the services shape
+// must be honest, so return the base as an unchanged commit rather than throw.
+const noopMutate: UsagePricingServices['mutatePricing'] = async (_host, base) => ({
+ kind: 'saved',
+ disposition: 'unchanged',
+ snapshot: base,
+});
+
+function pricingServices(load: UsagePricingServices['loadPricing']): UsagePricingServices {
+ return { loadPricing: load, mutatePricing: noopMutate };
+}
+
+function PricingTabPanel(props: { services: UsagePricingServices }) {
+ return (
+
+
+
+ {/* The Usage → 定价配置 tab panel wrapper the surface really renders the
+ editor inside. The surrounding settings-surface chrome (modal, nav
+ sidebar, the centered content column that bounds this width) is
+ exercised by Product/Settings/Pages; this story isolates the tab's
+ own content, capped at a representative content-column width so the
+ table is not reviewed stretched to the full 1280 render frame. */}
+
+
+
+
+ );
+}
+
+const meta = {
+ title: 'Product/Settings/Pricing',
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+// Real path: 设置 → 使用统计 → 定价配置 on a Host with a couple of user overrides.
+// The overrides-only table shows the custom rows (自定义, with Reset or Delete per
+// their reset effect); the built-in catalog is reached only via the Add picker.
+export const Populated: Story = {
+ render: () => MIXED_SNAPSHOT)} />,
+};
+
+// Real path: 设置 → 使用统计 → 定价配置 on a Host with no user overrides yet — the
+// common default (built-ins are never listed). The table area shows the
+// overrides-empty state prompting the user to Add one from the catalog.
+export const Empty: Story = {
+ render: () => EMPTY_SNAPSHOT)} />,
+};
+
+// Real path: 设置 → 使用统计 → 定价配置 on first open, before the Host's pricing
+// snapshot resolves. The table reserves its geometry with skeleton rows so real
+// rows land with no layout shift.
+export const Loading: Story = {
+ render: () => (
+ new Promise(() => {}))} />
+ ),
+};
+
+// Real path: 设置 → 使用统计 → 定价配置 when reading the Host's pricing snapshot
+// fails. The panel shows a load-failed empty state with a Retry action instead
+// of the table.
+export const LoadFailed: Story = {
+ render: () => (
+ {
+ throw new Error('Runtime Host pricing snapshot unreachable');
+ })}
+ />
+ ),
+};
+
+// Real path: Add price → manual fallback. The form accepts the exact Runtime
+// lookup key as one copy/paste-safe value instead of making the user reconstruct
+// it from separate provider/model fields.
+export const ManualExactKey: Story = {
+ render: () => MIXED_SNAPSHOT)} />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(await canvas.findByRole('button', { name: '添加定价' }));
+ const dialog = within(await canvas.findByRole('dialog', { name: '添加定价' }));
+ await userEvent.click(dialog.getByRole('button', { name: '模型不在列表中?手动输入' }));
+ await userEvent.type(dialog.getByRole('textbox', { name: /^模型键/ }), 'acme:coder-v3');
+ },
+};
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index e81ab3eaea..43942012b9 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -69,6 +69,8 @@ import type { PetPackManifestV1 } from '@maka/core/pet';
import { SettingsSurface } from '../../src/renderer/settings/settings-surface';
import { ConnectionSettingsServicesProvider } from '../../src/renderer/features/connection-settings';
import { RuntimeHostManagementServicesProvider } from '../../src/renderer/features/runtime-host-management';
+import { UsagePricingServicesProvider, type UsagePricingServices } from '../../src/renderer/features/usage';
+import type { DesktopPricingMutationInput } from '../../src/shared/desktop-pricing';
import { createDesktopConnectionSettingsServices } from '../../src/renderer/platform/desktop/create-connection-settings-services';
import { createDesktopRuntimeHostManagementServices } from '../../src/renderer/platform/desktop/create-runtime-host-management-services';
import { createUiLocaleUpdateGate } from '../../src/renderer/settings/ui-locale-update-gate';
@@ -324,7 +326,6 @@ const usageStats: UsageStats = {
},
{ tool: 'Bash', calls: 120, success: 118, errors: 2, avgDurationMs: 840 },
],
- pricing: [{ provider: 'zai-coding-plan', model: 'glm-4.7', inputPerMTokUsd: 0, outputPerMTokUsd: 0 }],
provenance: STORY_USAGE_PROVENANCE,
};
@@ -345,7 +346,6 @@ const emptyUsageStats: UsageStats = {
byProvider: [],
byModel: [],
byTool: [],
- pricing: [],
provenance: EMPTY_USAGE_PROVENANCE,
};
@@ -1621,6 +1621,33 @@ const withUsageLongTailBridge = withUsageStoryBridge(usageStats, {
activeTab: 'requests',
});
+const settingsPricingServices: UsagePricingServices = {
+ loadPricing: async (host) => ({
+ hostEpoch: `epoch-${host.hostId}`,
+ connectionId: `connection-${host.hostId}`,
+ revision: 1,
+ entries: [],
+ }),
+ mutatePricing: async () => { throw new Error('Pricing writes are not configured in this story'); },
+};
+
+const withUsagePricingHostsBridge = withScopedMakaBridge({
+ ...makaBridge,
+ runtimeHostProfiles: generationStoryRuntimeHostProfilesBridge,
+ settings: {
+ ...makaBridge.settings,
+ get: async () => mergeSettings(createDefaultSettings(), { usage: { activeTab: 'pricing' } }),
+ },
+});
+let pricingStoryMutation: (DesktopPricingMutationInput & { hostId: string }) | undefined;
+const hostSwitchPricingServices: UsagePricingServices = {
+ ...settingsPricingServices,
+ mutatePricing: async (host, base, mutation) => {
+ pricingStoryMutation = { hostId: host.hostId, base, mutation };
+ return { kind: 'saved_refresh_failed', disposition: 'committed' };
+ },
+};
+
const subagentStorySettings = mergeSettings(createDefaultSettings(), {
subagents: {
presets: [
@@ -1796,6 +1823,7 @@ function renderedLinkColors(renderedLink: HTMLElement) {
type SettingsStoryProps = {
section: SettingsSection;
+ pricingServices?: UsagePricingServices;
connections?: LlmConnection[];
defaultSlug?: string | null;
openProviderCatalog?: boolean;
@@ -1889,28 +1917,30 @@ function SettingsStoryFrame(props: SettingsStoryProps) {
>
-
+
+
+
@@ -2449,6 +2479,70 @@ export const UsageEmpty: Story = {
decorators: [withUsageEmptyBridge],
render: () => ,
};
+
+// Real path: Settings → Usage → Pricing → Add, then select another Host.
+// Settings keys its content by Host identity; the user draft must outlive that
+// real remount and require review against the replacement pricing authority.
+export const UsagePricingHostSwitch: Story = {
+ decorators: [withUsagePricingHostsBridge],
+ render: () => {
+ resetGenerationStoryBridge(runtimeHostProfilesWithRemote);
+ pricingStoryMutation = undefined;
+ return ;
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const navigation = await canvas.findByRole('navigation', { name: '使用统计视图' });
+ await userEvent.click(within(navigation).getByRole('button', { name: '定价配置' }));
+ const add = await canvas.findByRole('button', { name: '添加定价' });
+ await waitFor(() => expect(add).not.toHaveAttribute('aria-disabled', 'true'));
+ await userEvent.click(add);
+ const dialog = within(await canvas.findByRole('dialog', { name: '添加定价' }));
+ await userEvent.click(dialog.getByRole('button', { name: '模型不在列表中?手动输入' }));
+ await userEvent.type(dialog.getByRole('textbox', { name: /^模型键/ }), 'acme:host-draft');
+ await userEvent.type(dialog.getByRole('spinbutton', { name: /输入价格/ }), '1.25');
+ await userEvent.type(dialog.getByRole('spinbutton', { name: /输出价格/ }), '2.75');
+ await userEvent.tab();
+ const oldInput = dialog.getByRole('textbox', { name: /^模型键/ });
+
+ // An offline event removes the active Host and unmounts the entire page.
+ // The Settings selector then allows switching to the still-ready Remote.
+ const listener = generationStoryProfileListener;
+ if (!listener) throw new Error('Settings did not subscribe to Host lifecycle changes');
+ listener({
+ epoch: 'pricing-local-offline', profileId: 'local', profileName: 'Local',
+ profileKind: 'local', profileAccess: 'owner', readiness: 'unavailable', isDefault: true,
+ });
+ await waitFor(() => expect(oldInput.isConnected).toBe(false));
+ await userEvent.click(canvas.getByRole('combobox', { name: 'Runtime Host' }));
+ await userEvent.click(await within(document.body).findByRole('option', { name: 'Remote' }));
+ const restoredDialog = await canvas.findByRole('dialog', { name: '添加定价' });
+ const restored = within(restoredDialog);
+ await expect(restored.getByRole('textbox', { name: /^模型键/ })).toHaveValue('acme:host-draft');
+ await expect(restored.getByRole('spinbutton', { name: /输入价格/ })).toHaveValue('1.25');
+ await expect(restored.getByRole('spinbutton', { name: /输出价格/ })).toHaveValue('2.75');
+ const save = restored.getByRole('button', { name: '保存' });
+ await expect(save).toHaveAttribute('aria-disabled', 'true');
+ await userEvent.click(save);
+ await expect(pricingStoryMutation).toBeUndefined();
+ const review = restored.getByRole('button', { name: '已核对新主机定价' });
+ await waitFor(() => expect(review).not.toHaveAttribute('aria-disabled', 'true'));
+ await userEvent.click(review);
+ await expect(restoredDialog.contains(document.activeElement)).toBe(true);
+ await userEvent.click(save);
+ await waitFor(() => expect(pricingStoryMutation?.hostId).toBe('storybook-remote-host'));
+ await expect(pricingStoryMutation?.base.hostEpoch).toBe('epoch-storybook-remote-host');
+ await expect(pricingStoryMutation?.mutation).toEqual({
+ kind: 'upsert', pricing: { modelKey: 'acme:host-draft', inputUsdPer1M: 1.25, outputUsdPer1M: 2.75 },
+ });
+ await restored.findByText('已保存,但无法加载最新定价');
+ await expect(restoredDialog.contains(document.activeElement)).toBe(true);
+ await userEvent.click(restored.getByRole('button', { name: '取消' }));
+ await waitFor(() => expect(restoredDialog.isConnected).toBe(false));
+ await expect(canvas.getByRole('button', { name: '添加定价' })).toHaveFocus();
+ await expect(canvas.queryByText('暂无自定义定价')).not.toBeInTheDocument();
+ },
+};
// Real path: 设置 → 使用统计 → 供应商统计, with traffic from one provider.
export const UsageSingleProvider: Story = {
decorators: [withUsageSingleProviderBridge],
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index 4fc566c36c..9c3e55ae1e 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
-**Totals:** 268 files — blocker 0, reimplementation 0, polish 2, aligned 266.
+**Totals:** 270 files — blocker 0, reimplementation 0, polish 2, aligned 268.
## Exclusions (explicit)
@@ -81,8 +81,10 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `apps/desktop/src/renderer/features/task-entry/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/usage/pricing-services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/usage/services-context.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `apps/desktop/src/renderer/features/usage/ui/metric-card.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx` | other | Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading, Layout, LayoutContent, LayoutFooter, NumberInput, Skeleton, Text, TextInput, Typeahead, VStack | aligned — uses Astryx (Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading) | aligned |
| `apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx` | other | Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, TextInput, Tooltip | aligned — uses Astryx (Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList) | aligned |
| `apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx` | other | Card, EmptyState, Table | aligned — uses Astryx (Card, EmptyState, Table) | aligned |
| `apps/desktop/src/renderer/features/workbar/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 39c8aea7cb..872e78c6d3 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -52,8 +52,10 @@ apps/desktop/src/renderer/features/session-settings/services-context.tsx
apps/desktop/src/renderer/features/task-entry/services-context.tsx
apps/desktop/src/renderer/features/task-entry/ui/task-entry-host.tsx
apps/desktop/src/renderer/features/task-entry/ui/task-entry-provider.tsx
+apps/desktop/src/renderer/features/usage/pricing-services-context.tsx
apps/desktop/src/renderer/features/usage/services-context.tsx
apps/desktop/src/renderer/features/usage/ui/metric-card.tsx
+apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx
apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx
apps/desktop/src/renderer/features/usage/ui/usage-stats-table.tsx
apps/desktop/src/renderer/features/workbar/services-context.tsx
diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts
index 6cfde6587a..3a7a3b46f8 100644
--- a/packages/core/src/settings.ts
+++ b/packages/core/src/settings.ts
@@ -651,12 +651,6 @@ export interface UsageStats {
errors: number;
avgDurationMs: number;
}>;
- pricing: Array<{
- provider: string;
- model: string;
- inputPerMTokUsd: number;
- outputPerMTokUsd: number;
- }>;
/**
* Coverage/legacy/unreadable/pending accounting behind these totals, so the
* page can qualify a cost that reads low (unpriced/unreadable/pending) rather
diff --git a/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json b/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json
new file mode 100644
index 0000000000..71b28c8237
--- /dev/null
+++ b/packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json
@@ -0,0 +1,5 @@
+{
+ "epoch": 142,
+ "files": ["packages/runtime-host/src/protocol/usage-pricing.ts"],
+ "reason": "Adds shared pure pricing reconciliation types and helpers without changing any protocol codec, validation rule, operation payload, or wire message shape. Older and newer peers exchange identical pricing frames."
+}
diff --git a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts
index 37ceb87aec..98100d2104 100644
--- a/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts
+++ b/packages/runtime-host/src/__tests__/usage-pricing-protocol.test.ts
@@ -41,6 +41,9 @@ import {
decodeUsageQueryInput,
encodePricingQueryResult,
encodeProtocolMessage,
+ createPricingReconciliationTarget,
+ pricingReconciliationTargetMatches,
+ pricingReconciliationTargetModelKey,
PRICING_PAGE_MAX_BYTES,
PRICING_PAGE_MAX_ITEMS,
RUNTIME_HOST_MAX_MESSAGE_BYTES,
@@ -63,6 +66,48 @@ const CONNECTION_CONTEXT: ConnectionContext = {
};
describe('Usage/Pricing protocol', () => {
+ test('pricing reconciliation captures reset semantics and exact custom provenance', () => {
+ const builtin: EffectivePricingEntry = {
+ source: 'builtin',
+ pricing: { modelKey: 'openai:gpt-5', inputUsdPer1M: 1, outputUsdPer1M: 2 },
+ };
+ const resettable = customPricingEntry('anthropic:claude', 'restore_builtin');
+ const upsert = createPricingReconciliationTarget([builtin, resettable], {
+ kind: 'upsert',
+ pricing: builtin.pricing,
+ });
+ assert.equal(pricingReconciliationTargetModelKey(upsert), 'openai:gpt-5');
+ assert.equal(
+ pricingReconciliationTargetMatches(upsert, [builtin, resettable]),
+ false,
+ 'equal bundled rates are not a committed custom override',
+ );
+ assert.equal(
+ pricingReconciliationTargetMatches(upsert, [
+ { ...builtin, source: 'custom', resetEffect: 'restore_builtin' },
+ resettable,
+ ]),
+ true,
+ );
+
+ const reset = createPricingReconciliationTarget([builtin, resettable], {
+ kind: 'delete',
+ modelKey: resettable.pricing.modelKey,
+ });
+ assert.deepEqual(reset, {
+ kind: 'delete',
+ modelKey: 'anthropic:claude',
+ expected: 'builtin',
+ });
+ assert.equal(
+ pricingReconciliationTargetMatches(reset, [
+ builtin,
+ { source: 'builtin', pricing: resettable.pricing },
+ ]),
+ true,
+ );
+ });
+
test('decodes exact bounded usage queries', () => {
assert.deepEqual(
decodeUsageQueryInput({
diff --git a/packages/runtime-host/src/protocol/usage-pricing.ts b/packages/runtime-host/src/protocol/usage-pricing.ts
index 22e6f72ee7..35872769ab 100644
--- a/packages/runtime-host/src/protocol/usage-pricing.ts
+++ b/packages/runtime-host/src/protocol/usage-pricing.ts
@@ -18,6 +18,7 @@
*/
import {
+ canonicalPricingConfigsEqual,
comparePricingModelKeys,
normalizePricingModelKey,
validateCanonicalPricingConfig,
@@ -298,6 +299,57 @@ export type PricingMutation =
| { readonly kind: 'upsert'; readonly pricing: PricingConfig }
| { readonly kind: 'delete'; readonly modelKey: string };
+export type PricingReconciliationTarget =
+ | { readonly kind: 'upsert'; readonly pricing: Readonly }
+ | {
+ readonly kind: 'delete';
+ readonly modelKey: string;
+ readonly expected: 'builtin' | 'unpriced' | 'no_override';
+ };
+
+/** Capture the intended end state before dispatch, while the CAS base is known. */
+export function createPricingReconciliationTarget(
+ baseEntries: readonly EffectivePricingEntry[],
+ mutation: PricingMutation,
+): PricingReconciliationTarget {
+ if (mutation.kind === 'upsert') return { kind: 'upsert', pricing: mutation.pricing };
+ const baseEntry = baseEntries.find(({ pricing }) => pricing.modelKey === mutation.modelKey);
+ const expected =
+ baseEntry?.source === 'custom'
+ ? baseEntry.resetEffect === 'restore_builtin'
+ ? 'builtin'
+ : 'unpriced'
+ : 'no_override';
+ return { kind: 'delete', modelKey: mutation.modelKey, expected };
+}
+
+/** Compare a later authoritative projection with a previously captured target. */
+export function pricingReconciliationTargetMatches(
+ target: PricingReconciliationTarget,
+ entries: readonly EffectivePricingEntry[],
+): boolean {
+ const current = entries.find(
+ ({ pricing }) => pricing.modelKey === pricingReconciliationTargetModelKey(target),
+ );
+ if (target.kind === 'upsert') {
+ return (
+ current?.source === 'custom' && canonicalPricingConfigsEqual(current.pricing, target.pricing)
+ );
+ }
+ switch (target.expected) {
+ case 'builtin':
+ return current?.source === 'builtin';
+ case 'unpriced':
+ return current === undefined;
+ case 'no_override':
+ return current === undefined || current.source === 'builtin';
+ }
+}
+
+export function pricingReconciliationTargetModelKey(target: PricingReconciliationTarget): string {
+ return target.kind === 'upsert' ? target.pricing.modelKey : target.modelKey;
+}
+
export interface PricingMutateInput {
readonly expectedRevision: number;
readonly mutation: PricingMutation;