diff --git a/apps/desktop/e2e/settings-pricing.spec.ts b/apps/desktop/e2e/settings-pricing.spec.ts new file mode 100644 index 0000000000..8e7182207a --- /dev/null +++ b/apps/desktop/e2e/settings-pricing.spec.ts @@ -0,0 +1,69 @@ +/* + * 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 tab reads ONE Host-backed effective +// pricing snapshot (the bundled built-in table ∪ any user overrides) from the +// real embedded Runtime Host — no bridge stub. It exercises #2015 acceptance #1 +// (a Host-backed effective table whose built-in rows are labelled by 来源), #2 +// (the Pricing tab is not time-scoped: the Usage date range/summary toolbar is +// gone, so the range cannot be mistaken for a Pricing scope), and #11 (the +// price editor returns focus to the trigger that opened it — real Electron +// focus, which the linkedom unit harness cannot honestly exercise). +test('pricing tab shows the Host-backed effective table, is not time-scoped, and restores editor 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(); + 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. + await expect(page.getByText('美元 / 每百万 token。', { exact: false })).toBeVisible(); + const addButton = page.getByRole('button', { name: '添加定价' }); + await expect(addButton).toBeVisible(); + + // #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); + + // #2015 acceptance #1: the effective snapshot loads Host-backed rows, and a + // bundled row is distinguished by its 来源 = 内置 label. Scoped to the table + // Card so the empty-state copy (which also contains 内置) cannot satisfy it. + const pricingTable = page.locator('.settingsUsageTable'); + await expect(pricingTable).toBeVisible(); + await expect(pricingTable.getByText('内置', { exact: true }).first()).toBeVisible(); + + // #2015 acceptance #11: opening the editor and closing it returns focus to the + // trigger that opened it. + await addButton.click(); + const editor = page.getByRole('dialog'); + await expect(editor).toBeVisible(); + await editor.getByRole('button', { name: '取消' }).click(); + await expect(editor).toHaveCount(0); + await expect(addButton).toBeFocused(); +}); diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index c6dfc9c59c..6244106656 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -79,6 +79,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-provider-copy.ts", "src/renderer/locales/settings-shared-copy.ts", @@ -156,6 +157,8 @@ "src/renderer/settings/password-input.tsx", "src/renderer/settings/permission-center-page.tsx", "src/renderer/settings/personalization-settings-section.tsx", + "src/renderer/settings/pricing-settings-page.tsx", + "src/renderer/settings/pricing-settings-view-model.ts", "src/renderer/settings/projects-settings-page.tsx", "src/renderer/settings/provider-add-form.tsx", "src/renderer/settings/provider-add-model-dialog.tsx", @@ -205,11 +208,13 @@ "src/renderer/settings/tasks-settings-page.tsx", "src/renderer/settings/ui-locale-update-gate.ts", "src/renderer/settings/usage-settings-page.tsx", + "src/renderer/settings/usage-stats-table.tsx", "src/renderer/settings/use-action-guard.ts", "src/renderer/settings/use-connection-detail.ts", "src/renderer/settings/use-memory-settings-controller.ts", "src/renderer/settings/use-oauth-login-flow.ts", "src/renderer/settings/use-optimistic-settings-draft.ts", + "src/renderer/settings/use-pricing-settings-controller.ts", "src/renderer/settings/web-search-settings-page.tsx", "src/renderer/settled-message-merge.ts", "src/renderer/settled-session-transients.ts", @@ -1956,6 +1961,17 @@ "@maka/core/ui-locale": 1 } }, + "src/renderer/locales/settings-pricing-copy.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/ui-locale": 1 + } + }, "src/renderer/locales/settings-projects-copy.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -3388,6 +3404,45 @@ "react": 1 } }, + "src/renderer/settings/pricing-settings-page.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": { + "useEffect": 1, + "usePricingSettingsController": 1, + "useState": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../locales/settings-pricing-copy.js": 1, + "./pricing-settings-view-model.js": 1, + "./usage-stats-table.js": 1, + "./use-pricing-settings-controller.js": 1, + "@astryxdesign/core": 1, + "@astryxdesign/core/AlertDialog": 1, + "@astryxdesign/core/Collapsible": 1, + "@astryxdesign/core/Dialog": 1, + "@astryxdesign/core/Layout": 1, + "@maka/ui": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, + "src/renderer/settings/pricing-settings-view-model.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/usage-stats/pricing": 1, + "@maka/core/usage-stats/types": 1, + "@maka/runtime-host/protocol": 1 + } + }, "src/renderer/settings/projects-settings-page.tsx": { "bridgePaths": { "window.maka.app.info": 1, @@ -4441,9 +4496,11 @@ "dependencyPaths": { "../../shared/runtime-host-identity.js": 1, "../locales/settings-usage-copy": 1, + "./pricing-settings-page": 1, "./settings-error-copy": 1, "./settings-metric-card": 1, "./settings-section": 1, + "./usage-stats-table": 1, "./use-action-guard": 1, "./use-optimistic-settings-draft": 1, "@astryxdesign/core": 1, @@ -4455,6 +4512,19 @@ "react": 1 } }, + "src/renderer/settings/usage-stats-table.tsx": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@astryxdesign/core": 1, + "@maka/ui/icons": 1, + "react": 1 + } + }, "src/renderer/settings/use-action-guard.ts": { "bridgePaths": {}, "environmentCapabilities": {}, @@ -4602,6 +4672,40 @@ "react": 2 } }, + "src/renderer/settings/use-pricing-settings-controller.ts": { + "bridgePaths": { + "window.maka.settings.pricing.load": 1, + "window.maka.settings.pricing.mutate": 2 + }, + "environmentCapabilities": { + "requestAnimationFrame": 1 + }, + "hookCalls": { + "useEffect": 2, + "useKeyedActionGuard": 1, + "useRef": 5, + "useRuntimeHostSettingsErrorReporter": 1, + "useRuntimeHostSettingsGenerationKey": 1, + "useRuntimeHostSettingsTarget": 1, + "useState": 10, + "useToast": 1, + "useUiLocale": 1 + }, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "../../shared/desktop-pricing.js": 1, + "../locales/settings-pricing-copy.js": 1, + "./pricing-settings-view-model.js": 1, + "./runtime-host-settings-target.js": 1, + "./settings-error-copy.js": 1, + "./use-action-guard.js": 1, + "@maka/runtime-host/protocol": 1, + "@maka/ui": 1, + "react": 1 + } + }, "src/renderer/settings/web-search-settings-page.tsx": { "bridgePaths": { "window.maka.webSearch.query": 1, @@ -5433,6 +5537,18 @@ "@maka/core/llm-connections": 1 } }, + "src/shared/desktop-pricing.ts": { + "bridgePaths": {}, + "environmentCapabilities": {}, + "hookCalls": {}, + "lifecycleMethods": {}, + "unresolvedDependencies": 0, + "actionFactories": [], + "dependencyPaths": { + "@maka/core/usage-stats/pricing": 1, + "@maka/runtime-host/protocol": 1 + } + }, "src/shared/desktop-session-projection.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 cd12d9bb18..2bd643284a 100644 --- a/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-session-projection.test.ts @@ -226,7 +226,6 @@ test('projects only present Usage Session ids into the Desktop host namespace', byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; diff --git a/apps/desktop/src/main/__tests__/pricing-settings-boundary.test.ts b/apps/desktop/src/main/__tests__/pricing-settings-boundary.test.ts new file mode 100644 index 0000000000..c4561c5d3f --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-settings-boundary.test.ts @@ -0,0 +1,92 @@ +/* + * 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 { readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +// #2015 acceptance #12: the renderer Pricing surface may reach the Host ONLY +// through the two `window.maka.settings.pricing` IPC capabilities. It must not +// open a Host-owned Store, an embedded Runtime owner, or the main-process +// Runtime Host client, and it must not restore the retired `window.maka.usage` +// bridge or call the three legacy Pricing Main handlers. This is a static gate: +// a future edit that reaches past the bridge fails here rather than shipping a +// second production owner. + +const desktopRoot = resolve(fileURLToPath(new URL('../../../', import.meta.url))); +const settingsDir = join(desktopRoot, 'src', 'renderer', 'settings'); + +const RENDERER_PRICING_FILES = [ + 'pricing-settings-page.tsx', + 'pricing-settings-view-model.ts', + 'use-pricing-settings-controller.ts', +].map((name) => join(settingsDir, name)); + +// Import specifiers a renderer surface must never pull in. `/preload/` is +// intentionally absent: the renderer legitimately imports preload *types* +// (e.g. DesktopRuntimeHostRef) from the bridge contract. +const FORBIDDEN_IMPORT_SUBSTRINGS = [ + '@maka/storage', + 'root-authority', + 'runtime-policy-stores', + 'runtime-host-client', + '/main/', +]; + +// Source references that would mean the renderer bypassed the pricing bridge or +// reopened the retired usage bridge / legacy direct-Store channels. +const FORBIDDEN_SOURCE_SUBSTRINGS = [ + 'window.maka.usage', + 'usage:pricing:list', + 'usage:pricing:put', + 'usage:pricing:reset', +]; + +describe('Pricing settings renderer boundary', () => { + it('imports no Store, embedded owner, or main-process client', () => { + const violations: string[] = []; + for (const path of RENDERER_PRICING_FILES) { + const source = readFileSync(path, 'utf8'); + const name = relative(desktopRoot, path); + for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) { + const imported = match[1] ?? ''; + for (const forbidden of FORBIDDEN_IMPORT_SUBSTRINGS) { + if (imported.includes(forbidden)) violations.push(`${name}: imports ${imported}`); + } + } + for (const forbidden of FORBIDDEN_SOURCE_SUBSTRINGS) { + if (source.includes(forbidden)) violations.push(`${name}: references ${forbidden}`); + } + } + assert.deepEqual(violations, []); + }); + + it('reaches pricing through the settings.pricing bridge', () => { + const controller = readFileSync( + join(settingsDir, 'use-pricing-settings-controller.ts'), + 'utf8', + ); + assert.ok( + controller.includes('window.maka.settings.pricing'), + 'the controller reaches pricing through window.maka.settings.pricing', + ); + }); +}); diff --git a/apps/desktop/src/main/__tests__/pricing-settings-page.test.ts b/apps/desktop/src/main/__tests__/pricing-settings-page.test.ts new file mode 100644 index 0000000000..368a377ae3 --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-settings-page.test.ts @@ -0,0 +1,338 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { strict as assert } from 'node:assert'; +import { afterEach, describe, it } from 'node:test'; +import { parseHTML } from 'linkedom'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js'; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../shared/desktop-pricing.js'; +import { + PricingSettingsPage, + formatCache, + formatUsd, +} from '../../renderer/settings/pricing-settings-page.js'; +import { RuntimeHostSettingsTarget } from '../../renderer/settings/runtime-host-settings-target.js'; +import { getPricingSettingsCopy } from '../../renderer/locales/settings-pricing-copy.js'; + +const copy = getPricingSettingsCopy('en'); + +const TEST_RUNTIME_HOST = { + profileId: 'test-profile', + hostId: 'test-host', +} satisfies DesktopRuntimeHostRef; + +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('PricingSettingsPage', () => { + it('renders the built-in ∪ overrides list and loads for the selected host', async () => { + const harness = await renderPage({ load: async () => SNAPSHOT }); + assert.match(harness.container.textContent ?? '', /openai:gpt-4o/); + assert.match(harness.container.textContent ?? '', /anthropic:claude/); + assert.match(harness.container.textContent ?? '', new RegExp(copy.sourceBuiltin)); + assert.match(harness.container.textContent ?? '', new RegExp(copy.sourceCustomFallback)); + assert.deepEqual(harness.loadHosts, [TEST_RUNTIME_HOST]); + await act(async () => harness.root.unmount()); + }); + + it('reset sends a delete against the loaded snapshot and selected host', async () => { + const committed: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 6, entries: [SNAPSHOT.entries[0]!] }; + const harness = await renderPage({ + 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' }); + assert.deepEqual(harness.mutateHosts, [TEST_RUNTIME_HOST]); + await act(async () => harness.root.unmount()); + }); + + it('a saved-but-refresh-failed outcome disables further writes', async () => { + const harness = await renderPage({ + 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)); + const addButton = buttonByText(harness.doc, copy.add); + assert.ok(addButton); + // A disabled control that carries its reason via tooltip stays focusable and + // marks itself with aria-disabled rather than the native disabled attribute + // (DESIGN.md §Fields), so the write-block reason stays discoverable. + assert.equal(addButton.getAttribute('aria-disabled'), 'true'); + await act(async () => harness.root.unmount()); + }); + + it('a reset conflict keeps the dialog and confirms again against fresh authority', async () => { + const latest: DesktopPricingSnapshot = { ...SNAPSHOT, revision: 9 }; + let calls = 0; + const harness = await renderPage({ + load: async () => SNAPSHOT, + mutate: async () => { + calls += 1; + return calls === 1 + ? { kind: 'review_required', reason: 'revision_conflict', snapshot: latest } + : { kind: 'saved', disposition: 'committed', snapshot: latest }; + }, + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + // The conflict is surfaced and the confirm dialog stays open for an explicit + // second confirm — the mutation is never replayed blindly. + assert.match(harness.container.textContent ?? '', new RegExp(copy.conflictTitle)); + const confirmAgain = buttonByText(harness.doc, copy.confirmReset); + assert.ok(confirmAgain, 'reset dialog stays open on conflict'); + await click(confirmAgain); + + assert.equal(calls, 2); + // The second attempt carries the fresh authority (revision 9) as its base. + assert.equal(harness.mutations[1]?.base.revision, 9); + await act(async () => harness.root.unmount()); + }); + + it('an uncertain outcome blocks writes and dims the possibly-stale list', async () => { + const harness = await renderPage({ + load: async () => SNAPSHOT, + mutate: async () => ({ kind: 'reconciliation_unavailable', reason: 'outcome_unknown' }), + }); + await click(buttonByLabel(harness.doc, copy.resetAria('anthropic:claude'))); + await click(buttonByText(harness.doc, copy.confirmReset)); + + assert.match(harness.container.textContent ?? '', new RegExp(copy.reconcileTitle)); + assert.equal(buttonByText(harness.doc, copy.add)?.getAttribute('aria-disabled'), 'true'); + assert.ok( + harness.container.querySelector('.settingsPricingStale'), + 'the possibly-stale list is dimmed while writes are blocked', + ); + await act(async () => harness.root.unmount()); + }); + + it('associates required-field errors with their controls after an empty save', async () => { + const harness = await renderPage({ 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()); + }); +}); + +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 renderPage(options: { + load: (host?: DesktopRuntimeHostRef) => Promise; + mutate?: ( + base: DesktopPricingSnapshot, + mutation: DesktopPricingMutationInput['mutation'], + host?: DesktopRuntimeHostRef, + ) => Promise; +}) { + 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 loadHosts: Array = []; + const mutateHosts: Array = []; + const mutations: DesktopPricingMutationInput[] = []; + (window as unknown as { maka: unknown }).maka = { + settings: { + pricing: { + load: async (host?: DesktopRuntimeHostRef) => { + loadHosts.push(host); + return options.load(host); + }, + mutate: async ( + base: DesktopPricingSnapshot, + mutation: DesktopPricingMutationInput['mutation'], + host?: DesktopRuntimeHostRef, + ) => { + mutateHosts.push(host); + mutations.push({ base, mutation }); + return ( + options.mutate?.(base, mutation, host) ?? + 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); + await act(async () => { + const targeted = createElement(RuntimeHostSettingsTarget, { + host: TEST_RUNTIME_HOST, + children: createElement(PricingSettingsPage), + }); + const toasted = createElement(ToastProvider, { children: targeted }); + const localized = createElement(AstryxLocaleProvider, { children: toasted }); + root.render(createElement(LocaleProvider, { locale: 'en', children: localized })); + await Promise.resolve(); + await Promise.resolve(); + }); + return { + doc: document as unknown as Document, + container, + root: root as Root, + 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(); + }); +} + +function buttonByText(doc: Document, text: string): HTMLButtonElement | undefined { + return Array.from(doc.querySelectorAll('button')).find( + (button) => (button.textContent ?? '').trim() === text, + ); +} + +function buttonByLabel(doc: Document, label: string): HTMLButtonElement | undefined { + return ( + doc.querySelector(`button[aria-label="${label}"]`) ?? undefined + ); +} diff --git a/apps/desktop/src/main/__tests__/pricing-settings-view-model.test.ts b/apps/desktop/src/main/__tests__/pricing-settings-view-model.test.ts new file mode 100644 index 0000000000..6b3c551f33 --- /dev/null +++ b/apps/desktop/src/main/__tests__/pricing-settings-view-model.test.ts @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { EffectivePricingEntry } from "@maka/runtime-host/protocol"; +import { + derivePricingRows, + validatePricingDraft, + type PricingDraft, +} from "../../renderer/settings/pricing-settings-view-model.js"; + +const EMPTY: PricingDraft = { + provider: "", + model: "", + input: null, + output: null, + cacheRead: null, + cacheWrite: null, +}; + +test("derivePricingRows maps source, split, and cache presence", () => { + const entries: EffectivePricingEntry[] = [ + { + source: "custom", + resetEffect: "become_unpriced", + pricing: { modelKey: "acme:coder-v2", inputUsdPer1M: 0.8, outputUsdPer1M: 2.4 }, + }, + { + source: "builtin", + pricing: { + modelKey: "openai:gpt-4o", + inputUsdPer1M: 2.5, + outputUsdPer1M: 10, + cacheReadUsdPer1M: 0, + }, + }, + { + source: "custom", + resetEffect: "restore_builtin", + pricing: { modelKey: "anthropic:claude", inputUsdPer1M: 2, outputUsdPer1M: 12 }, + }, + ]; + + const rows = derivePricingRows(entries); + + // Canonical key order, not input order. + assert.deepEqual( + rows.map((row) => row.modelKey), + ["acme:coder-v2", "anthropic:claude", "openai:gpt-4o"], + ); + const acme = rows[0]!; + assert.equal(acme.provider, "acme"); + assert.equal(acme.model, "coder-v2"); + assert.equal(acme.source, "custom"); + assert.equal(acme.resetEffect, "become_unpriced"); + + const anthropic = rows[1]!; + assert.equal(anthropic.resetEffect, "restore_builtin"); + + const openai = rows[2]!; + assert.equal(openai.source, "builtin"); + assert.equal(openai.resetEffect, null); + // Explicit 0 is preserved and stays distinct from "not set" (undefined). + assert.equal(openai.cacheReadUsdPer1M, 0); + assert.equal(openai.cacheWriteUsdPer1M, undefined); +}); + +test("validatePricingDraft add flags empty provider/model", () => { + const result = validatePricingDraft(EMPTY, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.provider, "required"); + assert.equal(result.errors.model, "required"); + assert.equal(result.errors.input, "required"); + assert.equal(result.errors.output, "required"); + assert.equal(result.hasErrors, true); + assert.equal(result.config, null); +}); + +test("validatePricingDraft add flags a duplicate key against existing rows", () => { + const draft: PricingDraft = { ...EMPTY, provider: "openai", model: "gpt-4o", input: 1, output: 2 }; + const result = validatePricingDraft(draft, { + mode: "add", + existingKeys: ["openai:gpt-4o"], + }); + assert.equal(result.errors.model, "duplicate"); + assert.equal(result.config, null); +}); + +test("validatePricingDraft add builds a canonical config; blank cache is omitted", () => { + const draft: PricingDraft = { + provider: "acme", + model: "coder-v2", + input: 0.8, + output: 2.4, + cacheRead: null, + cacheWrite: null, + }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.hasErrors, false); + assert.deepEqual(result.config, { + modelKey: "acme:coder-v2", + inputUsdPer1M: 0.8, + outputUsdPer1M: 2.4, + }); + assert.equal(Object.hasOwn(result.config!, "cacheReadUsdPer1M"), false); +}); + +test("validatePricingDraft keeps an explicit 0 cache rate distinct from blank", () => { + const draft: PricingDraft = { + provider: "acme", + model: "coder-v2", + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: null, + }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.config?.cacheReadUsdPer1M, 0); + assert.equal(Object.hasOwn(result.config!, "cacheWriteUsdPer1M"), false); +}); + +test("validatePricingDraft rejects a negative rate", () => { + const draft: PricingDraft = { ...EMPTY, provider: "a", model: "b", input: -1, output: 2 }; + const result = validatePricingDraft(draft, { mode: "add", existingKeys: [] }); + assert.equal(result.errors.input, "invalid_rate"); + assert.equal(result.config, null); +}); + +test("validatePricingDraft edit locks the key and ignores provider/model", () => { + const draft: PricingDraft = { + provider: "ignored", + model: "ignored", + input: 3, + output: 4, + cacheRead: null, + cacheWrite: null, + }; + const result = validatePricingDraft(draft, { + mode: "edit", + existingKeys: ["openai:gpt-4o"], + lockedModelKey: "openai:gpt-4o", + }); + assert.equal(result.hasErrors, false); + assert.equal(result.config?.modelKey, "openai:gpt-4o"); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts new file mode 100644 index 0000000000..54d4fa6962 --- /dev/null +++ b/apps/desktop/src/main/__tests__/runtime-host-pricing-ipc-main.test.ts @@ -0,0 +1,190 @@ +/* + * 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 3b7b3df61f..9207e9faac 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 @@ -86,24 +86,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"); @@ -134,14 +117,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()); @@ -200,14 +175,7 @@ test("settings usage stats reject a non-advancing activity page", async () => { nextOffset: null, } satisfies UsageQueryResult); }, - loadPricingSnapshot: async () => ({ - hostEpoch: "host-epoch", - connectionId: "connection-id", - revision: 0, - entries: [], - }), } as unknown as DesktopRuntimeHostClient, - sendToRenderer: () => undefined, }); const handler = handlers.get("settings:usageStats"); @@ -267,14 +235,7 @@ test("settings usage stats degrade instead of erroring when logs disagree with t nextOffset: null, } satisfies UsageQueryResult); }, - loadPricingSnapshot: async () => ({ - hostEpoch: "host-epoch", - connectionId: "connection-id", - revision: 0, - entries: [], - }), } as unknown as DesktopRuntimeHostClient, - sendToRenderer: () => undefined, }); const handler = handlers.get("settings:usageStats"); @@ -344,14 +305,7 @@ test("settings usage stats group the provider breakdown by connection", async () nextOffset: null, } satisfies UsageQueryResult); }, - loadPricingSnapshot: async () => ({ - hostEpoch: "host-epoch", - connectionId: "connection-id", - revision: 0, - entries: [], - }), } as unknown as DesktopRuntimeHostClient, - sendToRenderer: () => undefined, }); const handler = handlers.get("settings:usageStats"); @@ -422,14 +376,7 @@ test("settings usage stats truncate the activity log at the cap instead of error nextOffset: null, } satisfies UsageQueryResult; }, - loadPricingSnapshot: async () => ({ - hostEpoch: "host-epoch", - connectionId: "connection-id", - revision: 0, - entries: [], - }), } as unknown as DesktopRuntimeHostClient, - sendToRenderer: () => undefined, }); const handler = handlers.get("settings:usageStats"); @@ -513,7 +460,6 @@ test("settings usage stats name each row from the Host-resolved session title", entries: [], }), } as unknown as DesktopRuntimeHostClient, - sendToRenderer: () => undefined, }); const handler = handlers.get("settings:usageStats"); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 3962b57441..6d1744cf33 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -214,6 +214,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 { @@ -1370,11 +1371,8 @@ function registerHostClientIpc( allowLocalPaths: !usesHostWorkspace, }); registerRuntimeHostSearchIpc({ ipcMain: scopedIpc, client }); - registerRuntimeHostUsageIpc({ - ipcMain: scopedIpc, - client, - sendToRenderer, - }); + registerRuntimeHostUsageIpc({ ipcMain: scopedIpc, client }); + 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 62f760c087..cecbf6d70d 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -44,6 +44,11 @@ import { comparePricingModelKeys, } from "@maka/core/usage-stats/pricing"; import type { PricingConfig } from "@maka/core/usage-stats/types"; +import type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from "../shared/desktop-pricing.js"; import { type ClientCapabilityProvider, type DecodedSessionTranscriptPage, @@ -220,12 +225,13 @@ export interface DesktopRuntimeHostSession { close(): Promise; } -export interface DesktopPricingSnapshot { - readonly hostEpoch: string; - readonly connectionId: string; - readonly revision: number; - readonly entries: readonly EffectivePricingEntry[]; -} +// The Pricing Settings cross-boundary types live in `../shared/desktop-pricing` +// so the preload and renderer can name them without importing this client. +export type { + DesktopPricingMutationInput, + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from "../shared/desktop-pricing.js"; export interface DesktopSkillCatalogSnapshot { readonly revision: SkillCatalogRevision; @@ -234,31 +240,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 } | { @@ -633,6 +614,27 @@ export class DesktopRuntimeHostClient { } } + /** + * Reconcile a pricing mutation whose outcome is unknown WITHOUT re-dispatching + * it — load fresh authority on the current connection and compare the intended + * end state. This is the reconcile step the Desktop reconciled-control IPC runs + * against a replacement Host after a response-losing disconnect: it must never + * replay the write, and it deliberately skips the connection stale-guard + * because `base` legitimately belongs to the previous connection. + */ + async reconcilePricingMutation( + input: DesktopPricingMutationInput, + reason: "revision_conflict" | "outcome_unknown", + ): Promise { + this.#assertOpen(); + const request = decodePricingMutateInput({ + expectedRevision: input.base.revision, + mutation: input.mutation, + }); + const target = createPricingReconciliationTarget(input.base, request.mutation); + return this.#reconcilePricingMutation(target, reason); + } + async listSessions(): Promise { this.#assertOpen(); try { diff --git a/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts b/apps/desktop/src/main/runtime-host-pricing-ipc-main.ts new file mode 100644 index 0000000000..2abcf3923b --- /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, + type DesktopPricingMutationInput, + type 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 6250ab5bf6..8c5df5db84 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -108,6 +108,7 @@ import type { OperationInput, OperationOutcome, OperationOutput, + PricingMutation, } from '@maka/runtime-host/protocol'; import type { CollaborationAccessQueryResult, @@ -130,6 +131,10 @@ import type { } from './runtime-host-renderer-operations.js'; import type { SessionTrace } from '@maka/core/session-trace'; import type { UsageSummaryV2 } from '@maka/core/usage-stats/types'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../shared/desktop-pricing.js'; import type { UsageProvenance } from '@maka/core/usage-ledger-merge'; import type { ContextDiagnosticsResult } from '@maka/runtime-host/protocol'; import type { TestProxyInput } from '@maka/core/settings/network-settings'; @@ -1387,6 +1392,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 3e9beb6133..5efe118b28 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -118,6 +118,10 @@ import type { ThemePreference, } from '@maka/core/settings'; import type { BotProvider } from '@maka/core/bot-chat-settings'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../shared/desktop-pricing.js'; import type { BotOnboardingSnapshot, BotOnboardingStartInput } from '@maka/core/bot-onboarding'; import type { HealthSnapshot } from '@maka/core/health'; import { @@ -221,6 +225,7 @@ import { type OperationInput, type OperationOutcome, type OperationOutput, + type PricingMutation, } from '@maka/runtime-host/protocol'; import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import { @@ -2991,6 +2996,36 @@ 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 this exact + // snapshot back as the CAS `base` when it saves. + 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 revision the renderer was viewing + // (`base`). The adapter owns CAS + reconciliation, so the returned outcome + // encodes committed / conflict / uncertain — the renderer must handle each. + 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/locales/settings-pricing-copy.ts b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts new file mode 100644 index 0000000000..a2adba1a6b --- /dev/null +++ b/apps/desktop/src/renderer/locales/settings-pricing-copy.ts @@ -0,0 +1,238 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; + +export type PricingSettingsCopy = { + title: string; + subtitle: string; + refresh: string; + add: string; + loading: string; + loadFailedTitle: string; + loadFailedBody: string; + retry: string; + emptyTitle: string; + emptyBody: string; + tableAria: string; + headers: readonly [string, string, string, string, string, string]; + actionsHeader: string; + sourceBuiltin: string; + sourceCustomFallback: string; + sourceCustomOnly: string; + cacheNotSet: string; + edit: string; + reset: string; + delete: string; + editAria(modelKey: string): string; + resetAria(modelKey: string): string; + deleteAria(modelKey: string): string; + // editor + addTitle: string; + editTitle: string; + providerLabel: string; + providerPlaceholder: string; + modelLabel: string; + modelPlaceholder: string; + keyHelp: string; + inputLabel: string; + outputLabel: string; + rateHelp: string; + cacheSection: string; + cacheReadLabel: string; + cacheWriteLabel: string; + cacheHelp: string; + cancel: string; + save: string; + // field errors + errorRequired: string; + errorInvalidRate: string; + errorKeyTooLong: string; + errorDuplicate: string; + // outcomes + saved: string; + synchronized: string; + conflictTitle: string; + conflictTitleUnknown: string; + conflictBody: string; + conflictBodyUnknown: string; + conflictLatest(input: string, output: string): string; + reviewSave: string; + refreshFailedTitle: string; + refreshFailedBody: string; + reconcileTitle: string; + reconcileBody: string; + writeBlockedReason: string; + saveFailed: string; + // reset / delete confirm + resetTitle: string; + resetBody(modelKey: string): string; + deleteTitle: string; + deleteBody(modelKey: string): string; + confirmReset: string; + confirmDelete: string; + resetFailed: string; + resetDone: string; +}; + +const SETTINGS_PRICING_COPY = { + zh: { + title: '定价配置', + subtitle: + '美元 / 每百万 token。用于新激活的模型调用;进行中的运行沿用其开始时的价格。历史费用不会重算,最终以供应商结算为准。', + refresh: '刷新', + add: '添加定价', + loading: '正在加载定价…', + loadFailedTitle: '无法加载定价', + loadFailedBody: '读取运行时主机的定价快照失败,请重试。', + retry: '重试', + emptyTitle: '暂无定价', + emptyBody: '运行时主机尚未返回任何内置或自定义定价。', + tableAria: '模型定价表', + headers: ['模型', '来源', '输入 / 1M', '输出 / 1M', '缓存读 / 1M', '缓存写 / 1M'], + actionsHeader: '操作', + sourceBuiltin: '内置', + sourceCustomFallback: '自定义 · 可回退', + sourceCustomOnly: '仅自定义', + cacheNotSet: '未设置(Maka 估算不计缓存费用)', + edit: '编辑', + reset: '重置', + delete: '删除', + editAria: (modelKey: string) => `编辑「${modelKey}」定价`, + resetAria: (modelKey: string) => `重置「${modelKey}」定价`, + deleteAria: (modelKey: string) => `删除「${modelKey}」定价`, + addTitle: '添加定价', + editTitle: '编辑定价', + providerLabel: '供应商', + providerPlaceholder: '例如 anthropic', + modelLabel: '模型', + modelPlaceholder: '例如 claude-sonnet-4-5', + keyHelp: '这是运行时的精确查找键,需与用量记录中的供应商与模型 ID 完全一致(区分大小写,不要用连接别名)。', + inputLabel: '输入价格', + outputLabel: '输出价格', + rateHelp: '美元 / 每百万 token;0 表示免费(如本地模型)。', + cacheSection: '缓存价格(可选)', + cacheReadLabel: '缓存读取', + cacheWriteLabel: '缓存写入', + cacheHelp: '留空表示未设置(不计缓存费用),与显式填 0 不同。', + cancel: '取消', + save: '保存', + errorRequired: '必填', + errorInvalidRate: '请输入有效价格(≥ 0)', + errorKeyTooLong: '模型键过长(上限 128 字符)', + errorDuplicate: '该模型已在列表中,请直接编辑对应行', + saved: '定价已保存', + synchronized: '当前定价已与你的修改一致', + conflictTitle: '定价已被其他修改更新', + conflictTitleUnknown: '无法确认上次修改的结果', + conflictBody: '该模型的价格已被其他修改更新。请核对最新值后,基于最新版本再次保存。', + conflictBodyUnknown: '上次修改可能已生效、也可能未生效。请核对最新值后,再决定是否基于最新版本重新保存。', + conflictLatest: (input: string, output: string) => `当前最新:输入 ${input} / 输出 ${output}`, + reviewSave: '核对并保存', + refreshFailedTitle: '已保存,但无法加载最新定价', + refreshFailedBody: '保存已完成,但未能读取最新定价。请刷新后再进行修改。', + reconcileTitle: '无法确认结果', + reconcileBody: '未能确认这次修改的结果。请刷新定价后再进行修改。', + writeBlockedReason: '需先刷新最新定价后才能修改。', + saveFailed: '保存定价失败', + resetTitle: '重置定价', + resetBody: (modelKey: string) => `将删除「${modelKey}」的自定义价格,恢复为内置定价。`, + deleteTitle: '删除定价', + deleteBody: (modelKey: string) => + `将删除「${modelKey}」的定价;新激活的调用将变为未定价(不计入 Maka 的费用估算,与显式填 0 不同),进行中的运行沿用其开始时的快照。`, + confirmReset: '重置', + confirmDelete: '删除', + resetFailed: '操作失败', + resetDone: '已更新定价', + }, + en: { + title: 'Pricing', + subtitle: + 'USD per 1M tokens. Applies to newly activated model work; an active run keeps its starting prices. Historical costs are not recalculated. Provider billing is authoritative.', + refresh: 'Refresh', + add: 'Add price', + loading: 'Loading pricing…', + loadFailedTitle: 'Could not load pricing', + loadFailedBody: 'Reading the Runtime Host pricing snapshot failed. Try again.', + retry: 'Retry', + emptyTitle: 'No pricing', + emptyBody: 'The Runtime Host returned no built-in or custom pricing.', + tableAria: 'Model pricing table', + headers: ['Model', 'Source', 'Input / 1M', 'Output / 1M', 'Cache read / 1M', 'Cache write / 1M'], + actionsHeader: 'Actions', + sourceBuiltin: 'Built-in', + sourceCustomFallback: 'Custom · has fallback', + sourceCustomOnly: 'Custom-only', + cacheNotSet: 'Not set (no cache charge in Maka estimates)', + edit: 'Edit', + reset: 'Reset', + delete: 'Delete', + editAria: (modelKey: string) => `Edit pricing for ${modelKey}`, + resetAria: (modelKey: string) => `Reset pricing for ${modelKey}`, + deleteAria: (modelKey: string) => `Delete pricing for ${modelKey}`, + addTitle: 'Add price', + editTitle: 'Edit price', + providerLabel: 'Provider', + providerPlaceholder: 'e.g. anthropic', + modelLabel: 'Model', + modelPlaceholder: 'e.g. claude-sonnet-4-5', + keyHelp: + 'This is the exact Runtime lookup key. Match the provider and model IDs from your usage records exactly (case-sensitive; not the connection slug).', + inputLabel: 'Input price', + outputLabel: 'Output price', + rateHelp: 'USD per 1M tokens; 0 means free (e.g. local models).', + cacheSection: 'Cache prices (optional)', + cacheReadLabel: 'Cache read', + cacheWriteLabel: 'Cache write', + cacheHelp: 'Leave blank for "Not set" (no cache charge) — distinct from an explicit 0.', + cancel: 'Cancel', + save: 'Save', + errorRequired: 'Required', + errorInvalidRate: 'Enter a valid price (≥ 0)', + errorKeyTooLong: 'Model key is too long (128 characters max)', + errorDuplicate: 'This model is already listed — edit its row instead', + saved: 'Pricing saved', + synchronized: 'Pricing already matches your change', + conflictTitle: 'Pricing changed elsewhere', + conflictTitleUnknown: "Couldn't confirm the last change", + conflictBody: "This model's price was changed elsewhere. Review the latest value, then save again against the latest revision.", + conflictBodyUnknown: 'The last change may or may not have applied. Review the latest value, then decide whether to save again against the latest revision.', + conflictLatest: (input: string, output: string) => `Latest: input ${input} / output ${output}`, + reviewSave: 'Review & save', + refreshFailedTitle: 'Saved, but the latest pricing could not be loaded', + refreshFailedBody: 'The save completed but the latest prices could not be loaded. Refresh before changing pricing again.', + reconcileTitle: "Couldn't confirm the result", + reconcileBody: "The result of this change could not be confirmed. Reload pricing before changing it again.", + writeBlockedReason: 'Refresh the latest pricing before making changes.', + saveFailed: 'Failed to save pricing', + resetTitle: 'Reset pricing', + resetBody: (modelKey: string) => `This removes the custom price for ${modelKey} and restores its built-in pricing.`, + deleteTitle: 'Delete pricing', + deleteBody: (modelKey: string) => + `This deletes pricing for ${modelKey}; newly activated work becomes unpriced (excluded from Maka's cost estimates — distinct from an explicit $0), while an active run keeps its starting snapshot.`, + confirmReset: 'Reset', + confirmDelete: 'Delete', + resetFailed: 'Action failed', + resetDone: 'Pricing updated', + }, +} satisfies UiCatalog; + +export function getPricingSettingsCopy(locale: UiLocale): PricingSettingsCopy { + return SETTINGS_PRICING_COPY[locale]; +} diff --git a/apps/desktop/src/renderer/settings/pricing-settings-page.tsx b/apps/desktop/src/renderer/settings/pricing-settings-page.tsx new file mode 100644 index 0000000000..967949d1db --- /dev/null +++ b/apps/desktop/src/renderer/settings/pricing-settings-page.tsx @@ -0,0 +1,407 @@ +/* + * 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, useState, type ReactNode } from 'react'; +import { EmptyState, Heading, Skeleton, Text } from '@astryxdesign/core'; +import { AlertDialog } from '@astryxdesign/core/AlertDialog'; +import { Collapsible } from '@astryxdesign/core/Collapsible'; +import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; +import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { Banner, Button, HStack, NumberInput, TextInput, VStack } from '@maka/ui'; +import { ICON_SIZE, BarChart3, Pencil, Plus, RefreshCcw, RotateCcw, Trash2 } from '@maka/ui/icons'; +import type { PricingSettingsCopy } from '../locales/settings-pricing-copy.js'; +import { usePricingSettingsController } from './use-pricing-settings-controller.js'; +import type { + PricingDraftErrors, + PricingRowView, +} from './pricing-settings-view-model.js'; +import { UsageStatsTable, type UsageColumn } from './usage-stats-table.js'; + +export function PricingSettingsPage() { + const c = usePricingSettingsController(); + const { copy } = c; + + const columns: UsageColumn[] = [ + { header: copy.headers[0], width: 300 }, + { header: copy.headers[1], width: 152 }, + { header: copy.headers[2], numeric: true }, + { header: copy.headers[3], numeric: true }, + { header: copy.headers[4], numeric: true }, + { header: copy.headers[5], numeric: true }, + { header: copy.actionsHeader, width: 104 }, + ]; + + const rows = c.rows.map((row) => [ + row.modelKey, + pricingSourceLabel(row, copy), + formatUsd(row.inputUsdPer1M), + formatUsd(row.outputUsdPer1M), + formatCache(row.cacheReadUsdPer1M, copy), + formatCache(row.cacheWriteUsdPer1M, copy), + c.openEdit(row, trigger)} + onReset={(trigger) => c.openReset(row, trigger)} + />, + ]); + + return ( +
+
+
+ {copy.title} + {copy.subtitle} +
+ +
+ + {/* Panel-level write notice — visible when no editor is open (e.g. a reset + produced a conflict/uncertain outcome and closed its dialog). */} + {c.editor === null ? ( + + ) : null} + +
+ {c.loadError !== null ? ( + } + title={copy.loadFailedTitle} + description={copy.loadFailedBody} + actions={
+ + {c.editor !== null ? : null} + + { + if (!open) c.cancelReset(); + }} + title={c.resetTarget?.resetEffect === 'become_unpriced' ? copy.deleteTitle : copy.resetTitle} + description={ + c.resetTarget + ? c.resetTarget.resetEffect === 'become_unpriced' + ? copy.deleteBody(c.resetTarget.modelKey) + : copy.resetBody(c.resetTarget.modelKey) + : '' + } + actionLabel={c.resetTarget?.resetEffect === 'become_unpriced' ? copy.confirmDelete : copy.confirmReset} + cancelLabel={copy.cancel} + isActionLoading={c.resetBusy} + onAction={() => void c.confirmReset()} + /> +
+ ); +} + +function PricingRowActions(props: { + row: PricingRowView; + copy: PricingSettingsCopy; + disabled: boolean; + onEdit(trigger: HTMLElement | null): void; + onReset(trigger: HTMLElement | null): void; +}) { + const { row, copy } = props; + const secondary = row.source === 'builtin' ? 'none' : row.resetEffect === 'become_unpriced' ? 'delete' : 'reset'; + return ( + +
+ ); +} + +function PricingWriteNotice(props: { + writeState: ReturnType['writeState']; + latestEntry: PricingRowView | null; + copy: PricingSettingsCopy; +}) { + const { writeState, latestEntry, copy } = props; + switch (writeState.kind) { + case 'conflict': { + // An `outcome_unknown` conflict is uncertain, not a confirmed external + // change — it must not be described as one. + const uncertain = writeState.reason === 'outcome_unknown'; + const latest = latestEntry + ? ` ${copy.conflictLatest(formatUsd(latestEntry.inputUsdPer1M), formatUsd(latestEntry.outputUsdPer1M))}` + : ''; + return ( + + ); + } + case 'refresh_failed': + return ; + case 'reconcile_unavailable': + return ; + case 'idle': + return null; + } +} + +/** Skeleton rows that mirror the real table's column count for a zero-shift load. */ +function pricingSkeletonRows(columnCount: number): Array> { + return Array.from({ length: 6 }, () => + Array.from({ length: columnCount }, (_unused, column) => ( + + )), + ); +} + +function pricingSourceLabel(row: PricingRowView, copy: PricingSettingsCopy): string { + if (row.source === 'builtin') return copy.sourceBuiltin; + return row.resetEffect === 'restore_builtin' ? copy.sourceCustomFallback : copy.sourceCustomOnly; +} + +// Display formatting must round-trip the canonical value without losing +// precision, and a positive rate must never render as `$0` (#2015). Raw +// interpolation uses JS shortest-round-trip `Number.toString`, so `2.5` stays +// `$2.5` and `0.075` stays `$0.075` — never `.toFixed`-collapsed to `$0`. +export function formatUsd(value: number): string { + return `$${value}`; +} + +// An omitted cache rate ("not set", no cache charge) stays distinct from an +// explicit `0` (#2015): only `undefined` maps to the not-set copy. +export function formatCache(value: number | undefined, copy: PricingSettingsCopy): string { + return value === undefined ? copy.cacheNotSet : `$${value}`; +} diff --git a/apps/desktop/src/renderer/settings/pricing-settings-view-model.ts b/apps/desktop/src/renderer/settings/pricing-settings-view-model.ts new file mode 100644 index 0000000000..40b2577dd0 --- /dev/null +++ b/apps/desktop/src/renderer/settings/pricing-settings-view-model.ts @@ -0,0 +1,168 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Pure derivations for the Pricing Settings panel — no React, no IPC — so the + * row projection and the editor validation are unit-testable without a + * renderer. The Host already returns entries as the canonical built-in ∪ + * overrides union in key order; this only maps them to display rows (re-sorting + * defensively) and mirrors the Host's `normalizePricingConfig` rules per-field. + */ + +import { + comparePricingModelKeys, + normalizePricingModelKey, + pricingModelKey, +} from '@maka/core/usage-stats/pricing'; +import type { PricingConfig } from '@maka/core/usage-stats/types'; +import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; + +export interface PricingRowView { + readonly modelKey: string; + /** Display-only split of `modelKey` on its first colon. */ + readonly provider: string; + readonly model: string; + readonly source: 'builtin' | 'custom'; + /** null for a built-in row; the delete consequence for a custom row. */ + readonly resetEffect: 'restore_builtin' | 'become_unpriced' | null; + readonly inputUsdPer1M: number; + readonly outputUsdPer1M: number; + /** `undefined` means "Not set" — distinct from an explicit `0`. */ + readonly cacheReadUsdPer1M: number | undefined; + readonly cacheWriteUsdPer1M: number | undefined; +} + +export function derivePricingRows( + entries: readonly EffectivePricingEntry[], +): PricingRowView[] { + return [...entries] + .sort((left, right) => + comparePricingModelKeys(left.pricing.modelKey, right.pricing.modelKey), + ) + .map((entry) => { + const key = entry.pricing.modelKey; + const separator = key.indexOf(':'); + return { + modelKey: key, + provider: separator < 0 ? '' : key.slice(0, separator), + model: separator < 0 ? key : key.slice(separator + 1), + source: entry.source, + resetEffect: entry.source === 'custom' ? entry.resetEffect : null, + inputUsdPer1M: entry.pricing.inputUsdPer1M, + outputUsdPer1M: entry.pricing.outputUsdPer1M, + cacheReadUsdPer1M: entry.pricing.cacheReadUsdPer1M, + cacheWriteUsdPer1M: entry.pricing.cacheWriteUsdPer1M, + }; + }); +} + +export interface PricingDraft { + readonly provider: string; + readonly model: string; + /** `null` = the field is empty (a cleared NumberInput). */ + readonly input: number | null; + readonly output: number | null; + readonly cacheRead: number | null; + readonly cacheWrite: number | null; +} + +export type PricingRateErrorCode = 'required' | 'invalid_rate'; +export type PricingKeyErrorCode = 'required' | 'key_too_long' | 'duplicate'; + +export interface PricingDraftErrors { + provider?: 'required'; + model?: PricingKeyErrorCode; + input?: PricingRateErrorCode; + output?: PricingRateErrorCode; + cacheRead?: 'invalid_rate'; + cacheWrite?: 'invalid_rate'; +} + +export interface PricingDraftValidation { + readonly errors: PricingDraftErrors; + readonly hasErrors: boolean; + /** The canonical config to send, present iff `hasErrors` is false. */ + readonly config: PricingConfig | null; +} + +export function validatePricingDraft( + draft: PricingDraft, + options: { + readonly mode: 'add' | 'edit'; + readonly existingKeys: readonly string[]; + /** Required in edit mode — the fixed identity key. */ + readonly lockedModelKey?: string; + }, +): PricingDraftValidation { + const errors: PricingDraftErrors = {}; + + let modelKey: string | null = null; + if (options.mode === 'edit') { + modelKey = options.lockedModelKey ?? null; + } else { + const provider = draft.provider.trim(); + const model = draft.model.trim(); + if (provider === '') errors.provider = 'required'; + if (model === '') errors.model = 'required'; + if (provider !== '' && model !== '') { + const normalized = normalizePricingModelKey(pricingModelKey(provider, model)); + if (!normalized.ok) { + errors.model = 'key_too_long'; + } else if (options.existingKeys.includes(normalized.value)) { + errors.model = 'duplicate'; + } else { + modelKey = normalized.value; + } + } + } + + const input = validateRequiredRate(draft.input); + if (input !== 'ok') errors.input = input; + const output = validateRequiredRate(draft.output); + if (output !== 'ok') errors.output = output; + if (draft.cacheRead !== null && !isValidRate(draft.cacheRead)) { + errors.cacheRead = 'invalid_rate'; + } + if (draft.cacheWrite !== null && !isValidRate(draft.cacheWrite)) { + errors.cacheWrite = 'invalid_rate'; + } + + const hasErrors = Object.keys(errors).length > 0; + const config: PricingConfig | null = + !hasErrors && modelKey !== null && draft.input !== null && draft.output !== null + ? { + modelKey, + inputUsdPer1M: draft.input, + outputUsdPer1M: draft.output, + ...(draft.cacheRead !== null ? { cacheReadUsdPer1M: draft.cacheRead } : {}), + ...(draft.cacheWrite !== null ? { cacheWriteUsdPer1M: draft.cacheWrite } : {}), + } + : null; + + return { errors, hasErrors, config }; +} + +function validateRequiredRate(value: number | null): 'ok' | PricingRateErrorCode { + if (value === null) return 'required'; + return isValidRate(value) ? 'ok' : 'invalid_rate'; +} + +function isValidRate(value: number): boolean { + return Number.isFinite(value) && value >= 0; +} diff --git a/apps/desktop/src/renderer/settings/usage-settings-page.tsx b/apps/desktop/src/renderer/settings/usage-settings-page.tsx index 7d0aa1c909..569eb057f8 100644 --- a/apps/desktop/src/renderer/settings/usage-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/usage-settings-page.tsx @@ -17,19 +17,12 @@ * under the License. */ -import { useMemo, useState, type ReactNode } from 'react'; +import { useMemo, useState } from 'react'; import { - Card, - EmptyState, SegmentedControl, SegmentedControlItem, Tab, TabList, - Table, - type TableColumn, - type TablePlugin, - pixel, - proportional, } from '@astryxdesign/core'; import { uiLocaleToIntlLocale } from '@maka/core/ui-locale'; import { parseDesktopSessionKey } from '../../shared/runtime-host-identity.js'; @@ -49,7 +42,7 @@ import { useUiLocale, Banner, } from '@maka/ui'; -import { ICON_SIZE, Activity, BarChart3, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons'; +import { ICON_SIZE, Activity, Cpu, Database, RefreshCcw, Search } from '@maka/ui/icons'; import { getUsageSettingsCopy, type UsageSettingsCopy, @@ -59,6 +52,8 @@ import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsPage } from './settings-section'; import { useActionGuard } from './use-action-guard'; import { useOptimisticSettingsDraft } from './use-optimistic-settings-draft'; +import { PricingSettingsPage } from './pricing-settings-page'; +import { UsageStatsTable } from './usage-stats-table'; type UsageActiveTab = AppSettings['usage']['activeTab']; @@ -102,12 +97,11 @@ export function UsageSettingsPage(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, }; async function setRange(range: UsageRange) { @@ -156,7 +150,10 @@ export function UsageSettingsPage(props: { return ( - {usageIncomplete ? ( + {/* The incomplete-data warning is about the selected range's usage rows, + so it belongs to the time-scoped tabs only — never the Pricing tab, + which is not time-scoped (#2015). */} + {usageIncomplete && usageDraft.activeTab !== 'pricing' ? ( ) : null} -
+ {/* Pricing is not time-scoped (#2015): hide the range + summary on that + tab so the Usage date range cannot be mistaken for a Pricing scope. + The Pricing panel renders its own header and refresh. */} + {usageDraft.activeTab !== 'pricing' ? ( +
+ ) : null}
@@ -215,7 +217,7 @@ export function UsageSettingsPage(props: { {tabCounts.providers}} /> {tabCounts.models}} /> {tabCounts.tools}} /> - {tabCounts.pricing}} /> +
@@ -262,7 +264,7 @@ export function UsageSettingsPage(props: { {usageDraft.activeTab === 'pricing' ? (
- +
) : null}
@@ -440,22 +442,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) { @@ -517,104 +503,3 @@ function usageRequestStatusLabel(status: UsageStats['logs'][number]['status'], c case 'aborted': return copy.tables.aborted; } } - -// ── Usage table mapping ───────────────────────────────────────────────────── -// Astryx Table owns table geometry, scrolling, dividers, density, and cell -// semantics. This page only maps its product rows and empty-state copy into -// that public API. - -interface UsageColumn { - header: string; - numeric?: boolean; - grow?: boolean; - width?: number; -} - -type UsageTableRow = Record & { id: number }; - -function usageCellNeedsCustomRenderer(value: ReactNode) { - return value !== null - && value !== undefined - && !['string', 'number', 'boolean', 'bigint'].includes(typeof value); -} - -const usageTablePlugins = { - cellSemantics: { - transformBodyCell: (cell, column, _row, columnIndex) => ({ - ...cell, - htmlProps: { - ...cell.htmlProps, - ...(columnIndex === 0 ? { role: 'rowheader' as const } : {}), - ...(column.align === 'end' - ? { - className: [cell.htmlProps.className, 'settingsUsageNumericCell'] - .filter(Boolean) - .join(' '), - } - : {}), - }, - }), - }, -} satisfies Record>; - -interface UsageEmpty { - /** A lucide icon (same shape EmptyState accepts). */ - Icon: typeof Search; - title: string; - body?: string; - /** Tier-3 single action (DESIGN.md §10) — e.g. a filter empty's clear button. */ - action?: ReactNode; -} - -function UsageStatsTable(props: { - ariaLabel: string; - columns: UsageColumn[]; - rows: Array>; - empty: UsageEmpty; -}) { - if (props.rows.length === 0) { - return ( - } - title={props.empty.title} - description={props.empty.body ?? undefined} - actions={props.empty.action} - className="settingsUsageEmpty" - /> - ); - } - const data: UsageTableRow[] = props.rows.map((cells, id) => ({ - id, - ...Object.fromEntries(cells.map((cell, index) => [`cell-${index}`, cell])), - })); - const columns: Array> = props.columns.map((column, index) => { - const key = `cell-${index}`; - const needsCustomRenderer = props.rows.some((row) => usageCellNeedsCustomRenderer(row[index])); - return { - key, - header: column.header, - align: column.numeric ? 'end' : 'start', - width: column.width !== undefined - ? pixel(column.width) - : column.grow - ? proportional(1) - : pixel(column.numeric ? 88 : 120), - ...(needsCustomRenderer ? { renderCell: (row) => row[key] as ReactNode } : {}), - }; - }); - - return ( - - - - ); -} diff --git a/apps/desktop/src/renderer/settings/usage-stats-table.tsx b/apps/desktop/src/renderer/settings/usage-stats-table.tsx new file mode 100644 index 0000000000..aec2473296 --- /dev/null +++ b/apps/desktop/src/renderer/settings/usage-stats-table.tsx @@ -0,0 +1,131 @@ +/* + * 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 { ReactNode } from 'react'; +import { + Card, + EmptyState, + Table, + type TableColumn, + type TablePlugin, + pixel, + proportional, +} from '@astryxdesign/core'; +import type { LucideIcon } from '@maka/ui/icons'; + +// Astryx Table owns table geometry, scrolling, dividers, density, and cell +// semantics. The Usage tabs and the Pricing panel both map their product rows +// and empty-state copy into this shared recipe (hairline / column-rhythm / +// tabular-nums) rather than re-deriving Table geometry each. + +export interface UsageColumn { + header: string; + numeric?: boolean; + grow?: boolean; + width?: number; +} + +type UsageTableRow = Record & { id: number }; + +function usageCellNeedsCustomRenderer(value: ReactNode) { + return value !== null + && value !== undefined + && !['string', 'number', 'boolean', 'bigint'].includes(typeof value); +} + +const usageTablePlugins = { + cellSemantics: { + transformBodyCell: (cell, column, _row, columnIndex) => ({ + ...cell, + htmlProps: { + ...cell.htmlProps, + ...(columnIndex === 0 ? { role: 'rowheader' as const } : {}), + ...(column.align === 'end' + ? { + className: [cell.htmlProps.className, 'settingsUsageNumericCell'] + .filter(Boolean) + .join(' '), + } + : {}), + }, + }), + }, +} satisfies Record>; + +export interface UsageEmpty { + /** A lucide icon (same shape EmptyState accepts). */ + Icon: LucideIcon; + title: string; + body?: string; + /** Tier-3 single action (DESIGN.md §10) — e.g. a filter empty's clear button. */ + action?: ReactNode; +} + +export function UsageStatsTable(props: { + ariaLabel: string; + columns: UsageColumn[]; + rows: Array>; + empty: UsageEmpty; +}) { + if (props.rows.length === 0) { + return ( + } + title={props.empty.title} + description={props.empty.body ?? undefined} + actions={props.empty.action} + className="settingsUsageEmpty" + /> + ); + } + const data: UsageTableRow[] = props.rows.map((cells, id) => ({ + id, + ...Object.fromEntries(cells.map((cell, index) => [`cell-${index}`, cell])), + })); + const columns: Array> = props.columns.map((column, index) => { + const key = `cell-${index}`; + const needsCustomRenderer = props.rows.some((row) => usageCellNeedsCustomRenderer(row[index])); + return { + key, + header: column.header, + align: column.numeric ? 'end' : 'start', + width: column.width !== undefined + ? pixel(column.width) + : column.grow + ? proportional(1) + : pixel(column.numeric ? 88 : 120), + ...(needsCustomRenderer ? { renderCell: (row) => row[key] as ReactNode } : {}), + }; + }); + + return ( + +
+ + ); +} diff --git a/apps/desktop/src/renderer/settings/use-pricing-settings-controller.ts b/apps/desktop/src/renderer/settings/use-pricing-settings-controller.ts new file mode 100644 index 0000000000..22eb923b2c --- /dev/null +++ b/apps/desktop/src/renderer/settings/use-pricing-settings-controller.ts @@ -0,0 +1,364 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useToast, useUiLocale } from '@maka/ui'; +import type { PricingMutation } from '@maka/runtime-host/protocol'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../shared/desktop-pricing.js'; +import { getPricingSettingsCopy } from '../locales/settings-pricing-copy.js'; +import { settingsActionErrorMessage } from './settings-error-copy.js'; +import { useKeyedActionGuard } from './use-action-guard.js'; +import { + useRuntimeHostSettingsErrorReporter, + useRuntimeHostSettingsGenerationKey, + useRuntimeHostSettingsTarget, +} from './runtime-host-settings-target.js'; +import { + derivePricingRows, + validatePricingDraft, + type PricingDraft, + type PricingRowView, +} from './pricing-settings-view-model.js'; + +type PricingEditor = + | { readonly mode: 'add' } + | { readonly mode: 'edit'; readonly row: PricingRowView }; + +/** + * Write blockers from #2015: after a save whose post-commit reload failed, or an + * outcome we could not reconcile, further writes are disabled until a fresh + * snapshot loads. `conflict` keeps the draft and allows an explicit second save + * against the latest snapshot. + */ +export type PricingWriteState = + | { readonly kind: 'idle' } + | { + readonly kind: 'conflict'; + readonly latest: DesktopPricingSnapshot; + readonly reason: 'revision_conflict' | 'outcome_unknown'; + } + | { readonly kind: 'refresh_failed' } + | { readonly kind: 'reconcile_unavailable'; readonly reason: 'revision_conflict' | 'outcome_unknown' }; + +const EMPTY_DRAFT: PricingDraft = { + provider: '', + model: '', + input: null, + output: null, + cacheRead: null, + cacheWrite: null, +}; + +/** Owns the Host-backed Pricing snapshot, the editor draft, and every outcome. */ +export function usePricingSettingsController() { + const host = useRuntimeHostSettingsTarget(); + const generationKey = useRuntimeHostSettingsGenerationKey(); + const reportHostError = useRuntimeHostSettingsErrorReporter(); + const locale = useUiLocale(); + const copy = getPricingSettingsCopy(locale); + const toast = useToast(); + + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [editor, setEditor] = useState(null); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [cacheOpen, setCacheOpen] = useState(false); + const [writeState, setWriteState] = useState({ kind: 'idle' }); + const [saving, setSaving] = useState(false); + const [resetTarget, setResetTarget] = useState(null); + const [resetBusy, setResetBusy] = useState(false); + const triggerRef = useRef(null); + + const guard = useKeyedActionGuard(); + const mountedRef = useRef(false); + const lifecycleRef = useRef(0); + const reloadTicketRef = useRef(0); + // Bumped whenever the selected Host enters a new lifecycle generation. A + // mutation captures it at dispatch and drops its result if the generation + // changed while it was in flight — an old-generation save must never write + // back onto a freshly loaded snapshot. + const generationEpochRef = useRef(0); + + useEffect(() => { + lifecycleRef.current += 1; + mountedRef.current = true; + const lifecycle = lifecycleRef.current; + return () => { + if (lifecycleRef.current !== lifecycle) return; + mountedRef.current = false; + reloadTicketRef.current += 1; + }; + }, []); + + function isCurrent(lifecycle: number, epoch: number): boolean { + return ( + mountedRef.current && + lifecycleRef.current === lifecycle && + generationEpochRef.current === epoch + ); + } + + async function reload(): Promise { + const lifecycle = lifecycleRef.current; + const epoch = generationEpochRef.current; + const ticket = ++reloadTicketRef.current; + setLoading(true); + try { + const next = await window.maka.settings.pricing.load(host); + if (!isCurrent(lifecycle, epoch) || ticket !== reloadTicketRef.current) return; + setSnapshot(next); + setLoadError(null); + setWriteState({ kind: 'idle' }); + } catch (error) { + if (!isCurrent(lifecycle, epoch) || ticket !== reloadTicketRef.current) return; + setLoadError(settingsActionErrorMessage(error, locale)); + } finally { + if (isCurrent(lifecycle, epoch) && ticket === reloadTicketRef.current) setLoading(false); + } + } + + // Load on mount and whenever the selected Host enters a new lifecycle + // generation (same profile:host, new epoch). Bumping the generation epoch + // invalidates any in-flight mutation from the previous generation. The draft + // is intentionally preserved across a generation change; only the + // snapshot/base is discarded. + useEffect(() => { + generationEpochRef.current += 1; + setSnapshot(null); + setWriteState({ kind: 'idle' }); + void reload(); + }, [generationKey]); + + const rows = useMemo(() => derivePricingRows(snapshot?.entries ?? []), [snapshot]); + const existingKeys = useMemo(() => rows.map((row) => row.modelKey), [rows]); + const validation = useMemo( + () => + validatePricingDraft(draft, { + mode: editor?.mode ?? 'add', + existingKeys, + lockedModelKey: editor?.mode === 'edit' ? editor.row.modelKey : undefined, + }), + [draft, editor, existingKeys], + ); + + const writesBlocked = + writeState.kind === 'refresh_failed' || writeState.kind === 'reconcile_unavailable'; + + // On a conflict, the fresh-authority row for whatever the user is editing or + // resetting — so the notice can show the latest value beside their draft + // rather than only claiming one exists. + const conflictLatestEntry = useMemo(() => { + if (writeState.kind !== 'conflict') return null; + const key = + editor?.mode === 'edit' + ? editor.row.modelKey + : editor?.mode === 'add' + ? (validation.config?.modelKey ?? null) + : (resetTarget?.modelKey ?? null); + if (!key) return null; + return derivePricingRows(writeState.latest.entries).find((row) => row.modelKey === key) ?? null; + }, [writeState, editor, resetTarget, validation]); + + function restoreTriggerFocus() { + const trigger = triggerRef.current; + triggerRef.current = null; + if (trigger?.isConnected) requestAnimationFrame(() => trigger.focus()); + } + + function openAdd(trigger: HTMLElement | null) { + if (writesBlocked) return; + triggerRef.current = trigger; + setDraft(EMPTY_DRAFT); + setCacheOpen(false); + setEditor({ mode: 'add' }); + } + + function openEdit(row: PricingRowView, trigger: HTMLElement | null) { + if (writesBlocked) return; + triggerRef.current = trigger; + setDraft({ + provider: row.provider, + model: row.model, + input: row.inputUsdPer1M, + output: row.outputUsdPer1M, + cacheRead: row.cacheReadUsdPer1M ?? null, + cacheWrite: row.cacheWriteUsdPer1M ?? null, + }); + setCacheOpen(row.cacheReadUsdPer1M !== undefined || row.cacheWriteUsdPer1M !== undefined); + setEditor({ mode: 'edit', row }); + } + + function closeEditor() { + if (saving) return; + setEditor(null); + if (writeState.kind === 'conflict') setWriteState({ kind: 'idle' }); + restoreTriggerFocus(); + } + + const setField = (key: K, value: PricingDraft[K]) => + setDraft((current) => ({ ...current, [key]: value })); + + /** Map a settled outcome to state; `onCommitted` runs on saved/synchronized. */ + function applyOutcome( + outcome: DesktopPricingMutationOutcome, + onCommitted: () => void, + ): void { + switch (outcome.kind) { + case 'saved': + setSnapshot(outcome.snapshot); + setWriteState({ kind: 'idle' }); + onCommitted(); + toast.success(copy.saved, outcome.disposition === 'unchanged' ? copy.synchronized : undefined); + return; + case 'synchronized': + setSnapshot(outcome.snapshot); + setWriteState({ kind: 'idle' }); + onCommitted(); + toast.success(copy.synchronized); + return; + case 'review_required': + // Adopt fresh authority into the list so it is no longer speculative, + // keep the draft, and require an explicit second save against `latest`. + setSnapshot(outcome.snapshot); + setWriteState({ kind: 'conflict', latest: outcome.snapshot, reason: outcome.reason }); + return; + case 'saved_refresh_failed': + setWriteState({ kind: 'refresh_failed' }); + return; + case 'reconciliation_unavailable': + setWriteState({ kind: 'reconcile_unavailable', reason: outcome.reason }); + return; + } + } + + /** The CAS base: the latest we saw on a conflict, else the loaded snapshot. */ + function mutationBase(): DesktopPricingSnapshot | null { + return writeState.kind === 'conflict' ? writeState.latest : snapshot; + } + + async function save() { + const config = validation.config; + const base = mutationBase(); + if (!config || !base || saving) return; + const release = guard.begin('write'); + if (!release) return; + const lifecycle = lifecycleRef.current; + const epoch = generationEpochRef.current; + setSaving(true); + try { + const mutation: PricingMutation = { kind: 'upsert', pricing: config }; + const outcome = await window.maka.settings.pricing.mutate(base, mutation, host); + if (!isCurrent(lifecycle, epoch)) return; + applyOutcome(outcome, () => { + setEditor(null); + restoreTriggerFocus(); + }); + } catch (error) { + if (isCurrent(lifecycle, epoch)) { + reportHostError(copy.saveFailed, settingsActionErrorMessage(error, locale)); + } + } finally { + release(); + if (isCurrent(lifecycle, epoch)) setSaving(false); + } + } + + function openReset(row: PricingRowView, trigger: HTMLElement | null) { + if (writesBlocked) return; + triggerRef.current = trigger; + setResetTarget(row); + } + + function cancelReset() { + if (resetBusy) return; + setResetTarget(null); + restoreTriggerFocus(); + } + + async function confirmReset() { + const target = resetTarget; + const base = mutationBase(); + if (!target || !base || resetBusy) return; + const release = guard.begin('write'); + if (!release) return; + const lifecycle = lifecycleRef.current; + const epoch = generationEpochRef.current; + setResetBusy(true); + try { + const mutation: PricingMutation = { kind: 'delete', modelKey: target.modelKey }; + const outcome = await window.maka.settings.pricing.mutate(base, mutation, host); + if (!isCurrent(lifecycle, epoch)) return; + applyOutcome(outcome, () => { + setResetTarget(null); + restoreTriggerFocus(); + toast.success(copy.resetDone); + }); + // A conflict keeps the confirm dialog open for an explicit second + // confirm against fresh authority (mutationBase() now returns `latest`). + // An uncertain outcome blocks writes — close the dialog; the panel notice + // explains the next step. + if ( + outcome.kind === 'saved_refresh_failed' || + outcome.kind === 'reconciliation_unavailable' + ) { + setResetTarget(null); + } + } catch (error) { + if (isCurrent(lifecycle, epoch)) { + reportHostError(copy.resetFailed, settingsActionErrorMessage(error, locale)); + } + } finally { + release(); + if (isCurrent(lifecycle, epoch)) setResetBusy(false); + } + } + + return { + copy, + locale, + loading, + loadError, + rows, + editor, + draft, + setField, + cacheOpen, + setCacheOpen, + validation, + writeState, + writesBlocked, + conflictLatestEntry, + saving, + resetTarget, + resetBusy, + triggerRef, + reload, + openAdd, + openEdit, + closeEditor, + save, + openReset, + cancelReset, + confirmReset, + }; +} diff --git a/apps/desktop/src/renderer/styles/settings/usage.css b/apps/desktop/src/renderer/styles/settings/usage.css index 84197a4150..78939aa216 100644 --- a/apps/desktop/src/renderer/styles/settings/usage.css +++ b/apps/desktop/src/renderer/styles/settings/usage.css @@ -53,6 +53,40 @@ justify-content: space-between; } +/* The Pricing panel owns its own header (title + explainer on the left, refresh + and the primary "add" pinned to the trailing edge) because it is not + time-scoped and drops the range/summary the other Usage tabs share. */ +.settingsPricing { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; +} + +.settingsPricingHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--space-2); +} + +.settingsPricingHeading { + display: flex; + flex-direction: column; + gap: var(--space-1); + flex: 1 1 20rem; + min-width: 0; +} + +/* After an uncertain / refresh-failed write, the loaded list may be out of + date. Dim it (and drop it from the tab order via inert-like affordance) so + it does not read as authoritative until a fresh snapshot loads. */ +.settingsPricingStale { + opacity: 0.55; + pointer-events: none; +} + /* The wrapper only contains overflow at the minimum window width; Astryx owns the tab geometry, divider, focus ring, and selected indicator. */ .settingsUsageTabsBar { diff --git a/apps/desktop/src/shared/desktop-pricing.ts b/apps/desktop/src/shared/desktop-pricing.ts new file mode 100644 index 0000000000..8ba94117c8 --- /dev/null +++ b/apps/desktop/src/shared/desktop-pricing.ts @@ -0,0 +1,154 @@ +/* + * 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. + * + * 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`. `decodeDesktopPricingSnapshot` + * guards that round-trip at the Main IPC boundary so a malformed base fails + * cleanly instead of deep inside the adapter. + */ + +import { + comparePricingModelKeys, + validateCanonicalPricingConfig, +} from '@maka/core/usage-stats/pricing'; +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'; + }; + +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/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx index df32fa0951..2f6af6a2b7 100644 --- a/apps/desktop/stories/settings/settings-pages.stories.tsx +++ b/apps/desktop/stories/settings/settings-pages.stories.tsx @@ -40,6 +40,11 @@ import type { } from '@maka/core/capabilities'; import type { HealthSignal, HealthSnapshot } from '@maka/core/health'; import type { DesktopExternalSessionCatalogItem } from '../../src/preload/external-session-catalog'; +import type { EffectivePricingEntry } from '@maka/runtime-host/protocol'; +import type { + DesktopPricingMutationOutcome, + DesktopPricingSnapshot, +} from '../../src/shared/desktop-pricing'; import type { SessionSummary } from '@maka/core/session'; import { revisionFamilySessionIds } from '@maka/core/session-revisions'; import type { IdentifiedLlmConnection, LlmConnection, ProviderType } from '@maka/core/llm-connections'; @@ -301,7 +306,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, }; @@ -322,7 +326,6 @@ const emptyUsageStats: UsageStats = { byProvider: [], byModel: [], byTool: [], - pricing: [], provenance: EMPTY_USAGE_PROVENANCE, }; @@ -362,6 +365,54 @@ const multiModelUsageStats: UsageStats = { provenance: STORY_USAGE_PROVENANCE, }; +// ── Pricing fixtures ──────────────────────────────────────────────────────── +function builtinPricing( + modelKey: string, + input: number, + output: number, + cacheRead?: number, +): EffectivePricingEntry { + return { + source: 'builtin', + pricing: { + modelKey, + inputUsdPer1M: input, + outputUsdPer1M: output, + ...(cacheRead !== undefined ? { cacheReadUsdPer1M: cacheRead } : {}), + }, + }; +} + +function customPricing( + modelKey: string, + input: number, + output: number, + resetEffect: 'restore_builtin' | 'become_unpriced', + cache?: { readonly read?: number; readonly write?: number }, +): EffectivePricingEntry { + return { + source: 'custom', + resetEffect, + pricing: { + modelKey, + inputUsdPer1M: input, + outputUsdPer1M: output, + ...(cache?.read !== undefined ? { cacheReadUsdPer1M: cache.read } : {}), + ...(cache?.write !== undefined ? { cacheWriteUsdPer1M: cache.write } : {}), + }, + }; +} + +function pricingSnapshot(entries: EffectivePricingEntry[]): DesktopPricingSnapshot { + return { hostEpoch: 'story-epoch', connectionId: 'story-conn', revision: 3, entries }; +} + +const STORY_PRICING_MIXED = pricingSnapshot([ + customPricing('acme:coder-v2', 0.8, 2.4, 'become_unpriced'), + customPricing('anthropic:claude-sonnet-4-5', 2, 12, 'restore_builtin', { read: 0.3, write: 3.75 }), + builtinPricing('openai:gpt-4o', 2.5, 10, 1.25), +]); + function makeMemoryEntry(input: { id: string; title: string; @@ -778,6 +829,14 @@ const makaBridge = { subscribeClientChanged: () => () => undefined, subscribeExternalChanged: () => () => undefined, usageStats: async (): Promise => usageStats, + pricing: { + load: async (): Promise => STORY_PRICING_MIXED, + mutate: async (): Promise => ({ + kind: 'saved', + disposition: 'committed', + snapshot: STORY_PRICING_MIXED, + }), + }, bots: { listStatuses: async () => ({}), subscribeStatusChanges: () => () => undefined, @@ -1397,6 +1456,36 @@ function withUsageStoryBridge( } satisfies Record); } +// Pricing is its own tab within the Usage section; drive it with `activeTab` +// and override just the pricing read. usageStats is still fetched by the Usage +// section on mount, so it must resolve (the pricing tab hides the summary). +function withPricingStoryBridge(load: () => Promise) { + const settings = mergeSettings(createDefaultSettings(), { + usage: { activeTab: 'pricing' }, + }); + return withScopedMakaBridge({ + ...makaBridge, + settings: { + ...makaBridge.settings, + get: async () => settings, + update: async ( + patch: Parameters[0], + ): Promise => ({ + settings: mergeSettings(settings, patch), + }), + usageStats: async (): Promise => emptyUsageStats, + pricing: { + load, + mutate: async (): Promise => ({ + kind: 'saved', + disposition: 'committed', + snapshot: await load(), + }), + }, + }, + } satisfies Record); +} + const withUsageEmptyBridge = withUsageStoryBridge(emptyUsageStats, { activeTab: 'providers', }); @@ -2091,6 +2180,62 @@ export const UsagePersistedRangeRestore: Story = { ); }, }; + +// Real path: 设置 → 使用统计 → 定价配置 — the effective table (built-in rows plus +// user overrides). Interaction states (editor, validation, conflict, uncertain) +// are asserted in pricing-settings-page.test.ts rather than autoplay-disabled +// stories, per stories/FIDELITY.md. +export const PricingMixed: Story = { + decorators: [withPricingStoryBridge(async () => STORY_PRICING_MIXED)], + render: () => , +}; +// Real path: 定价配置 while the first Host-backed snapshot is still loading. +export const PricingLoading: Story = { + decorators: [withPricingStoryBridge(() => pendingForever())], + render: () => , +}; +// Real path: 定价配置 when the Host pricing read fails — shows retry. +export const PricingReadError: Story = { + decorators: [ + withPricingStoryBridge(async () => { + throw new Error('Runtime Host pricing is unavailable'); + }), + ], + render: () => , +}; +// Real path: 定价配置 on a fresh install — every row is a bundled built-in price +// with no user overrides yet, so each row offers only 自定义 (no reset/delete). +export const PricingBuiltinOnly: Story = { + decorators: [ + withPricingStoryBridge(async () => + pricingSnapshot([ + builtinPricing('anthropic:claude-sonnet-4-5', 3, 15, 0.3), + builtinPricing('openai:gpt-4o', 2.5, 10, 1.25), + builtinPricing('openai:gpt-4o-mini', 0.15, 0.6, 0.075), + ]), + ), + ], + render: () => , +}; +// Real path: 定价配置 where every row is a Custom-only override (no bundled +// fallback) — the secondary action is 删除, and removing a row leaves newly +// activated work unpriced rather than restoring a built-in price. +export const PricingCustomOnly: Story = { + decorators: [ + withPricingStoryBridge(async () => + pricingSnapshot([ + customPricing('acme:coder-v2', 0.8, 2.4, 'become_unpriced'), + customPricing('relay:transit-large', 1.2, 4, 'become_unpriced', { read: 0.15 }), + ]), + ), + ], + render: () => , +}; +// Real path: the same mixed 定价配置 table at the minimum supported window width. +export const PricingNarrow: Story = { + ...PricingMixed, + parameters: { viewport: { defaultViewport: 'mobile2' } }, +}; /** * #1364: entry list (long title / content / tag set), archived group, and * backup-candidate rows. The bridge used to lack the `memory` channel diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index b1e9f656c0..d86be0a1ea 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.0` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 231 files — blocker 0, reimplementation 0, polish 1, aligned 230. +**Totals:** 233 files — blocker 0, reimplementation 0, polish 1, aligned 232. ## Exclusions (explicit) @@ -101,6 +101,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/settings/password-input.tsx` | settings-module | IconButton, InputGroup, InputGroupText, TextInput | aligned — uses Astryx (IconButton, InputGroup, InputGroupText, TextInput) | aligned | | `apps/desktop/src/renderer/settings/permission-center-page.tsx` | settings-page | Banner, Button, Collapsible, CollapsibleGroup, HStack, List, ListItem, MetadataList, MetadataListItem, StatusDot, Text, VStack | aligned — uses Astryx (Banner, Button, Collapsible, CollapsibleGroup, HStack, List, ListItem, MetadataList) | aligned | | `apps/desktop/src/renderer/settings/personalization-settings-section.tsx` | settings-module | SegmentedControl, SegmentedControlItem, TextArea, TextInput | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, TextArea, TextInput) | aligned | +| `apps/desktop/src/renderer/settings/pricing-settings-page.tsx` | settings-page | AlertDialog, Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack, Heading, Layout, LayoutContent, LayoutFooter, NumberInput, Skeleton, Text, TextInput, VStack | aligned — uses Astryx (AlertDialog, Banner, Button, Collapsible, Dialog, DialogHeader, EmptyState, HStack) | aligned | | `apps/desktop/src/renderer/settings/projects-settings-page.tsx` | settings-page | Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu, TextInput | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, List, ListItem, MoreMenu) | aligned | | `apps/desktop/src/renderer/settings/provider-add-form.tsx` | settings-module | Banner, Button, Collapsible, FormLayout, HStack, TextInput, VStack | aligned — uses Astryx (Banner, Button, Collapsible, FormLayout, HStack, TextInput, VStack) | aligned | | `apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx` | settings-module | Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter, NumberInput, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, FormLayout, HStack, Layout, LayoutContent, LayoutFooter) | aligned | @@ -131,7 +132,8 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `apps/desktop/src/renderer/settings/settings-surface.tsx` | settings-module | Badge, Banner, Button, IconButton, Layout, LayoutContent, LayoutHeader, LayoutPanel, Selector, SideNav, SideNavItem, SideNavSection | aligned — uses Astryx (Badge, Banner, Button, IconButton, Layout, LayoutContent, LayoutHeader, LayoutPanel) | aligned | | `apps/desktop/src/renderer/settings/subagent-settings-page.tsx` | settings-page | Badge, Banner, Button, EmptyState, HStack, IconButton, Selector, Switch, TextArea, TextInput, VStack | aligned — uses Astryx (Badge, Banner, Button, EmptyState, HStack, IconButton, Selector, Switch) | aligned | | `apps/desktop/src/renderer/settings/tasks-settings-page.tsx` | settings-page | Button, EmptyState, HStack, List, ListItem, MoreMenu, StackItem, TextInput | aligned — uses Astryx (Button, EmptyState, HStack, List, ListItem, MoreMenu, StackItem, TextInput) | aligned | -| `apps/desktop/src/renderer/settings/usage-settings-page.tsx` | settings-page | Banner, Button, Card, EmptyState, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, Table, TextInput | aligned — uses Astryx (Banner, Button, Card, EmptyState, SegmentedControl, SegmentedControlItem, Selector, Switch) | aligned | +| `apps/desktop/src/renderer/settings/usage-settings-page.tsx` | settings-page | Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList, TextInput | aligned — uses Astryx (Banner, Button, SegmentedControl, SegmentedControlItem, Selector, Switch, Tab, TabList) | aligned | +| `apps/desktop/src/renderer/settings/usage-stats-table.tsx` | settings-module | Card, EmptyState, Table | aligned — uses Astryx (Card, EmptyState, Table) | aligned | | `apps/desktop/src/renderer/settings/web-search-settings-page.tsx` | settings-page | Banner, Button, EmptyState, Link, Selector, StatusDot, Switch, TextInput | aligned — uses Astryx (Banner, Button, EmptyState, Link, Selector, StatusDot, Switch, TextInput) | aligned | | `apps/desktop/src/renderer/styles.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `apps/desktop/src/renderer/styles/agent-graph.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index dbde3ed403..ff6684eee3 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -72,6 +72,7 @@ apps/desktop/src/renderer/settings/memory-settings-sections.tsx apps/desktop/src/renderer/settings/password-input.tsx apps/desktop/src/renderer/settings/permission-center-page.tsx apps/desktop/src/renderer/settings/personalization-settings-section.tsx +apps/desktop/src/renderer/settings/pricing-settings-page.tsx apps/desktop/src/renderer/settings/projects-settings-page.tsx apps/desktop/src/renderer/settings/provider-add-form.tsx apps/desktop/src/renderer/settings/provider-add-model-dialog.tsx @@ -103,6 +104,7 @@ apps/desktop/src/renderer/settings/settings-surface.tsx apps/desktop/src/renderer/settings/subagent-settings-page.tsx apps/desktop/src/renderer/settings/tasks-settings-page.tsx apps/desktop/src/renderer/settings/usage-settings-page.tsx +apps/desktop/src/renderer/settings/usage-stats-table.tsx apps/desktop/src/renderer/settings/web-search-settings-page.tsx apps/desktop/src/renderer/styles.css apps/desktop/src/renderer/styles/agent-graph.css diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 001bde9aa6..c9e9b69f04 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -609,12 +609,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