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