From 6d23dbcceb229b36d5d3ae9bd65f63307d779fab Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Thu, 3 Sep 2026 21:45:03 +0800 Subject: [PATCH] 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/settings-pricing.spec.ts | 76 +++ .../desktop-session-projection.test.ts | 1 - .../src/main/__tests__/pricing-editor.test.ts | 542 ++++++++++++++++++ .../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 | 7 +- .../usage/controller/pricing-controller.ts | 468 +++++++++++++++ .../src/renderer/features/usage/index.ts | 6 + .../src/renderer/features/usage/ports.ts | 12 + .../renderer/features/usage/pricing-copy.ts | 258 +++++++++ .../renderer/features/usage/pricing-ports.ts | 55 ++ .../usage/pricing-services-context.tsx | 46 ++ .../features/usage/pricing-view-model.ts | 168 ++++++ .../src/renderer/features/usage/testing.ts | 34 ++ .../features/usage/ui/pricing-editor.tsx | 513 +++++++++++++++++ .../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 | 4 +- docs/astryx-surface-file-inventory.paths | 2 + packages/core/src/settings.ts | 6 - 35 files changed, 3242 insertions(+), 180 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/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 cd12d9bb18..2bd643284a 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -226,7 +226,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..20ca5d12d8 --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-editor.test.ts @@ -0,0 +1,542 @@ +/* + * 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); + 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()); + }); +}); + +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 74e68af3ee..2569a85c70 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -240,6 +240,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 { @@ -1532,6 +1533,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 5a39dba44b..7970b30d69 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -686,6 +686,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 d68e5c75c2..c94e46c8b7 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -138,6 +138,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, @@ -1450,6 +1455,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 b4013452f4..040c878bcf 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -208,6 +208,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, @@ -3110,6 +3115,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 91755a2677..56d53f1487 100644 --- a/apps/desktop/src/renderer/composition/desktop-feature-services.tsx +++ b/apps/desktop/src/renderer/composition/desktop-feature-services.tsx @@ -26,6 +26,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 { createDesktopGoalServices } from '../platform/desktop/create-goal-services'; import { createDesktopConnectionSettingsServices } from '../platform/desktop/create-connection-settings-services'; @@ -35,6 +36,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'; export function createDesktopFeatureServices() { @@ -47,6 +49,7 @@ export function createDesktopFeatureServices() { sessionNavigation: createDesktopSessionNavigationServices(), sessionSettings: createDesktopSessionSettingsServices(), taskEntry: createDesktopTaskEntryServices(), + usagePricing: createDesktopUsagePricingServices(), workbar: createDesktopWorkbarServices(), }; } @@ -65,7 +68,9 @@ 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..b7a42b2905 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/controller/pricing-controller.ts @@ -0,0 +1,468 @@ +/* + * 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, + 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]); + const existingKeys = useMemo(() => rows.map((row) => row.modelKey), [rows]); + // 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. + // `existingKeys` stays over the FULL union for duplicate detection. + const overrideRows = useMemo(() => rows.filter((row) => row.source === 'custom'), [rows]); + const catalogRows = useMemo(() => rows.filter((row) => row.source === 'builtin'), [rows]); + const validation = useMemo( + () => + validatePricingDraft(draft, { + mode: editor?.mode ?? 'add', + existingKeys, + lockedModelKey: editor?.mode === 'edit' ? editor.row.modelKey : undefined, + }), + [draft, editor, existingKeys], + ); + + 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 derivePricingRows(writeState.latest.entries).find((row) => row.modelKey === key) ?? null; + }, [writeState, editor, resetTarget, validation]); + + function restoreTriggerFocus() { + const trigger = triggerRef.current; + triggerRef.current = null; + if (trigger?.isConnected) requestAnimationFrame(() => trigger.focus()); + } + + function openAdd(trigger: HTMLElement | null) { + if (writesBlocked) 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; + setDraft({ + provider: row.provider, + model: row.model, + input: row.inputUsdPer1M, + output: row.outputUsdPer1M, + cacheRead: row.cacheReadUsdPer1M ?? null, + cacheWrite: row.cacheWriteUsdPer1M ?? null, + }); + setCacheOpen(row.cacheReadUsdPer1M !== undefined || row.cacheWriteUsdPer1M !== undefined); + 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) { + setDraft({ + provider: row.provider, + model: row.model, + input: row.inputUsdPer1M, + output: row.outputUsdPer1M, + cacheRead: row.cacheReadUsdPer1M ?? null, + cacheWrite: row.cacheWriteUsdPer1M ?? null, + }); + setCacheOpen(row.cacheReadUsdPer1M !== undefined || row.cacheWriteUsdPer1M !== undefined); + } + + 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 = derivePricingRows(outcome.snapshot.entries).find( + (row) => row.modelKey === 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, + locale, + loading, + loadError, + 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, + triggerRef, + 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..03d5f41e32 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-copy.ts @@ -0,0 +1,258 @@ +/* + * 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; + 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: { + title: '定价配置', + subtitle: + '美元 / 每百万 token。用于新激活的模型调用;进行中的运行沿用其开始时的价格。历史费用不会重算,最终以供应商结算为准。', + refresh: '刷新', + add: '添加定价', + 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: '已更新定价', + }, + 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', + 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..8d0c535b59 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-services-context.tsx @@ -0,0 +1,46 @@ +/* + * 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 { createContext, useContext, type ReactNode } from 'react'; +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 UsagePricingServicesContext = createContext(null); + +export function UsagePricingServicesProvider(props: { + readonly services: UsagePricingServices; + readonly children?: ReactNode; +}) { + return ( + + {props.children} + + ); +} + +export function useUsagePricingServices(): UsagePricingServices { + const services = useContext(UsagePricingServicesContext); + if (!services) throw new Error('UsagePricingServicesProvider is missing'); + return services; +} 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..40b2577dd0 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/pricing-view-model.ts @@ -0,0 +1,168 @@ +/* + * 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, + }; + }); +} + +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; +} + +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..b95a690f23 --- /dev/null +++ b/apps/desktop/src/renderer/features/usage/ui/pricing-editor.tsx @@ -0,0 +1,513 @@ +/* + * 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 }, + { header: copy.headers[2], numeric: true }, + { header: copy.headers[3], numeric: true }, + { header: copy.headers[4], numeric: true }, + { header: copy.headers[5], numeric: true }, + { 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; + const secondary = row.source === 'builtin' ? 'none' : row.resetEffect === 'become_unpriced' ? 'delete' : 'reset'; + 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 { + if (row.source === 'builtin') return copy.sourceBuiltin; + return row.resetEffect === 'restore_builtin' ? copy.sourceCustomFallback : copy.sourceCustomOnly; +} + +// Display formatting must round-trip the canonical value without losing +// precision, and a positive rate must never render as `$0` (#2015). Raw +// interpolation uses JS shortest-round-trip `Number.toString`, so `2.5` stays +// `$2.5` and `0.075` stays `$0.075` — never `.toFixed`-collapsed to `$0`. +export function formatUsd(value: number): string { + return `$${value}`; +} + +// An omitted cache rate ("not set", no cache charge) stays distinct from an +// explicit `0` (#2015): only `undefined` maps to the not-set copy. +export function formatCache(value: number | undefined, copy: PricingSettingsCopy): string { + return value === undefined ? copy.cacheNotSet : `$${value}`; +} diff --git a/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx b/apps/desktop/src/renderer/features/usage/ui/usage-settings-view.tsx index 61df3dcdd8..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 2fd53045e2..8e942d7a9e 100644 --- a/apps/desktop/src/renderer/settings/settings-surface.tsx +++ b/apps/desktop/src/renderer/settings/settings-surface.tsx @@ -1128,7 +1128,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 a3a13a12c7..e6a0001d90 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -313,7 +313,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, }; @@ -334,7 +333,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 14992542f9..0a0ec0ae02 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.2` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 244 files — blocker 0, reimplementation 0, polish 1, aligned 243. +**Totals:** 246 files — blocker 0, reimplementation 0, polish 1, aligned 245. ## Exclusions (explicit) @@ -68,8 +68,10 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/features/session-settings/services-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `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/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 e256ca9acb..c535e3cd6a 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -39,8 +39,10 @@ apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-prov 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/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 b6d2daccec..a973e1c078 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -650,12 +650,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