From 5bc9f1b7bb2a975fa4743f737bc67fbb81ddabfd Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 3 Sep 2026 21:45:03 +0800 Subject: [PATCH 1/7] feat(desktop): add overrides-only pricing editor with a catalog picker (#4164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the editable Pricing Settings surface (#2015) to the migrated `features/usage` feature (#4425) as the Usage "pricing" tab, over the Host's CAS + reconciliation protocol. Per the maintainer direction on #2218 the surface is **overrides-only**: the table lists only the user's custom rows, and the built-in models.dev catalog (~1.4k rows) is reached only through the Add flow — never rendered as a table. (#3129's user-overridable model facts deliberately exclude pricing and ship no UI, so pricing remains its own dedicated surface.) Renderer (feature-owned, ratchet-clean): - Pricing controller, view-model, copy, and editor UI under `features/usage/`, rendered as the Usage "pricing" tab. Its services come from a dedicated `UsagePricingServices` port + provider + `platform/desktop` adapter wired through `composition/desktop-feature-services.tsx`, so the sole `window.maka.settings.pricing` bridge access stays in the platform zone. - Overrides-only table: `overrideRows` = the Host's `custom` entries (the Host collapses an overridden built-in into one custom row). The Usage range/summary toolbar is hidden on this tab (#2015 acceptance #2 — not time-scoped). - Add flow uses an Astryx `Typeahead` catalog picker over the Host's `builtin` entries (renders only the top matches, never the full list; a pick pre-fills the built-in price), with a manual-entry fallback for a model not in the catalog (local/new keys). Edit locks the key. Duplicate detection stays over the full built-in ∪ overrides union. Correctness (fixes found in review of the earlier full-table revision): - A committed mutation fences an in-flight reload (shared authority sequence), so a slow refresh can't overwrite the saved authority or clear a write-block. - A Host generation change resets all transient state (editor/draft/busy latches + action guard), so a dialog can't stick saving and an old-Host draft can't be saved onto the new authority. - An Add conflict whose key now exists elsewhere converts the Add into an Edit locked on that key, so the required second save upserts instead of being silently blocked by the duplicate check. - A saved-but-refresh-failed outcome clears the now-stale list (no speculative final list, per #2015) while retaining the draft until a successful refresh. Main / preload: the CAS pricing IPC (`usage:pricing:load` / `usage:pricing:mutate`) over `DesktopRuntimeHostClient`, a public `reconcilePricingMutation` for the reconciled-control path (reload + compare intent; never replay), and the declaration-only `desktop-pricing.d.ts` + main-only `desktop-pricing-decode.ts`. Core: remove the orphaned `UsageStats.pricing` field + its usage-stats projection. Tests: pricing view-model + a `PricingEditor` render suite (overrides-only table, catalog/manual Add, saved / refresh-failed / conflict / reconcile-unavailable / invalid-draft, and one regression per correctness fix above), plus the load/mutate IPC (base pass-through, malformed base, reconcile-no-replay). A Desktop E2E drives 设置 → 使用统计 → 定价配置: overrides-only (no 内置 rows), the absent range toolbar (#2), the catalog/manual Add UI, and editor focus restore (#11). Storybook stories (populated / empty / loading / load-failed) and the Astryx surface inventory regenerated. Refs #4164 #2015 #4425 #2218 Generated-by: Claude Code --- apps/desktop/e2e-budget.json | 4 + apps/desktop/e2e/settings-pricing.spec.ts | 76 +++ .../desktop-session-projection.test.ts | 1 - .../src/main/__tests__/pricing-editor.test.ts | 605 ++++++++++++++++++ .../main/__tests__/pricing-view-model.test.ts | 161 +++++ .../runtime-host-pricing-ipc-main.test.ts | 194 ++++++ .../runtime-host-usage-ipc-main.test.ts | 24 - .../__tests__/usage-settings-view.test.ts | 4 +- apps/desktop/src/main/runtime-host-boot.ts | 2 + apps/desktop/src/main/runtime-host-client.ts | 21 + .../src/main/runtime-host-pricing-ipc-main.ts | 161 +++++ .../src/main/runtime-host-usage-ipc-main.ts | 95 +-- apps/desktop/src/preload/bridge-contract.d.ts | 13 + apps/desktop/src/preload/preload.ts | 33 + .../composition/desktop-feature-services.tsx | 9 +- .../usage/controller/pricing-controller.ts | 466 ++++++++++++++ .../src/renderer/features/usage/index.ts | 6 + .../src/renderer/features/usage/ports.ts | 12 + .../renderer/features/usage/pricing-copy.ts | 337 ++++++++++ .../renderer/features/usage/pricing-ports.ts | 55 ++ .../usage/pricing-services-context.tsx | 36 ++ .../features/usage/pricing-view-model.ts | 198 ++++++ .../src/renderer/features/usage/testing.ts | 34 + .../features/usage/ui/pricing-editor.tsx | 531 +++++++++++++++ .../features/usage/ui/usage-settings-view.tsx | 94 ++- .../desktop/create-usage-pricing-services.ts | 38 ++ .../renderer/settings/settings-surface.tsx | 2 +- .../renderer/settings/usage-settings-page.tsx | 6 + .../src/renderer/styles/settings/usage.css | 29 + .../src/shared/desktop-pricing-decode.ts | 109 ++++ apps/desktop/src/shared/desktop-pricing.d.ts | 75 +++ .../settings/pricing-editor.stories.tsx | 161 +++++ .../settings/settings-pages.stories.tsx | 2 - docs/astryx-surface-file-inventory.md | 2 + docs/astryx-surface-file-inventory.paths | 2 + packages/core/src/settings.ts | 6 - 36 files changed, 3425 insertions(+), 179 deletions(-) create mode 100644 apps/desktop/e2e/settings-pricing.spec.ts create mode 100644 apps/desktop/src/main/__tests__/pricing-editor.test.ts create mode 100644 apps/desktop/src/main/__tests__/pricing-view-model.test.ts create mode 100644 apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts create mode 100644 apps/desktop/src/main/runtime-host-pricing-ipc-main.ts create mode 100644 apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts create mode 100644 apps/desktop/src/renderer/features/usage/pricing-copy.ts create mode 100644 apps/desktop/src/renderer/features/usage/pricing-ports.ts create mode 100644 apps/desktop/src/renderer/features/usage/pricing-services-context.tsx create mode 100644 apps/desktop/src/renderer/features/usage/pricing-view-model.ts create mode 100644 apps/desktop/src/renderer/features/usage/testing.ts create mode 100644 apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx create mode 100644 apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts create mode 100644 apps/desktop/src/shared/desktop-pricing-decode.ts create mode 100644 apps/desktop/src/shared/desktop-pricing.d.ts create mode 100644 apps/desktop/stories/settings/pricing-editor.stories.tsx 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..5c3c10c963 --- /dev/null +++ b/apps/desktop/e2e/settings-pricing.spec.ts @@ -0,0 +1,76 @@ +/* + * 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 (the editor returns focus +// to the trigger that opened it — 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(); +}); 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..d4908cbd05 --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-editor.test.ts @@ -0,0 +1,605 @@ +/* + * 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 { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../shared/desktop-pricing.js'; +import { + getPricingSettingsCopy, + PricingEditor, + UsagePricingServicesProvider, + 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 ?? '', new RegExp(copy.sourceBuiltin)); + 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 provider input, plus a toggle to + // manual entry (inferred without depending on the Typeahead's internal DOM). + assert.equal( + inputByPlaceholder(harness.doc, copy.providerPlaceholder), + undefined, + 'no free-text provider 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 the free-text provider/model inputs + a toggle + // back to the catalog. + await click(toManual); + assert.ok(inputByPlaceholder(harness.doc, copy.providerPlaceholder), 'manual provider input'); + assert.ok(inputByPlaceholder(harness.doc, copy.modelPlaceholder), 'manual model input'); + assert.ok(buttonByText(harness.doc, copy.catalogToggle), 'catalog toggle present in manual mode'); + 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('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('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.textContent ?? '', /anthropic:claude/); + 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('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 and the confirm dialog stays open for an explicit + // second confirm — the mutation is never replayed blindly. + assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitle)); + const confirmAgain = buttonByText(harness.doc, copy.confirmReset); + assert.ok(confirmAgain, 'reset dialog stays open on conflict'); + 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 dims the possibly-stale list', 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'); + assert.ok( + harness.container.querySelector('.settingsPricingStale'), + 'the possibly-stale list is dimmed while writes are blocked', + ); + 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 closes the editor and reloads fresh authority (P1.1)', async () => { + const harness = await renderEditor({ load: async () => SNAPSHOT }); + await click(buttonByText(harness.doc, copy.add)); + assert.ok(buttonByText(harness.doc, copy.save), 'the Add dialog is open'); + // 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 open editor is dropped (so a stale draft can't be saved onto the new + // authority) and a fresh reload runs. + assert.equal(buttonByText(harness.doc, copy.save), undefined, 'the editor is closed'); + assert.equal(harness.loadCalls(), 2, 'a fresh authority reload ran'); + 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('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.providerPlaceholder), 'acme'); + await setInput(inputByPlaceholder(harness.doc, copy.modelPlaceholder), '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'); + 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.providerPlaceholder), 'openai'); + await setInput(inputByPlaceholder(harness.doc, copy.modelPlaceholder), '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.providerPlaceholder), 'anthropic'); + await setInput(inputByPlaceholder(harness.doc, copy.modelPlaceholder), '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; +}) { + 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: Array = []; + const mutateHosts: Array = []; + 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'; + function renderTree(generationKey: string): void { + const editor = createElement(PricingEditor, { + describeError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + runtimeHost, + generationKey, + }); + const provided = createElement(UsagePricingServicesProvider, { services, children: editor }); + const toasted = createElement(ToastProvider, { children: provided }); + const localized = createElement(AstryxLocaleProvider, { children: toasted }); + root.render(createElement(LocaleProvider, { locale: 'en', children: localized })); + } + 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, + 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(); + }); +} + +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(doc: Document, text: string): HTMLButtonElement | undefined { + return Array.from(doc.querySelectorAll('button')).find( + (button) => (button.textContent ?? '').trim() === text, + ); +} + +function buttonByLabel(doc: Document, label: string): HTMLButtonElement | undefined { + return ( + doc.querySelector(`button[aria-label="${label}"]`) ?? undefined + ); +} 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..4ec2aae745 --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-view-model.test.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. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { EffectivePricingEntry } from "@maka/runtime-host/protocol"; +import { + derivePricingRows, + validatePricingDraft, + type PricingDraft, +} from "../../renderer/features/usage/testing.js"; + +const EMPTY: PricingDraft = { + provider: "", + model: "", + input: null, + output: null, + cacheRead: null, + cacheWrite: null, +}; + +test("derivePricingRows maps source, split, and cache presence", () => { + const entries: EffectivePricingEntry[] = [ + { + source: "custom", + resetEffect: "become_unpriced", + pricing: { modelKey: "acme:coder-v2", inputUsdPer1M: 0.8, outputUsdPer1M: 2.4 }, + }, + { + source: "builtin", + pricing: { + modelKey: "openai:gpt-4o", + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + cacheReadUsdPer1M: 0, + }, + }, + { + source: "custom", + resetEffect: "restore_builtin", + pricing: { modelKey: "anthropic:claude", inputUsdPer1M: 2, outputUsdPer1M: 12 }, + }, + ]; + + const rows = derivePricingRows(entries); + + // Canonical key order, not input order. + assert.deepEqual( + rows.map((row) => row.modelKey), + ["acme:coder-v2", "anthropic:claude", "openai:gpt-4o"], + ); + const acme = rows[0]!; + assert.equal(acme.provider, "acme"); + assert.equal(acme.model, "coder-v2"); + assert.equal(acme.source, "custom"); + assert.equal(acme.resetEffect, "become_unpriced"); + + const anthropic = rows[1]!; + assert.equal(anthropic.resetEffect, "restore_builtin"); + + const openai = rows[2]!; + assert.equal(openai.source, "builtin"); + assert.equal(openai.resetEffect, null); + // Explicit 0 is preserved and stays distinct from "not set" (undefined). + assert.equal(openai.cacheReadUsdPer1M, 0); + assert.equal(openai.cacheWriteUsdPer1M, undefined); +}); + +test("validatePricingDraft add flags empty provider/model", () => { + const result = validatePricingDraft(EMPTY, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.provider, "required"); + assert.equal(result.errors.model, "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, provider: "openai", model: "gpt-4o", input: 1, output: 2 }; + const result = validatePricingDraft(draft, { + mode: "add", + existingKeys: ["openai:gpt-4o"], + }); + assert.equal(result.errors.model, "duplicate"); + assert.equal(result.config, null); +}); + +test("validatePricingDraft add builds a canonical config; blank cache is omitted", () => { + const draft: PricingDraft = { + provider: "acme", + model: "coder-v2", + 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: "acme:coder-v2", + 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 = { + provider: "acme", + model: "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, provider: "a", model: "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 provider/model", () => { + const draft: PricingDraft = { + provider: "ignored", + model: "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-pricing-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts new file mode 100644 index 0000000000..ec33af648e --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts @@ -0,0 +1,194 @@ +/* + * 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, + sendToRenderer: () => undefined, + }); + 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..6c4e343475 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,22 +87,6 @@ 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, }); @@ -135,14 +119,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()); 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..e124f8abd8 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 { @@ -1731,6 +1732,7 @@ function registerHostClientIpc( 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..2f2a78f758 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -694,6 +694,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, request.mutation); + return this.#reconcilePricingMutation(target, reason); + } + async listSessions(): Promise { this.#assertOpen(); try { 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..ca84e8496a 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, @@ -53,16 +47,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 +99,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 +106,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 +144,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 +317,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 +369,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..cbe1a68c55 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..8ee82e30ac 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/controller/pricing-controller.ts b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts new file mode 100644 index 0000000000..ee67039f6e --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts @@ -0,0 +1,466 @@ +/* + * 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, useMemo, useRef, useState } from 'react'; +import { useToast, useUiLocale } from '@maka/ui'; +import type { PricingMutation } from '@maka/runtime-host/protocol'; +import { useUsagePricingServices } from '../pricing-services-context.js'; +import type { UsagePricingServices } from '../pricing-ports.js'; +import type { UsageHostRef } from '../ports.js'; +import { getPricingSettingsCopy } from '../pricing-copy.js'; +import { useActionGuard } from './action-guard.js'; +import { + derivePricingRows, + draftFromRow, + findPricingRow, + validatePricingDraft, + type PricingDraft, + type PricingRowView, +} from '../pricing-view-model.js'; + +// Desktop pricing shapes derive from the `UsagePricingServices` port (whose +// types come from the global `window.maka.settings.pricing` bridge), so the +// feature names them without importing the preload/`shared` Desktop types. +type DesktopPricingSnapshot = Awaited>; +type DesktopPricingMutationOutcome = Awaited>; + +type PricingEditor = + | { readonly mode: 'add' } + | { readonly mode: 'edit'; readonly row: PricingRowView }; + +/** + * 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 latest: DesktopPricingSnapshot; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + } + | { readonly kind: 'refresh_failed' } + | { readonly kind: 'reconcile_unavailable'; readonly reason: 'revision_conflict' | 'outcome_unknown' }; + +const EMPTY_DRAFT: PricingDraft = { + provider: '', + model: '', + input: null, + output: null, + cacheRead: null, + cacheWrite: null, +}; + +/** Owns the Host-backed Pricing snapshot, the editor draft, and every outcome. */ +export function usePricingController(props: { + readonly describeError: (error: unknown) => string; + /** + * The settings-*selected* Runtime Host (threaded as a prop from the legacy + * surface, not resolved at the bridge). Pricing overrides are per-Host, so the + * Pricing tab must read/write the same Host as the rest of the settings page — + * the app's *active* Host (what an omitted bridge arg would resolve) can differ + * because the settings surface has its own Host selector. + */ + readonly runtimeHost: UsageHostRef | undefined; + /** + * The Usage scope's `targetKey` (`host:epoch`). Pricing services come from a + * single app-root provider (not a Host-keyed one), so a Host/generation change + * does not remount this controller; instead this key changes and the reload + * effect below re-fetches against the fresh Host — mirroring the previous + * surface's reload-on-generation behaviour. + */ + readonly generationKey: string; +}) { + 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] = useState(null); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [cacheOpen, setCacheOpen] = useState(false); + const [writeState, setWriteState] = useState({ kind: 'idle' }); + const [saving, setSaving] = useState(false); + const [resetTarget, setResetTarget] = useState(null); + const [resetBusy, setResetBusy] = useState(false); + const triggerRef = useRef(null); + + const guard = useActionGuard(); + const mountedRef = useRef(false); + 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); + // Bumped whenever the selected Host enters a new lifecycle generation. A + // mutation captures it at dispatch and drops its result if the generation + // changed while it was in flight — an old-generation save must never write + // back onto a freshly loaded snapshot. + const generationEpochRef = useRef(0); + + useEffect(() => { + lifecycleRef.current += 1; + mountedRef.current = true; + const lifecycle = lifecycleRef.current; + return () => { + if (lifecycleRef.current !== lifecycle) return; + mountedRef.current = false; + reloadTicketRef.current += 1; + }; + }, []); + + function isCurrent(lifecycle: number, epoch: number): boolean { + return ( + mountedRef.current && + lifecycleRef.current === lifecycle && + generationEpochRef.current === epoch + ); + } + + async function reload(): Promise { + const host = props.runtimeHost; + const lifecycle = lifecycleRef.current; + const epoch = generationEpochRef.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, epoch) && ticket === reloadTicketRef.current) { + setSnapshot(null); + setLoadError(null); + setWriteState({ kind: 'idle' }); + setLoading(false); + } + return; + } + try { + const next = await services.loadPricing(host); + if (!isCurrent(lifecycle, epoch) || ticket !== reloadTicketRef.current) return; + setSnapshot(next); + setLoadError(null); + setWriteState({ kind: 'idle' }); + } catch (error) { + if (!isCurrent(lifecycle, epoch) || ticket !== reloadTicketRef.current) return; + setLoadError(describeError(error)); + } finally { + if (isCurrent(lifecycle, epoch) && ticket === reloadTicketRef.current) setLoading(false); + } + } + + // Load on mount and whenever the selected Host generation changes. Pricing + // services come from a single app-root provider, so a Host change does not + // remount this controller; the `generationKey` prop (the Usage scope's + // `host:epoch`) changes instead, which resets the snapshot and reloads — + // replacing the previous surface's generation-key remount. A generation bump + // also fences any in-flight mutation from an older Host (`isCurrent`). The + // draft is intentionally dropped on a generation change. + useEffect(() => { + generationEpochRef.current += 1; + // A Host generation change is a fresh authority/list. Fence any in-flight + // reload, drop the snapshot, and reset ALL transient interaction state: + // close the editor and clear the draft (so an old-Host draft can't be saved + // onto the new authority), clear the reset target, and release both busy + // latches + the action guard (so a mutation whose `finally` no longer runs + // — its epoch changed — can't leave a dialog stuck saving/resetting). + reloadTicketRef.current += 1; + setSnapshot(null); + setWriteState({ kind: 'idle' }); + setEditor(null); + setDraft(EMPTY_DRAFT); + setCacheOpen(false); + setResetTarget(null); + setSaving(false); + setResetBusy(false); + guard.finish(); + void reload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.generationKey]); + + const rows = useMemo(() => derivePricingRows(snapshot?.entries ?? []), [snapshot]); + // 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(() => rows.filter((row) => row.source === 'custom'), [rows]); + const catalogRows = useMemo(() => rows.filter((row) => row.source === 'builtin'), [rows]); + // 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.modelKey), [overrideRows]); + const validation = useMemo( + () => + validatePricingDraft(draft, { + mode: editor?.mode ?? 'add', + existingKeys: overrideKeys, + lockedModelKey: editor?.mode === 'edit' ? editor.row.modelKey : undefined, + }), + [draft, editor, overrideKeys], + ); + + const writesBlocked = + writeState.kind === 'refresh_failed' || writeState.kind === 'reconcile_unavailable'; + + // 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 = + editor?.mode === 'edit' + ? editor.row.modelKey + : editor?.mode === 'add' + ? (validation.config?.modelKey ?? null) + : (resetTarget?.modelKey ?? null); + if (!key) return null; + return findPricingRow(writeState.latest.entries, key); + }, [writeState, editor, resetTarget, validation]); + + function restoreTriggerFocus() { + const trigger = triggerRef.current; + triggerRef.current = null; + if (trigger?.isConnected) requestAnimationFrame(() => trigger.focus()); + } + + 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; + setDraft(EMPTY_DRAFT); + setCacheOpen(false); + setEditor({ mode: 'add' }); + } + + function openEdit(row: PricingRowView, trigger: HTMLElement | null) { + if (writesBlocked) return; + triggerRef.current = trigger; + const prefill = draftFromRow(row); + setDraft(prefill.draft); + setCacheOpen(prefill.cacheOpen); + setEditor({ mode: 'edit', row }); + } + + /** + * 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: PricingRowView) { + const prefill = draftFromRow(row); + setDraft(prefill.draft); + setCacheOpen(prefill.cacheOpen); + } + + function closeEditor() { + if (saving) return; + setEditor(null); + if (writeState.kind === 'conflict') setWriteState({ kind: 'idle' }); + restoreTriggerFocus(); + } + + const setField = (key: K, value: PricingDraft[K]) => + setDraft((current) => ({ ...current, [key]: value })); + + /** Map a settled outcome to state; `onCommitted` runs on saved/synchronized. */ + function applyOutcome( + outcome: DesktopPricingMutationOutcome, + onCommitted: () => void, + attemptedKey?: string, + ): 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); + switch (outcome.kind) { + case 'saved': + setSnapshot(outcome.snapshot); + setWriteState({ kind: 'idle' }); + onCommitted(); + toast.success(copy.saved, outcome.disposition === 'unchanged' ? copy.synchronized : undefined); + return; + case 'synchronized': + setSnapshot(outcome.snapshot); + setWriteState({ kind: 'idle' }); + onCommitted(); + 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`. + setSnapshot(outcome.snapshot); + setWriteState({ kind: 'conflict', latest: outcome.snapshot, reason: outcome.reason }); + // 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). + if (editor?.mode === 'add' && attemptedKey) { + const latestRow = findPricingRow(outcome.snapshot.entries, attemptedKey); + if (latestRow) setEditor({ mode: 'edit', row: latestRow }); + } + 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); the draft is retained and writes stay blocked until a refresh. + setSnapshot(null); + setWriteState({ kind: 'refresh_failed' }); + return; + case 'reconciliation_unavailable': + setWriteState({ kind: 'reconcile_unavailable', reason: outcome.reason }); + return; + } + } + + /** The CAS base: the latest we saw on a conflict, else the loaded snapshot. */ + function mutationBase(): DesktopPricingSnapshot | null { + return writeState.kind === 'conflict' ? writeState.latest : snapshot; + } + + async function save() { + const config = validation.config; + const base = mutationBase(); + if (!config || !base || saving) return; + if (!guard.begin('write')) return; + const lifecycle = lifecycleRef.current; + const epoch = generationEpochRef.current; + setSaving(true); + try { + const mutation: PricingMutation = { kind: 'upsert', pricing: config }; + const outcome = await services.mutatePricing(props.runtimeHost, base, mutation); + if (!isCurrent(lifecycle, epoch)) return; + applyOutcome( + outcome, + () => { + setEditor(null); + restoreTriggerFocus(); + }, + config.modelKey, + ); + } catch (error) { + if (isCurrent(lifecycle, epoch)) { + toast.error(copy.saveFailed, describeError(error)); + } + } finally { + guard.finish(); + if (isCurrent(lifecycle, epoch)) setSaving(false); + } + } + + function openReset(row: PricingRowView, trigger: HTMLElement | null) { + if (writesBlocked) return; + triggerRef.current = trigger; + setResetTarget(row); + } + + function cancelReset() { + if (resetBusy) return; + setResetTarget(null); + restoreTriggerFocus(); + } + + async function confirmReset() { + const target = resetTarget; + const base = mutationBase(); + if (!target || !base || resetBusy) return; + if (!guard.begin('write')) return; + const lifecycle = lifecycleRef.current; + const epoch = generationEpochRef.current; + setResetBusy(true); + try { + const mutation: PricingMutation = { kind: 'delete', modelKey: target.modelKey }; + const outcome = await services.mutatePricing(props.runtimeHost, base, mutation); + if (!isCurrent(lifecycle, epoch)) return; + applyOutcome(outcome, () => { + setResetTarget(null); + restoreTriggerFocus(); + toast.success(copy.resetDone); + }); + // A conflict keeps the confirm dialog open for an explicit second + // confirm against fresh authority (mutationBase() now returns `latest`). + // An uncertain outcome blocks writes — close the dialog; the panel notice + // explains the next step. + if ( + outcome.kind === 'saved_refresh_failed' || + outcome.kind === 'reconciliation_unavailable' + ) { + setResetTarget(null); + } + } catch (error) { + if (isCurrent(lifecycle, epoch)) { + toast.error(copy.resetFailed, describeError(error)); + } + } finally { + guard.finish(); + if (isCurrent(lifecycle, epoch)) setResetBusy(false); + } + } + + return { + copy, + 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, + rows, + // Overrides-only table + catalog picker for the Add flow. + overrideRows, + catalogRows, + pickCatalogModel, + editor, + draft, + setField, + cacheOpen, + setCacheOpen, + validation, + writeState, + writesBlocked, + 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-copy.ts b/apps/desktop/src/renderer/features/usage/pricing-copy.ts new file mode 100644 index 0000000000..273773c937 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-copy.ts @@ -0,0 +1,337 @@ +/* + * 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'; + +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; + providerLabel: string; + providerPlaceholder: string; + modelLabel: string; + modelPlaceholder: 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(input: string, output: string): string; + reviewSave: 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; + resetFailed: string; + resetDone: 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: '编辑定价', + providerLabel: '供应商', + providerPlaceholder: '例如 anthropic', + modelLabel: '模型', + modelPlaceholder: '例如 claude-sonnet-4-5', + keyHelp: '这是运行时的精确查找键,需与用量记录中的供应商与模型 ID 完全一致(区分大小写,不要用连接别名)。', + 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: (input: string, output: string) => `当前最新:输入 ${input} / 输出 ${output}`, + reviewSave: '核对并保存', + refreshFailedTitle: '已保存,但无法加载最新定价', + refreshFailedBody: '保存已完成,但未能读取最新定价。请刷新后再进行修改。', + reconcileTitle: '无法确认结果', + reconcileBody: '未能确认这次修改的结果。请刷新定价后再进行修改。', + writeBlockedReason: '需先刷新最新定价后才能修改。', + saveFailed: '保存定价失败', + resetTitle: '重置定价', + resetBody: (modelKey: string) => `将删除「${modelKey}」的自定义价格,恢复为内置定价。`, + deleteTitle: '删除定价', + deleteBody: (modelKey: string) => + `将删除「${modelKey}」的定价;新激活的调用将变为未定价(不计入 Maka 的费用估算,与显式填 0 不同),进行中的运行沿用其开始时的快照。`, + confirmReset: '重置', + confirmDelete: '删除', + resetFailed: '操作失败', + resetDone: '已更新定价', + }, + 'zh-TW': { + title: '定價設定', + subtitle: + '美元 / 每百萬 token。用於新啟用的模型呼叫;進行中的執行沿用其開始時的價格。歷史費用不會重算,最終以供應商結算為準。', + refresh: '重新整理', + add: '新增定價', + addNeedsSnapshot: '需先載入定價後才能新增。', + loading: '正在載入定價…', + loadFailedTitle: '無法載入定價', + loadFailedBody: '讀取 Runtime Host 的定價快照失敗,請重試。', + 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: '編輯定價', + providerLabel: '供應商', + providerPlaceholder: '例如 anthropic', + modelLabel: '模型', + modelPlaceholder: '例如 claude-sonnet-4-5', + keyHelp: '這是執行階段的精確查找鍵,需與用量記錄中的供應商與模型 ID 完全一致(區分大小寫,請勿使用連線別名)。', + 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: (input: string, output: string) => `目前最新:輸入 ${input} / 輸出 ${output}`, + reviewSave: '核對並儲存', + refreshFailedTitle: '已儲存,但無法載入最新定價', + refreshFailedBody: '儲存已完成,但無法讀取最新定價。請重新整理後再進行修改。', + reconcileTitle: '無法確認結果', + reconcileBody: '無法確認這次修改的結果。請重新整理定價後再進行修改。', + writeBlockedReason: '需先重新整理最新定價後才能修改。', + saveFailed: '儲存定價失敗', + resetTitle: '重設定價', + resetBody: (modelKey: string) => `將刪除「${modelKey}」的自訂價格,還原為內建定價。`, + deleteTitle: '刪除定價', + deleteBody: (modelKey: string) => + `將刪除「${modelKey}」的定價;新啟用的呼叫將變為未定價(不計入 Maka 的費用估算,與明確填入 0 不同),進行中的執行沿用其開始時的快照。`, + confirmReset: '重設', + confirmDelete: '刪除', + resetFailed: '操作失敗', + resetDone: '已更新定價', + }, + 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', + providerLabel: 'Provider', + providerPlaceholder: 'e.g. anthropic', + modelLabel: 'Model', + modelPlaceholder: 'e.g. claude-sonnet-4-5', + keyHelp: + 'This is the exact Runtime lookup key. Match the provider and model IDs from your usage records exactly (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: (input: string, output: string) => `Latest: input ${input} / output ${output}`, + reviewSave: 'Review & save', + 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', + resetFailed: 'Action failed', + resetDone: 'Pricing updated', + }, +} satisfies UiCatalog; + +export function getPricingSettingsCopy(locale: UiLocale): PricingSettingsCopy { + return SETTINGS_PRICING_COPY[locale]; +} 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..0b3d4cf31f --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-ports.ts @@ -0,0 +1,55 @@ +/* + * 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). +// +// Types derive from the global `window.maka.settings.pricing` bridge as a +// type-only reference (no runtime bridge access, so no bridge path is recorded +// for this feature file) — the feature names the Host-scoped snapshot/outcome +// shapes without importing the preload/`shared` Desktop types. +import type { UsageHostRef } from './ports.js'; + +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 | undefined, + ): Promise>>; + /** Apply one pricing upsert/delete against the viewed snapshot (the CAS base). */ + mutatePricing( + host: UsageHostRef | undefined, + base: Parameters[0], + mutation: Parameters[1], + ): 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..0935c4b449 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx @@ -0,0 +1,36 @@ +/* + * 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'; + +// The pricing services are Host-agnostic at this seam: the platform adapter +// targets the settings-selected Runtime Host inside the preload bridge, so a +// single app-root provider serves every mount. A Host/generation change is +// surfaced to the pricing controller via the Usage scope's `targetKey` (threaded +// as `generationKey`), which drives the reload — not by remounting a keyed +// 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..c549c7d0cc --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts @@ -0,0 +1,198 @@ +/* + * 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. + */ + +/** + * Pure derivations for the Pricing Settings panel — no React, no IPC — so the + * row projection and the editor validation are unit-testable without a + * renderer. The Host already returns entries as the canonical built-in ∪ + * overrides union in key order; this only maps them to display rows (re-sorting + * defensively) and mirrors the Host's `normalizePricingConfig` rules per-field. + */ + +import { + comparePricingModelKeys, + normalizePricingModelKey, + pricingModelKey, +} from '@maka/core/usage-stats/pricing'; +import type { PricingConfig } from '@maka/core/usage-stats/types'; +import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; + +export interface PricingRowView { + readonly modelKey: string; + /** Display-only split of `modelKey` on its first colon. */ + readonly provider: string; + readonly model: string; + readonly source: 'builtin' | 'custom'; + /** null for a built-in row; the delete consequence for a custom row. */ + readonly resetEffect: 'restore_builtin' | 'become_unpriced' | null; + readonly inputUsdPer1M: number; + readonly outputUsdPer1M: number; + /** `undefined` means "Not set" — distinct from an explicit `0`. */ + readonly cacheReadUsdPer1M: number | undefined; + readonly cacheWriteUsdPer1M: number | undefined; +} + +export function derivePricingRows( + entries: readonly EffectivePricingEntry[], +): PricingRowView[] { + return [...entries] + .sort((left, right) => + comparePricingModelKeys(left.pricing.modelKey, right.pricing.modelKey), + ) + .map((entry) => { + const key = entry.pricing.modelKey; + const separator = key.indexOf(':'); + return { + modelKey: key, + provider: separator < 0 ? '' : key.slice(0, separator), + model: separator < 0 ? key : key.slice(separator + 1), + source: entry.source, + resetEffect: entry.source === 'custom' ? entry.resetEffect : null, + inputUsdPer1M: entry.pricing.inputUsdPer1M, + outputUsdPer1M: entry.pricing.outputUsdPer1M, + cacheReadUsdPer1M: entry.pricing.cacheReadUsdPer1M, + cacheWriteUsdPer1M: entry.pricing.cacheWriteUsdPer1M, + }; + }); +} + +/** The derived row for `modelKey` within a snapshot's entries, or null. */ +export function findPricingRow( + entries: readonly EffectivePricingEntry[], + modelKey: string, +): PricingRowView | null { + return derivePricingRows(entries).find((row) => row.modelKey === modelKey) ?? null; +} + +export interface PricingDraft { + readonly provider: string; + readonly model: 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; +} + +/** + * 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 draftFromRow(row: PricingRowView): { + readonly draft: PricingDraft; + readonly cacheOpen: boolean; +} { + return { + draft: { + provider: row.provider, + model: row.model, + input: row.inputUsdPer1M, + output: row.outputUsdPer1M, + cacheRead: row.cacheReadUsdPer1M ?? null, + cacheWrite: row.cacheWriteUsdPer1M ?? null, + }, + cacheOpen: row.cacheReadUsdPer1M !== undefined || row.cacheWriteUsdPer1M !== undefined, + }; +} + +export type PricingRateErrorCode = 'required' | 'invalid_rate'; +export type PricingKeyErrorCode = 'required' | 'key_too_long' | 'duplicate'; + +export interface PricingDraftErrors { + provider?: 'required'; + model?: 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 provider = draft.provider.trim(); + const model = draft.model.trim(); + if (provider === '') errors.provider = 'required'; + if (model === '') errors.model = 'required'; + if (provider !== '' && model !== '') { + const normalized = normalizePricingModelKey(pricingModelKey(provider, model)); + if (!normalized.ok) { + errors.model = 'key_too_long'; + } else if (options.existingKeys.includes(normalized.value)) { + errors.model = '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/testing.ts b/apps/desktop/src/renderer/features/usage/testing.ts new file mode 100644 index 0000000000..512c8fc523 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/testing.ts @@ -0,0 +1,34 @@ +/* + * 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 { + derivePricingRows, + 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 type { UsagePricingServices } from './pricing-ports.js'; +export type { UsageHostRef } from './ports.js'; +export { getPricingSettingsCopy } from './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..9d7e9b6e97 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx @@ -0,0 +1,531 @@ +/* + * 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, useMemo, useState, type ReactNode } from 'react'; +import { EmptyState, Heading, Skeleton, Text } from '@astryxdesign/core'; +import { AlertDialog } from '@astryxdesign/core/AlertDialog'; +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 '../pricing-copy.js'; +import { usePricingController } from '../controller/pricing-controller.js'; +import type { UsageHostRef } from '../ports.js'; +import type { PricingDraftErrors, PricingRowView } 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: PricingRowView }>; + +export function PricingEditor(props: { + readonly describeError: (error: unknown) => string; + readonly runtimeHost: UsageHostRef | undefined; + readonly generationKey: string; +}) { + const c = usePricingController({ + describeError: props.describeError, + runtimeHost: props.runtimeHost, + generationKey: props.generationKey, + }); + 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.modelKey, + pricingSourceLabel(row, copy), + formatUsd(row.inputUsdPer1M), + formatUsd(row.outputUsdPer1M), + formatCache(row.cacheReadUsdPer1M, copy), + formatCache(row.cacheWriteUsdPer1M, copy), + c.openEdit(row, trigger)} + onReset={(trigger) => c.openReset(row, trigger)} + />, + ]); + + return ( +
+
+
+ {copy.title} + {copy.subtitle} +
+ +
+ + {/* Panel-level write notice — visible when no editor is open (e.g. a reset + produced a conflict/uncertain outcome and closed its dialog). */} + {c.editor === null ? ( + + ) : null} + +
+ {c.loadError !== null ? ( + } + title={copy.loadFailedTitle} + description={copy.loadFailedBody} + actions={
+ + {c.editor !== null ? : null} + + { + if (!open) c.cancelReset(); + }} + title={c.resetTarget?.resetEffect === 'become_unpriced' ? copy.deleteTitle : copy.resetTitle} + description={ + c.resetTarget + ? c.resetTarget.resetEffect === 'become_unpriced' + ? copy.deleteBody(c.resetTarget.modelKey) + : copy.resetBody(c.resetTarget.modelKey) + : '' + } + actionLabel={c.resetTarget?.resetEffect === 'become_unpriced' ? copy.confirmDelete : copy.confirmReset} + cancelLabel={copy.cancel} + isActionLoading={c.resetBusy} + onAction={() => void c.confirmReset()} + /> +
+ ); +} + +function PricingRowActions(props: { + row: PricingRowView; + 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 ( + +
+ ); +} + +function PricingWriteNotice(props: { + writeState: ReturnType['writeState']; + latestEntry: PricingRowView | null; + copy: PricingSettingsCopy; +}) { + 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(formatUsd(latestEntry.inputUsdPer1M), formatUsd(latestEntry.outputUsdPer1M))}` + : ''; + 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: PricingRowView, copy: PricingSettingsCopy): string { + // Overrides-only: `row` is always a custom override here (the built-in catalog + // is never rendered as table rows), so the label is only the fallback split. + 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..268c6989b3 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(); @@ -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) => ( - - ))} - -
+ {/* #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) => ( + + ))} + +
-
- - - - +
+ + + + +
-
+ ) : null}
@@ -203,7 +211,7 @@ export function UsageSettingsView(props: { {tabCounts.providers}} /> {tabCounts.models}} /> {tabCounts.tools}} /> - {tabCounts.pricing}} /> +
@@ -249,7 +257,11 @@ export function UsageSettingsView(props: { {usageDraft.activeTab === 'pricing' ? (
- +
) : null}
@@ -420,22 +432,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/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..3aa72c844f --- /dev/null +++ b/apps/desktop/src/renderer/platform/desktop/create-usage-pricing-services.ts @@ -0,0 +1,38 @@ +/* + * 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 (not the app's +// active Host, which `bridge.settings.pricing.load(undefined)` would resolve). +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..e943a2947b 100644 --- a/apps/desktop/src/renderer/styles/settings/usage.css +++ b/apps/desktop/src/renderer/styles/settings/usage.css @@ -95,6 +95,35 @@ 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; +} + +/* Writes are blocked after an uncertain/refresh-failed outcome: dim the possibly + stale list until a fresh snapshot loads, so it does not read as authoritative. */ +.settingsPricingStale { + opacity: 0.6; +} + /* 任务 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..2166d81a37 --- /dev/null +++ b/apps/desktop/stories/settings/pricing-editor.stories.tsx @@ -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. + */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ToastProvider } from '@maka/ui'; +import { + PricingEditor, + UsagePricingServicesProvider, + 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 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'); + })} + /> + ), +}; diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index e81ab3eaea..bbf890b5e0 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -324,7 +324,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 +344,6 @@ const emptyUsageStats: UsageStats = { byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 4fc566c36c..6781e03357 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -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 | AlertDialog, Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading, Layout, LayoutContent, LayoutFooter, NumberInput, Skeleton, Text, TextInput, Typeahead, VStack | aligned — uses Astryx (AlertDialog, Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack) | 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 From 3c12534d667b4bc144b13fb67a145120b8a640ac Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Mon, 7 Sep 2026 00:23:28 +0800 Subject: [PATCH 2/7] fix(desktop): simplify pricing override editor state Consolidate mutation reconciliation in the Runtime Host protocol, fence stale Host results synchronously, and retain uncertain mutation intent for explicit refresh reconciliation. Remove duplicate copy, bridge-derived types, stale styling, and unused controller surface while preserving the concurrency and CAS invariants required for correctness. Generated-by: Codex --- apps/desktop/e2e/settings-pricing.spec.ts | 2 +- apps/desktop/renderer-architecture.json | 10 + .../src/main/__tests__/pricing-editor.test.ts | 369 ++++++++++++++++-- .../main/__tests__/pricing-view-model.test.ts | 43 +- .../runtime-host-client-pricing.test.ts | 2 +- .../runtime-host-pricing-ipc-main.test.ts | 1 - .../runtime-host-usage-ipc-main.test.ts | 6 - apps/desktop/src/main/runtime-host-boot.ts | 1 - apps/desktop/src/main/runtime-host-client.ts | 106 +---- .../src/main/runtime-host-usage-ipc-main.ts | 1 - apps/desktop/src/preload/bridge-contract.d.ts | 4 +- apps/desktop/src/preload/preload.ts | 4 +- .../src/renderer/features/usage/README.md | 29 +- .../usage/controller/pricing-controller.ts | 224 +++++++---- .../renderer/features/usage/pricing-ports.ts | 28 +- .../usage/pricing-services-context.tsx | 9 +- .../features/usage/pricing-view-model.ts | 79 ++-- .../features/usage/services-context.tsx | 26 +- .../src/renderer/features/usage/testing.ts | 2 +- .../features/usage/ui/pricing-editor.tsx | 117 +++--- .../features/usage/ui/usage-settings-view.tsx | 7 +- .../settings-pricing-copy.ts} | 64 +-- .../renderer/locales/settings-usage-copy.ts | 29 +- .../desktop/create-usage-pricing-services.ts | 5 +- .../src/renderer/styles/settings/usage.css | 6 - .../settings/pricing-editor.stories.tsx | 18 +- .../pricing-reconciliation-helpers.json | 5 + .../__tests__/usage-pricing-protocol.test.ts | 45 +++ .../src/protocol/usage-pricing.ts | 52 +++ 29 files changed, 856 insertions(+), 438 deletions(-) rename apps/desktop/src/renderer/{features/usage/pricing-copy.ts => locales/settings-pricing-copy.ts} (86%) create mode 100644 packages/runtime-host/protocol-compatible-changes/pricing-reconciliation-helpers.json diff --git a/apps/desktop/e2e/settings-pricing.spec.ts b/apps/desktop/e2e/settings-pricing.spec.ts index 5c3c10c963..53d432b467 100644 --- a/apps/desktop/e2e/settings-pricing.spec.ts +++ b/apps/desktop/e2e/settings-pricing.spec.ts @@ -67,7 +67,7 @@ test('pricing tab is overrides-only with a catalog-picker Add flow, is not time- const editor = page.getByRole('dialog', { name: '添加定价' }); await expect(editor).toBeVisible(); await editor.getByRole('button', { name: '模型不在列表中?手动输入' }).click(); - await expect(editor.getByRole('textbox', { name: '供应商' })).toBeVisible(); + await expect(editor.getByRole('textbox', { name: '模型键' })).toBeVisible(); // #2015 acceptance #11: closing the editor returns focus to the trigger. await editor.getByRole('button', { name: '取消' }).click(); 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__/pricing-editor.test.ts b/apps/desktop/src/main/__tests__/pricing-editor.test.ts index d4908cbd05..85d2225c11 100644 --- a/apps/desktop/src/main/__tests__/pricing-editor.test.ts +++ b/apps/desktop/src/main/__tests__/pricing-editor.test.ts @@ -83,7 +83,7 @@ describe('PricingEditor', () => { // 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 ?? '', new RegExp(copy.sourceBuiltin)); + 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 @@ -95,24 +95,83 @@ describe('PricingEditor', () => { 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 provider input, plus a toggle to + // 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.providerPlaceholder), + inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), undefined, - 'no free-text provider input in catalog mode', + '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 the free-text provider/model inputs + a toggle + // Switching to manual reveals one exact model-key input + a toggle // back to the catalog. await click(toManual); - assert.ok(inputByPlaceholder(harness.doc, copy.providerPlaceholder), 'manual provider input'); - assert.ok(inputByPlaceholder(harness.doc, copy.modelPlaceholder), 'manual model input'); + 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({ @@ -208,7 +267,7 @@ describe('PricingEditor', () => { await act(async () => harness.root.unmount()); }); - it('an uncertain outcome blocks writes and dims the possibly-stale list', async () => { + 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' }), @@ -218,10 +277,100 @@ describe('PricingEditor', () => { assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle)); assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true'); - assert.ok( - harness.container.querySelector('.settingsPricingStale'), - 'the possibly-stale list is dimmed while writes are blocked', - ); + 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)); + + await click(buttonByLabel(harness.doc, 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('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)); + await click(buttonByLabel(harness.doc, 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('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()); }); @@ -247,16 +396,132 @@ describe('PricingEditor', () => { await act(async () => harness.root.unmount()); }); - it('a Host generation change closes the editor and reloads fresh authority (P1.1)', async () => { - const harness = await renderEditor({ load: async () => SNAPSHOT }); + 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)); - assert.ok(buttonByText(harness.doc, copy.save), 'the Add dialog is open'); + 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 open editor is dropped (so a stale draft can't be saved onto the new - // authority) and a fresh reload runs. - assert.equal(buttonByText(harness.doc, copy.save), undefined, 'the editor is closed'); + // 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 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('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()); }); @@ -320,8 +585,7 @@ describe('PricingEditor', () => { // 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.providerPlaceholder), 'acme'); - await setInput(inputByPlaceholder(harness.doc, copy.modelPlaceholder), 'new'); + 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'), @@ -365,8 +629,7 @@ describe('PricingEditor', () => { // 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.providerPlaceholder), 'openai'); - await setInput(inputByPlaceholder(harness.doc, copy.modelPlaceholder), 'gpt-4o'); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'openai:gpt-4o'); const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter( (input) => !input.getAttribute('placeholder'), ); @@ -388,8 +651,7 @@ describe('PricingEditor', () => { 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.providerPlaceholder), 'anthropic'); - await setInput(inputByPlaceholder(harness.doc, copy.modelPlaceholder), 'claude'); + await setInput(inputByPlaceholder(harness.doc, copy.modelKeyPlaceholder), 'anthropic:claude'); const rateInputs = Array.from(harness.doc.querySelectorAll('input')).filter( (input) => !input.getAttribute('placeholder'), ); @@ -462,8 +724,8 @@ async function renderEditor(options: { const runtimeHost = options.host === null ? undefined : options.host ?? TEST_RUNTIME_HOST; let loadCalls = 0; - const loadHosts: Array = []; - const mutateHosts: Array = []; + 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 @@ -502,11 +764,18 @@ async function renderEditor(options: { const defaultGenerationKey = runtimeHost ? `${runtimeHost.profileId}:${runtimeHost.hostId}:e1` : 'no-host'; + let currentGenerationKey: string | null = defaultGenerationKey; function renderTree(generationKey: string): void { + currentGenerationKey = generationKey; const editor = createElement(PricingEditor, { describeError: (error: unknown) => (error instanceof Error ? error.message : String(error)), - runtimeHost, - generationKey, + target: runtimeHost + ? { + host: runtimeHost, + generationKey, + isCurrent: () => currentGenerationKey === generationKey, + } + : null, }); const provided = createElement(UsagePricingServicesProvider, { services, children: editor }); const toasted = createElement(ToastProvider, { children: provided }); @@ -530,6 +799,9 @@ async function renderEditor(options: { container, root: root as Root, rerender, + fenceTarget: () => { + currentGenerationKey = null; + }, loadCalls: () => loadCalls, loadHosts, mutateHosts, @@ -546,6 +818,32 @@ async function click(button: HTMLButtonElement | undefined) { }); } +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; @@ -603,3 +901,18 @@ function buttonByLabel(doc: Document, label: string): HTMLButtonElement | undefi 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 index 4ec2aae745..97021fd992 100644 --- a/apps/desktop/src/main/__tests__/pricing-view-model.test.ts +++ b/apps/desktop/src/main/__tests__/pricing-view-model.test.ts @@ -27,21 +27,25 @@ import { } from "../../renderer/features/usage/testing.js"; const EMPTY: PricingDraft = { - provider: "", - model: "", + modelKey: "", input: null, output: null, cacheRead: null, cacheWrite: null, }; -test("derivePricingRows maps source, split, and cache presence", () => { +test("derivePricingRows maps source and cache presence", () => { const entries: EffectivePricingEntry[] = [ { source: "custom", resetEffect: "become_unpriced", pricing: { modelKey: "acme:coder-v2", inputUsdPer1M: 0.8, outputUsdPer1M: 2.4 }, }, + { + source: "custom", + resetEffect: "restore_builtin", + pricing: { modelKey: "anthropic:claude", inputUsdPer1M: 2, outputUsdPer1M: 12 }, + }, { source: "builtin", pricing: { @@ -51,23 +55,16 @@ test("derivePricingRows maps source, split, and cache presence", () => { cacheReadUsdPer1M: 0, }, }, - { - source: "custom", - resetEffect: "restore_builtin", - pricing: { modelKey: "anthropic:claude", inputUsdPer1M: 2, outputUsdPer1M: 12 }, - }, ]; const rows = derivePricingRows(entries); - // Canonical key order, not input order. + // The adapter-provided canonical key order is preserved. assert.deepEqual( rows.map((row) => row.modelKey), ["acme:coder-v2", "anthropic:claude", "openai:gpt-4o"], ); const acme = rows[0]!; - assert.equal(acme.provider, "acme"); - assert.equal(acme.model, "coder-v2"); assert.equal(acme.source, "custom"); assert.equal(acme.resetEffect, "become_unpriced"); @@ -82,10 +79,9 @@ test("derivePricingRows maps source, split, and cache presence", () => { assert.equal(openai.cacheWriteUsdPer1M, undefined); }); -test("validatePricingDraft add flags empty provider/model", () => { +test("validatePricingDraft add flags an empty model key", () => { const result = validatePricingDraft(EMPTY, { mode: "add", existingKeys: [] }); - assert.equal(result.errors.provider, "required"); - assert.equal(result.errors.model, "required"); + assert.equal(result.errors.modelKey, "required"); assert.equal(result.errors.input, "required"); assert.equal(result.errors.output, "required"); assert.equal(result.hasErrors, true); @@ -93,19 +89,18 @@ test("validatePricingDraft add flags empty provider/model", () => { }); test("validatePricingDraft add flags a duplicate key against existing rows", () => { - const draft: PricingDraft = { ...EMPTY, provider: "openai", model: "gpt-4o", input: 1, output: 2 }; + 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.model, "duplicate"); + 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 = { - provider: "acme", - model: "coder-v2", + modelKey: " DeepInfra:org/Model:Preview ", input: 0.8, output: 2.4, cacheRead: null, @@ -114,7 +109,7 @@ test("validatePricingDraft add builds a canonical config; blank cache is omitted const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); assert.equal(result.hasErrors, false); assert.deepEqual(result.config, { - modelKey: "acme:coder-v2", + modelKey: "DeepInfra:org/Model:Preview", inputUsdPer1M: 0.8, outputUsdPer1M: 2.4, }); @@ -123,8 +118,7 @@ test("validatePricingDraft add builds a canonical config; blank cache is omitted test("validatePricingDraft keeps an explicit 0 cache rate distinct from blank", () => { const draft: PricingDraft = { - provider: "acme", - model: "coder-v2", + modelKey: "acme:coder-v2", input: 1, output: 2, cacheRead: 0, @@ -136,16 +130,15 @@ test("validatePricingDraft keeps an explicit 0 cache rate distinct from blank", }); test("validatePricingDraft rejects a negative rate", () => { - const draft: PricingDraft = { ...EMPTY, provider: "a", model: "b", input: -1, output: 2 }; + 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 provider/model", () => { +test("validatePricingDraft edit locks the key and ignores the draft key", () => { const draft: PricingDraft = { - provider: "ignored", - model: "ignored", + modelKey: "ignored", input: 3, output: 4, cacheRead: null, 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 index ec33af648e..1145c13c66 100644 --- 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 @@ -56,7 +56,6 @@ test("pricing IPC registers the two capabilities and fences the legacy handlers" registerRuntimeHostUsageIpc({ ipcMain, client: {} as unknown as DesktopRuntimeHostClient, - sendToRenderer: () => undefined, }); registerRuntimeHostPricingIpc({ ipcMain, client: {} as unknown as DesktopRuntimeHostClient }); 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 6c4e343475..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 @@ -88,7 +88,6 @@ test("settings usage stats use the canonical model-call total and load every act } satisfies UsageQueryResult; }, } as unknown as DesktopRuntimeHostClient, - sendToRenderer: () => undefined, }); const handler = handlers.get("settings:usageStats"); @@ -185,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"); @@ -253,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"); @@ -331,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"); @@ -410,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"); @@ -495,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/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index e124f8abd8..76b00f481d 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1730,7 +1730,6 @@ function registerHostClientIpc( registerRuntimeHostUsageIpc({ ipcMain: scopedIpc, client, - sendToRenderer, }); registerRuntimeHostPricingIpc({ ipcMain: scopedIpc, client }); registerRuntimeHostWorkspaceIpc({ diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 2f2a78f758..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">; @@ -711,7 +674,7 @@ export class DesktopRuntimeHostClient { expectedRevision: input.base.revision, mutation: input.mutation, }); - const target = createPricingReconciliationTarget(input.base, request.mutation); + const target = createPricingReconciliationTarget(input.base.entries, request.mutation); return this.#reconcilePricingMutation(target, reason); } @@ -1760,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, @@ -2014,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-usage-ipc-main.ts b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts index ca84e8496a..5b89676c94 100644 --- a/apps/desktop/src/main/runtime-host-usage-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-usage-ipc-main.ts @@ -39,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; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index cbe1a68c55..b755182ee1 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1416,11 +1416,11 @@ export interface MakaBridge { testBotChannel(provider: BotProvider): Promise; usageStats(range?: UsageRange, host?: DesktopRuntimeHostRef): Promise; pricing: { - load(host?: DesktopRuntimeHostRef): Promise; + load(host: DesktopRuntimeHostRef): Promise; mutate( base: DesktopPricingSnapshot, mutation: PricingMutation, - host?: DesktopRuntimeHostRef, + host: DesktopRuntimeHostRef, ): Promise; }; bots: { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 8ee82e30ac..70ff50ffb4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3317,7 +3317,7 @@ const makaBridge = { 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 { + async load(host: DesktopRuntimeHostRef): Promise { const result = await invokeSelectedRuntimeHost>( host, 'usage:pricing:load', @@ -3330,7 +3330,7 @@ const makaBridge = { async mutate( base: DesktopPricingSnapshot, mutation: PricingMutation, - host?: DesktopRuntimeHostRef, + host: DesktopRuntimeHostRef, ): Promise { const result = await invokeSelectedRuntimeHost>( host, diff --git a/apps/desktop/src/renderer/features/usage/README.md b/apps/desktop/src/renderer/features/usage/README.md index 48c7e7f5b7..ef640fc1aa 100644 --- a/apps/desktop/src/renderer/features/usage/README.md +++ b/apps/desktop/src/renderer/features/usage/README.md @@ -39,6 +39,15 @@ 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` — Pricing authority, draft, conflict, and + Host-generation fencing. A Host change preserves an open draft, discards its + old mutation base, 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()` @@ -50,13 +59,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 @@ -69,9 +77,14 @@ via context. The scope takes a `host:epoch` `targetKey` as a **prop** (not a Rea so a Host change never remounts the rest of the Settings surface. 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 @@ -91,8 +104,6 @@ shim, since `settings-error-copy` is not a copy catalog. 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. - 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 index ee67039f6e..354cef7c49 100644 --- a/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts +++ b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts @@ -19,11 +19,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useToast, useUiLocale } from '@maka/ui'; -import type { PricingMutation } from '@maka/runtime-host/protocol'; +import { + createPricingReconciliationTarget, + pricingReconciliationTargetMatches, + pricingReconciliationTargetModelKey, + type PricingMutation, + type PricingReconciliationTarget, +} from '@maka/runtime-host/protocol'; import { useUsagePricingServices } from '../pricing-services-context.js'; -import type { UsagePricingServices } from '../pricing-ports.js'; -import type { UsageHostRef } from '../ports.js'; -import { getPricingSettingsCopy } from '../pricing-copy.js'; +import type { UsagePricingServices, UsagePricingTarget } from '../pricing-ports.js'; +import { getPricingSettingsCopy } from '../../../locales/settings-pricing-copy.js'; import { useActionGuard } from './action-guard.js'; import { derivePricingRows, @@ -34,9 +39,8 @@ import { type PricingRowView, } from '../pricing-view-model.js'; -// Desktop pricing shapes derive from the `UsagePricingServices` port (whose -// types come from the global `window.maka.settings.pricing` bridge), so the -// feature names them without importing the preload/`shared` Desktop types. +// 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>; @@ -56,13 +60,17 @@ export type PricingWriteState = readonly kind: 'conflict'; readonly latest: DesktopPricingSnapshot; readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly intent: PricingReconciliationTarget; } | { readonly kind: 'refresh_failed' } - | { readonly kind: 'reconcile_unavailable'; readonly reason: 'revision_conflict' | 'outcome_unknown' }; + | { + readonly kind: 'reconcile_unavailable'; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + readonly intent: PricingReconciliationTarget; + }; const EMPTY_DRAFT: PricingDraft = { - provider: '', - model: '', + modelKey: '', input: null, output: null, cacheRead: null, @@ -72,22 +80,8 @@ const EMPTY_DRAFT: PricingDraft = { /** Owns the Host-backed Pricing snapshot, the editor draft, and every outcome. */ export function usePricingController(props: { readonly describeError: (error: unknown) => string; - /** - * The settings-*selected* Runtime Host (threaded as a prop from the legacy - * surface, not resolved at the bridge). Pricing overrides are per-Host, so the - * Pricing tab must read/write the same Host as the rest of the settings page — - * the app's *active* Host (what an omitted bridge arg would resolve) can differ - * because the settings surface has its own Host selector. - */ - readonly runtimeHost: UsageHostRef | undefined; - /** - * The Usage scope's `targetKey` (`host:epoch`). Pricing services come from a - * single app-root provider (not a Host-keyed one), so a Host/generation change - * does not remount this controller; instead this key changes and the reload - * effect below re-fetches against the fresh Host — mirroring the previous - * surface's reload-on-generation behaviour. - */ - readonly generationKey: string; + /** Settings-selected Host plus its lifecycle generation (`host:epoch`). */ + readonly target: UsagePricingTarget | null; }) { const services = useUsagePricingServices(); const { describeError } = props; @@ -102,6 +96,7 @@ export function usePricingController(props: { const [draft, setDraft] = useState(EMPTY_DRAFT); const [cacheOpen, setCacheOpen] = useState(false); const [writeState, setWriteState] = useState({ kind: 'idle' }); + const [needsReview, setNeedsReview] = useState(false); const [saving, setSaving] = useState(false); const [resetTarget, setResetTarget] = useState(null); const [resetBusy, setResetBusy] = useState(false); @@ -121,6 +116,27 @@ export function usePricingController(props: { // changed while it was in flight — an old-generation save must never write // back onto a freshly loaded snapshot. const generationEpochRef = 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); + generationEpochRef.current += 1; + reloadTicketRef.current += 1; + setSnapshot(null); + setLoading(true); + setLoadError(null); + setWriteState({ kind: 'idle' }); + setNeedsReview(editor !== null); + setResetTarget(null); + setSaving(false); + setResetBusy(false); + guard.finish(); + } useEffect(() => { lifecycleRef.current += 1; @@ -133,16 +149,23 @@ export function usePricingController(props: { }; }, []); - function isCurrent(lifecycle: number, epoch: number): boolean { + function isCurrent( + lifecycle: number, + epoch: number, + target = props.target, + ): boolean { return ( mountedRef.current && lifecycleRef.current === lifecycle && - generationEpochRef.current === epoch + generationEpochRef.current === epoch && + (target === null || target.isCurrent()) ); } async function reload(): Promise { - const host = props.runtimeHost; + const host = props.target?.host; + const pendingReconciliation = + writeState.kind === 'reconcile_unavailable' ? writeState : undefined; const lifecycle = lifecycleRef.current; const epoch = generationEpochRef.current; const ticket = ++reloadTicketRef.current; @@ -164,7 +187,23 @@ export function usePricingController(props: { if (!isCurrent(lifecycle, epoch) || ticket !== reloadTicketRef.current) return; setSnapshot(next); setLoadError(null); - setWriteState({ kind: 'idle' }); + if (pendingReconciliation) { + if (pricingReconciliationTargetMatches(pendingReconciliation.intent, next.entries)) { + setWriteState({ kind: 'idle' }); + finishReconciledIntent(pendingReconciliation.intent); + toast.success(copy.synchronized); + } else { + setWriteState({ + kind: 'conflict', + latest: next, + reason: pendingReconciliation.reason, + intent: pendingReconciliation.intent, + }); + restoreReconciledIntent(pendingReconciliation.intent, next); + } + } else { + setWriteState({ kind: 'idle' }); + } } catch (error) { if (!isCurrent(lifecycle, epoch) || ticket !== reloadTicketRef.current) return; setLoadError(describeError(error)); @@ -175,32 +214,15 @@ export function usePricingController(props: { // Load on mount and whenever the selected Host generation changes. Pricing // services come from a single app-root provider, so a Host change does not - // remount this controller; the `generationKey` prop (the Usage scope's + // remount this controller; the target's `generationKey` (the Usage scope's // `host:epoch`) changes instead, which resets the snapshot and reloads — // replacing the previous surface's generation-key remount. A generation bump - // also fences any in-flight mutation from an older Host (`isCurrent`). The - // draft is intentionally dropped on a generation change. + // also fences any in-flight mutation from an older Host (`isCurrent`). An + // open draft is intentionally retained on a generation change. useEffect(() => { - generationEpochRef.current += 1; - // A Host generation change is a fresh authority/list. Fence any in-flight - // reload, drop the snapshot, and reset ALL transient interaction state: - // close the editor and clear the draft (so an old-Host draft can't be saved - // onto the new authority), clear the reset target, and release both busy - // latches + the action guard (so a mutation whose `finally` no longer runs - // — its epoch changed — can't leave a dialog stuck saving/resetting). - reloadTicketRef.current += 1; - setSnapshot(null); - setWriteState({ kind: 'idle' }); - setEditor(null); - setDraft(EMPTY_DRAFT); - setCacheOpen(false); - setResetTarget(null); - setSaving(false); - setResetBusy(false); - guard.finish(); void reload(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.generationKey]); + }, [targetKey]); const rows = useMemo(() => derivePricingRows(snapshot?.entries ?? []), [snapshot]); // Overrides-only surface (#2015 / maintainer direction on #2218): the table @@ -227,22 +249,18 @@ export function usePricingController(props: { ); const writesBlocked = - writeState.kind === 'refresh_failed' || writeState.kind === 'reconcile_unavailable'; + needsReview || + writeState.kind === 'refresh_failed' || + writeState.kind === 'reconcile_unavailable'; // 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 = - editor?.mode === 'edit' - ? editor.row.modelKey - : editor?.mode === 'add' - ? (validation.config?.modelKey ?? null) - : (resetTarget?.modelKey ?? null); - if (!key) return null; + const key = pricingReconciliationTargetModelKey(writeState.intent); return findPricingRow(writeState.latest.entries, key); - }, [writeState, editor, resetTarget, validation]); + }, [writeState]); function restoreTriggerFocus() { const trigger = triggerRef.current; @@ -282,19 +300,58 @@ export function usePricingController(props: { setCacheOpen(prefill.cacheOpen); } + function clearModel() { + setDraft((current) => ({ + ...current, + modelKey: '', + input: null, + output: null, + cacheRead: null, + cacheWrite: null, + })); + setCacheOpen(false); + } + + function reviewHostChange() { + if (needsReview && snapshot !== null) setNeedsReview(false); + } + function closeEditor() { if (saving) return; setEditor(null); - if (writeState.kind === 'conflict') setWriteState({ kind: 'idle' }); + setNeedsReview(false); + if (writeState.kind === 'conflict' && snapshot === writeState.latest) { + setWriteState({ kind: 'idle' }); + } restoreTriggerFocus(); } const setField = (key: K, value: PricingDraft[K]) => setDraft((current) => ({ ...current, [key]: value })); + function finishReconciledIntent(intent: PricingReconciliationTarget): void { + if (intent.kind === 'upsert') setEditor(null); + else setResetTarget(null); + restoreTriggerFocus(); + } + + function restoreReconciledIntent( + intent: PricingReconciliationTarget, + latest: DesktopPricingSnapshot, + ): void { + if (intent.kind === 'upsert') { + const latestRow = findPricingRow(latest.entries, intent.pricing.modelKey); + if (editor?.mode === 'add' && latestRow) setEditor({ mode: 'edit', row: latestRow }); + return; + } + const latestRow = findPricingRow(latest.entries, intent.modelKey); + if (latestRow?.source === 'custom') setResetTarget(latestRow); + } + /** Map a settled outcome to state; `onCommitted` runs on saved/synchronized. */ function applyOutcome( outcome: DesktopPricingMutationOutcome, + intent: PricingReconciliationTarget, onCommitted: () => void, attemptedKey?: string, ): void { @@ -321,7 +378,7 @@ export function usePricingController(props: { // Adopt fresh authority into the list so it is no longer speculative, // keep the draft, and require an explicit second save against `latest`. setSnapshot(outcome.snapshot); - setWriteState({ kind: 'conflict', latest: outcome.snapshot, reason: outcome.reason }); + setWriteState({ kind: 'conflict', latest: outcome.snapshot, 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 @@ -340,7 +397,7 @@ export function usePricingController(props: { setWriteState({ kind: 'refresh_failed' }); return; case 'reconciliation_unavailable': - setWriteState({ kind: 'reconcile_unavailable', reason: outcome.reason }); + setWriteState({ kind: 'reconcile_unavailable', reason: outcome.reason, intent }); return; } } @@ -353,17 +410,20 @@ export function usePricingController(props: { async function save() { const config = validation.config; const base = mutationBase(); - if (!config || !base || saving) return; + const target = props.target; + const host = target?.host; + if (!config || !base || !host || saving) return; if (!guard.begin('write')) return; const lifecycle = lifecycleRef.current; const epoch = generationEpochRef.current; setSaving(true); try { const mutation: PricingMutation = { kind: 'upsert', pricing: config }; - const outcome = await services.mutatePricing(props.runtimeHost, base, mutation); - if (!isCurrent(lifecycle, epoch)) return; + const outcome = await services.mutatePricing(host, base, mutation); + if (!isCurrent(lifecycle, epoch, target)) return; applyOutcome( outcome, + createPricingReconciliationTarget(base.entries, mutation), () => { setEditor(null); restoreTriggerFocus(); @@ -371,12 +431,14 @@ export function usePricingController(props: { config.modelKey, ); } catch (error) { - if (isCurrent(lifecycle, epoch)) { + if (isCurrent(lifecycle, epoch, target)) { toast.error(copy.saveFailed, describeError(error)); } } finally { - guard.finish(); - if (isCurrent(lifecycle, epoch)) setSaving(false); + if (isCurrent(lifecycle, epoch, target)) { + guard.finish(); + setSaving(false); + } } } @@ -395,19 +457,21 @@ export function usePricingController(props: { async function confirmReset() { const target = resetTarget; const base = mutationBase(); - if (!target || !base || resetBusy) return; + const pricingTarget = props.target; + const host = pricingTarget?.host; + if (!target || !base || !host || resetBusy) return; if (!guard.begin('write')) return; const lifecycle = lifecycleRef.current; const epoch = generationEpochRef.current; setResetBusy(true); try { const mutation: PricingMutation = { kind: 'delete', modelKey: target.modelKey }; - const outcome = await services.mutatePricing(props.runtimeHost, base, mutation); - if (!isCurrent(lifecycle, epoch)) return; - applyOutcome(outcome, () => { + const intent = createPricingReconciliationTarget(base.entries, mutation); + const outcome = await services.mutatePricing(host, base, mutation); + if (!isCurrent(lifecycle, epoch, pricingTarget)) return; + applyOutcome(outcome, intent, () => { setResetTarget(null); restoreTriggerFocus(); - toast.success(copy.resetDone); }); // A conflict keeps the confirm dialog open for an explicit second // confirm against fresh authority (mutationBase() now returns `latest`). @@ -420,12 +484,14 @@ export function usePricingController(props: { setResetTarget(null); } } catch (error) { - if (isCurrent(lifecycle, epoch)) { + if (isCurrent(lifecycle, epoch, pricingTarget)) { toast.error(copy.resetFailed, describeError(error)); } } finally { - guard.finish(); - if (isCurrent(lifecycle, epoch)) setResetBusy(false); + if (isCurrent(lifecycle, epoch, pricingTarget)) { + guard.finish(); + setResetBusy(false); + } } } @@ -437,11 +503,11 @@ export function usePricingController(props: { // a load pending/failed) the Add flow is disabled rather than silently // no-opping on save. hasAuthority: snapshot !== null, - rows, // Overrides-only table + catalog picker for the Add flow. overrideRows, catalogRows, pickCatalogModel, + clearModel, editor, draft, setField, @@ -449,7 +515,9 @@ export function usePricingController(props: { setCacheOpen, validation, writeState, + needsReview, writesBlocked, + reviewHostChange, conflictLatestEntry, saving, resetTarget, diff --git a/apps/desktop/src/renderer/features/usage/pricing-ports.ts b/apps/desktop/src/renderer/features/usage/pricing-ports.ts index 0b3d4cf31f..9c8ac0a056 100644 --- a/apps/desktop/src/renderer/features/usage/pricing-ports.ts +++ b/apps/desktop/src/renderer/features/usage/pricing-ports.ts @@ -29,12 +29,20 @@ // what keeps a new bridge path out of the frozen legacy-AppShell closure files // (the renderer-architecture ratchet forbids growing their bridge paths). // -// Types derive from the global `window.maka.settings.pricing` bridge as a -// type-only reference (no runtime bridge access, so no bridge path is recorded -// for this feature file) — the feature names the Host-scoped snapshot/outcome -// shapes without importing the preload/`shared` Desktop types. +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 @@ -43,13 +51,11 @@ export interface UsagePricingServices { * 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 | undefined, - ): Promise>>; + loadPricing(host: UsageHostRef): Promise; /** Apply one pricing upsert/delete against the viewed snapshot (the CAS base). */ mutatePricing( - host: UsageHostRef | undefined, - base: Parameters[0], - mutation: Parameters[1], - ): Promise>>; + 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 index 0935c4b449..0759a3414f 100644 --- a/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx +++ b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx @@ -20,12 +20,9 @@ import { createServicesContext } from '../../application/contracts/feature-services.js'; import type { UsagePricingServices } from './pricing-ports.js'; -// The pricing services are Host-agnostic at this seam: the platform adapter -// targets the settings-selected Runtime Host inside the preload bridge, so a -// single app-root provider serves every mount. A Host/generation change is -// surfaced to the pricing controller via the Usage scope's `targetKey` (threaded -// as `generationKey`), which drives the reload — not by remounting a keyed -// provider. +// 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'); diff --git a/apps/desktop/src/renderer/features/usage/pricing-view-model.ts b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts index c549c7d0cc..5cc7b3b781 100644 --- a/apps/desktop/src/renderer/features/usage/pricing-view-model.ts +++ b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts @@ -20,24 +20,17 @@ /** * Pure derivations for the Pricing Settings panel — no React, no IPC — so the * row projection and the editor validation are unit-testable without a - * renderer. The Host already returns entries as the canonical built-in ∪ - * overrides union in key order; this only maps them to display rows (re-sorting - * defensively) and mirrors the Host's `normalizePricingConfig` rules per-field. + * renderer. The Host adapter already validates the canonical built-in ∪ + * overrides order; this maps entries to display rows and mirrors the Host's + * `normalizePricingConfig` rules per-field. */ -import { - comparePricingModelKeys, - normalizePricingModelKey, - pricingModelKey, -} from '@maka/core/usage-stats/pricing'; +import { normalizePricingModelKey } from '@maka/core/usage-stats/pricing'; import type { PricingConfig } from '@maka/core/usage-stats/types'; import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; export interface PricingRowView { readonly modelKey: string; - /** Display-only split of `modelKey` on its first colon. */ - readonly provider: string; - readonly model: string; readonly source: 'builtin' | 'custom'; /** null for a built-in row; the delete consequence for a custom row. */ readonly resetEffect: 'restore_builtin' | 'become_unpriced' | null; @@ -51,25 +44,7 @@ export interface PricingRowView { export function derivePricingRows( entries: readonly EffectivePricingEntry[], ): PricingRowView[] { - return [...entries] - .sort((left, right) => - comparePricingModelKeys(left.pricing.modelKey, right.pricing.modelKey), - ) - .map((entry) => { - const key = entry.pricing.modelKey; - const separator = key.indexOf(':'); - return { - modelKey: key, - provider: separator < 0 ? '' : key.slice(0, separator), - model: separator < 0 ? key : key.slice(separator + 1), - source: entry.source, - resetEffect: entry.source === 'custom' ? entry.resetEffect : null, - inputUsdPer1M: entry.pricing.inputUsdPer1M, - outputUsdPer1M: entry.pricing.outputUsdPer1M, - cacheReadUsdPer1M: entry.pricing.cacheReadUsdPer1M, - cacheWriteUsdPer1M: entry.pricing.cacheWriteUsdPer1M, - }; - }); + return entries.map(pricingRowFromEntry); } /** The derived row for `modelKey` within a snapshot's entries, or null. */ @@ -77,12 +52,24 @@ export function findPricingRow( entries: readonly EffectivePricingEntry[], modelKey: string, ): PricingRowView | null { - return derivePricingRows(entries).find((row) => row.modelKey === modelKey) ?? null; + const entry = entries.find(({ pricing }) => pricing.modelKey === modelKey); + return entry ? pricingRowFromEntry(entry) : null; +} + +function pricingRowFromEntry(entry: EffectivePricingEntry): PricingRowView { + return { + modelKey: entry.pricing.modelKey, + source: entry.source, + resetEffect: entry.source === 'custom' ? entry.resetEffect : null, + inputUsdPer1M: entry.pricing.inputUsdPer1M, + outputUsdPer1M: entry.pricing.outputUsdPer1M, + cacheReadUsdPer1M: entry.pricing.cacheReadUsdPer1M, + cacheWriteUsdPer1M: entry.pricing.cacheWriteUsdPer1M, + }; } export interface PricingDraft { - readonly provider: string; - readonly model: string; + readonly modelKey: string; /** `null` = the field is empty (a cleared NumberInput). */ readonly input: number | null; readonly output: number | null; @@ -101,8 +88,7 @@ export function draftFromRow(row: PricingRowView): { } { return { draft: { - provider: row.provider, - model: row.model, + modelKey: row.modelKey, input: row.inputUsdPer1M, output: row.outputUsdPer1M, cacheRead: row.cacheReadUsdPer1M ?? null, @@ -116,8 +102,7 @@ export type PricingRateErrorCode = 'required' | 'invalid_rate'; export type PricingKeyErrorCode = 'required' | 'key_too_long' | 'duplicate'; export interface PricingDraftErrors { - provider?: 'required'; - model?: PricingKeyErrorCode; + modelKey?: PricingKeyErrorCode; input?: PricingRateErrorCode; output?: PricingRateErrorCode; cacheRead?: 'invalid_rate'; @@ -146,19 +131,13 @@ export function validatePricingDraft( if (options.mode === 'edit') { modelKey = options.lockedModelKey ?? null; } else { - const provider = draft.provider.trim(); - const model = draft.model.trim(); - if (provider === '') errors.provider = 'required'; - if (model === '') errors.model = 'required'; - if (provider !== '' && model !== '') { - const normalized = normalizePricingModelKey(pricingModelKey(provider, model)); - if (!normalized.ok) { - errors.model = 'key_too_long'; - } else if (options.existingKeys.includes(normalized.value)) { - errors.model = 'duplicate'; - } else { - modelKey = normalized.value; - } + 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; } } diff --git a/apps/desktop/src/renderer/features/usage/services-context.tsx b/apps/desktop/src/renderer/features/usage/services-context.tsx index 5993bc2327..caa3667890 100644 --- a/apps/desktop/src/renderer/features/usage/services-context.tsx +++ b/apps/desktop/src/renderer/features/usage/services-context.tsx @@ -53,6 +53,8 @@ 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; } const UsageScopeContext = createContext(null); @@ -93,7 +95,13 @@ export const UsageFeatureScope = forwardRef< const [snapshot, setSnapshot] = 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 +109,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 +146,7 @@ export const UsageFeatureScope = forwardRef< () => ({ fenceTarget: () => { reloadTicketRef.current += 1; + targetCurrentRef.current = false; setSnapshot(null); }, }), @@ -143,8 +154,14 @@ export const UsageFeatureScope = forwardRef< ); const value = useMemo( - () => ({ services, snapshot, targetKey, reload }), - [services, snapshot, targetKey, reload], + () => ({ + services, + snapshot, + targetKey, + reload, + isCurrentTarget, + }), + [services, snapshot, targetKey, reload, isCurrentTarget], ); return {props.children}; @@ -173,8 +190,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 index 512c8fc523..bb3d012938 100644 --- a/apps/desktop/src/renderer/features/usage/testing.ts +++ b/apps/desktop/src/renderer/features/usage/testing.ts @@ -31,4 +31,4 @@ export { PricingEditor, formatCache, formatUsd } from './ui/pricing-editor.js'; export { UsagePricingServicesProvider } from './pricing-services-context.js'; export type { UsagePricingServices } from './pricing-ports.js'; export type { UsageHostRef } from './ports.js'; -export { getPricingSettingsCopy } from './pricing-copy.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 index 9d7e9b6e97..f8ee55174a 100644 --- a/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx +++ b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx @@ -26,9 +26,9 @@ 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 '../pricing-copy.js'; +import type { PricingSettingsCopy } from '../../../locales/settings-pricing-copy.js'; import { usePricingController } from '../controller/pricing-controller.js'; -import type { UsageHostRef } from '../ports.js'; +import type { UsagePricingTarget } from '../pricing-ports.js'; import type { PricingDraftErrors, PricingRowView } from '../pricing-view-model.js'; import { UsageStatsTable, type UsageColumn } from './usage-stats-table.js'; @@ -37,13 +37,11 @@ type CatalogItem = SearchableItem<{ row: PricingRowView }>; export function PricingEditor(props: { readonly describeError: (error: unknown) => string; - readonly runtimeHost: UsageHostRef | undefined; - readonly generationKey: string; + readonly target: UsagePricingTarget | null; }) { const c = usePricingController({ describeError: props.describeError, - runtimeHost: props.runtimeHost, - generationKey: props.generationKey, + target: props.target, }); const { copy } = c; @@ -132,7 +130,7 @@ export function PricingEditor(props: { actions={
@@ -289,22 +283,13 @@ function PricingEditorDialog(props: { { event.preventDefault(); submit(); }}> {isEdit ? ( // Editing an existing override: the key is fixed, shown read-only. - <> - c.setField('provider', value)} - label={copy.providerLabel} - isReadOnly - width="100%" - /> - c.setField('model', value)} - label={copy.modelLabel} - isReadOnly - width="100%" - /> - + c.setField('modelKey', value)} + label={copy.modelKeyLabel} + isReadOnly + width="100%" + /> ) : addMode === '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 @@ -319,8 +304,7 @@ function PricingEditorDialog(props: { if (item) { c.pickCatalogModel(item.auxiliaryData!.row); } else { - c.setField('provider', ''); - c.setField('model', ''); + c.clearModel(); } }} placeholder={copy.catalogPickerPlaceholder} @@ -331,7 +315,7 @@ function PricingEditorDialog(props: { maxMenuItems={12} hasClear width="100%" - status={fieldStatus(errorMessage(validation.errors.model))} + status={fieldStatus(errorMessage(validation.errors.modelKey))} /> {picked ? ( {copy.builtinPrefillHint} @@ -343,35 +327,25 @@ function PricingEditorDialog(props: { label={copy.manualEntryToggle} onClick={() => { setPicked(null); - c.setField('provider', ''); - c.setField('model', ''); + c.clearModel(); setAddMode('manual'); }} /> ) : ( - // Manual fallback: an arbitrary key for a model not in the catalog. + // Manual fallback: paste the exact Runtime lookup key. c.setField('provider', value)} - label={copy.providerLabel} - placeholder={copy.providerPlaceholder} - isRequired - hasAutoFocus - width="100%" - status={fieldStatus(errorMessage(validation.errors.provider))} - /> - c.setField('model', value)} - label={copy.modelLabel} - placeholder={copy.modelPlaceholder} + value={draft.modelKey} + onChange={(value) => c.setField('modelKey', value)} + label={copy.modelKeyLabel} + placeholder={copy.modelKeyPlaceholder} description={copy.keyHelp} isRequired + hasAutoFocus width="100%" - status={fieldStatus(errorMessage(validation.errors.model))} + status={fieldStatus(errorMessage(validation.errors.modelKey))} />