From e986560ee8f7c06d6752ab86ee1890579750e5f7 Mon Sep 17 00:00:00 2001 From: Prekzursil Date: Sat, 4 Jul 2026 04:44:06 +0300 Subject: [PATCH 001/104] feat(brand): unify display name to "Reframe" + bump to 1.3.0 (WU A1) Unify every user-facing surface to the single brand "Reframe": the Electron BrowserWindow title, the in-app header, the native About panel (app.setAboutPanelOptions), and the electron-builder productName + nsis shortcutName. Add productName:"Reframe" to app/package.json and bump the version 1.2.0 -> 1.3.0. The internal id "media-studio" (package name, appId local.media-studio, the name-based artifact filename, appData/path literals) is deliberately preserved so first-run state, proxy/peak/dub caches, and the sidecar-env sentinel are untouched. Extend brand.test.ts to assert version 1.3.0, productName "Reframe", the new brand on all four surfaces + the About panel, and a no-user-facing-media-studio audit. Update the two e2e brand assertions and add a CHANGELOG 1.3.0 entry noting the 1.1.0 -> 1.2.0 -> 1.3 lineage. --- CHANGELOG.md | 19 ++++++++ app/e2e/packaged.spec.ts | 2 +- app/e2e/preview.spec.ts | 2 +- app/main/brand.test.ts | 99 ++++++++++++++++++++++++++++++++++------ app/main/main.ts | 6 ++- app/package.json | 3 +- app/renderer/src/App.tsx | 2 +- electron-builder.yml | 4 +- 8 files changed, 115 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 897bac9d..952bddcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### [1.3.0] — in development + +**Reframe v1.3 — naming lock + provenance reconcile.** v1.3 continues the +`1.1.0 → 1.2.0 → 1.3` lineage (v1.2.0 = the YuNet detector / virality badge / +EdgeTAM tracker release) on the `feat/reframe-v1.3` branch built off the clean +`v1.2.0` base. The final `1.3.0` tag is cut at release (workstream F). + +### Changed — display name unified to "Reframe" (WU A1) + +- **One user-facing name: "Reframe".** The window title, the in-app header, the + Electron About panel, and the installer/Start-menu shortcut now all read + **"Reframe"** (previously "Reframe - Media Studio"). `app/package.json` gains a + **`productName: "Reframe"`** key and bumps to **1.3.0**. The internal id + **`media-studio`** is deliberately unchanged — the package `name`, the + `local.media-studio` appId, the `${name}` installer-artifact filename, and every + appData/path literal keep it so first-run state, proxy/peak/dub caches, and the + sidecar-env sentinel are untouched. A brand guard test asserts no user-facing + surface leaks "media-studio"/"Media Studio". + ## [1.2.0] — 2026-07-03 **Reframe v1.2.0 — detector, virality, tracking + fail-loud hardening.** A single diff --git a/app/e2e/packaged.spec.ts b/app/e2e/packaged.spec.ts index 41294df6..69d6ff36 100644 --- a/app/e2e/packaged.spec.ts +++ b/app/e2e/packaged.spec.ts @@ -90,7 +90,7 @@ test.describe('packaged (shipped binary) E2E', () => { await win.waitForLoadState('domcontentloaded'); // The brand renders from the PACKAGED renderer bundle, driven by the bundled // Python sidecar (library + readiness rollup settle after the boot RPCs). - await expect(win.locator('.app__brand')).toHaveText('Reframe - Media Studio'); + await expect(win.locator('.app__brand')).toHaveText('Reframe'); await expect(win.locator('.library__title')).toHaveText('Library'); await win.waitForTimeout(1500); expect(consoleErrors, `console errors: ${JSON.stringify(consoleErrors)}`).toEqual([]); diff --git a/app/e2e/preview.spec.ts b/app/e2e/preview.spec.ts index 9fe2c92d..48141d68 100644 --- a/app/e2e/preview.spec.ts +++ b/app/e2e/preview.spec.ts @@ -53,7 +53,7 @@ test('renderer loads with no console errors', async () => { }); win.on('pageerror', (e) => consoleErrors.push(`PAGEERROR: ${e.message}`)); await win.waitForLoadState('domcontentloaded'); - await expect(win.locator('.app__brand')).toHaveText('Reframe - Media Studio'); + await expect(win.locator('.app__brand')).toHaveText('Reframe'); // Let the library list + readiness rollup settle (RPCs to the live sidecar). await win.waitForTimeout(1500); expect(consoleErrors, `console errors: ${JSON.stringify(consoleErrors)}`).toEqual([]); diff --git a/app/main/brand.test.ts b/app/main/brand.test.ts index 9d60a1af..dc770f88 100644 --- a/app/main/brand.test.ts +++ b/app/main/brand.test.ts @@ -1,11 +1,18 @@ -// Brand-rename guard for WU-FND-RENAME (P4 §0 / C13). +// Brand-rename + version guard for WU A1 (Reframe v1.3 naming lock). // -// The rename touches EXACTLY four brand surfaces and ZERO path literals. This -// test reads the REAL source files (not copies) so that: -// 1. all four brand surfaces read "Reframe - Media Studio", and -// 2. the appData/path literals still read "media-studio" — renaming a path -// literal would break first-run state, proxy/peak/dub caches, and the -// sidecar-env sentinel (a known regression class — P4 C13). +// v1.3 unifies the user-facing display name to a single word "Reframe" across +// EVERY user-visible surface (window title, in-app header, the Electron About +// panel, and the installer/shortcut names) while KEEPING the internal id +// "media-studio" (package `name`, reverse-DNS `appId`, the `${name}` artifact +// filename, and every appData/path literal) unchanged — renaming a path literal +// would break first-run state, proxy/peak/dub caches, and the sidecar-env +// sentinel (a known regression class — P4 C13). +// +// This test reads the REAL source files (not copies) so that: +// 1. all user-facing brand surfaces read "Reframe", +// 2. NO user-facing surface leaks "media-studio"/"Media Studio", and +// 3. app/package.json declares version 1.3.0 + productName "Reframe" while the +// internal `name` stays "media-studio". // Runs in the default node environment (filesystem access, no jsdom). Tests run // with cwd = app/, so repo paths are resolved via import.meta.url. import { describe, it, expect } from 'vitest'; @@ -19,33 +26,95 @@ const REPO_ROOT = resolve(HERE, '..', '..'); const MAIN_TS = resolve(REPO_ROOT, 'app', 'main', 'main.ts'); const APP_TSX = resolve(REPO_ROOT, 'app', 'renderer', 'src', 'App.tsx'); +const PACKAGE_JSON = resolve(REPO_ROOT, 'app', 'package.json'); const ELECTRON_BUILDER = resolve(REPO_ROOT, 'electron-builder.yml'); const SETTINGS_STORE = resolve(REPO_ROOT, 'sidecar', 'media_studio', 'settings_store.py'); const ASSETS_MANAGER = resolve(REPO_ROOT, 'sidecar', 'media_studio', 'assets', 'manager.py'); const read = (p: string): string => readFileSync(p, 'utf8'); -const BRAND = 'Reframe - Media Studio'; +const BRAND = 'Reframe'; + +// Extract the exact display strings from the real source. Each helper fails +// loudly (throws) if the anchor it depends on is gone, so a moved/renamed +// surface can never silently pass the audit below. +const capture = (src: string, re: RegExp, label: string): string => { + const m = re.exec(src); + if (!m) throw new Error(`brand surface not found: ${label}`); + return m[1]; +}; -describe('brand rename — four brand surfaces (P4 §0 / C13)', () => { +const windowTitle = (): string => capture(read(MAIN_TS), /title: '([^']*)'/, 'window title'); +const aboutName = (): string => + capture(read(MAIN_TS), /applicationName: '([^']*)'/, 'About panel applicationName'); +const headerBrand = (): string => + capture(read(APP_TSX), /([^<]*)<\/span>/, 'in-app header'); +const productName = (): string => + capture(read(ELECTRON_BUILDER), /^productName: (.+)$/m, 'electron-builder productName'); +const shortcutName = (): string => + capture(read(ELECTRON_BUILDER), /shortcutName: (.+)$/m, 'nsis shortcutName'); + +describe('brand rename — user-facing surfaces read "Reframe" (WU A1)', () => { it('window title reads the new brand (main.ts)', () => { - expect(read(MAIN_TS)).toContain(`title: '${BRAND}'`); + expect(windowTitle()).toBe(BRAND); + }); + + it('Electron About panel applicationName reads the new brand (main.ts)', () => { + expect(aboutName()).toBe(BRAND); }); - it('renderer brand string reads the new brand (App.tsx)', () => { - expect(read(APP_TSX)).toContain(`${BRAND}`); + it('renderer in-app header reads the new brand (App.tsx)', () => { + expect(headerBrand()).toBe(BRAND); }); it('electron-builder productName reads the new brand', () => { - expect(read(ELECTRON_BUILDER)).toContain(`productName: ${BRAND}`); + expect(productName()).toBe(BRAND); }); it('nsis shortcutName reads the new brand', () => { - expect(read(ELECTRON_BUILDER)).toContain(`shortcutName: ${BRAND}`); + expect(shortcutName()).toBe(BRAND); + }); +}); + +describe('brand rename — NO user-facing "media-studio" leak (WU A1 / R8 audit)', () => { + const surfaces: ReadonlyArray string]> = [ + ['window title', windowTitle], + ['About panel', aboutName], + ['in-app header', headerBrand], + ['productName', productName], + ['shortcutName', shortcutName], + ]; + + for (const [label, get] of surfaces) { + it(`${label} shows no "media-studio"/"Media Studio" to the user`, () => { + const shown = get().toLowerCase(); + expect(shown).not.toContain('media-studio'); + expect(shown).not.toContain('media studio'); + }); + } +}); + +describe('app/package.json — v1.3 version + productName (WU A1)', () => { + const pkg = JSON.parse(read(PACKAGE_JSON)) as { + version: string; + productName: string; + name: string; + }; + + it('version is bumped to 1.3.0', () => { + expect(pkg.version).toBe('1.3.0'); + }); + + it('productName is the display brand "Reframe"', () => { + expect(pkg.productName).toBe(BRAND); + }); + + it('internal package name stays "media-studio"', () => { + expect(pkg.name).toBe('media-studio'); }); }); -describe('brand rename — path literals stay "media-studio" (P4 C13 guard)', () => { +describe('brand rename — internal id stays "media-studio" (P4 C13 guard)', () => { it('main.ts appData first-run sentinel root stays media-studio', () => { const src = read(MAIN_TS); // resolveDataRoot()'s appData fallback: join(app.getPath('appData'), diff --git a/app/main/main.ts b/app/main/main.ts index d79e26e9..b9c74ddb 100644 --- a/app/main/main.ts +++ b/app/main/main.ts @@ -415,7 +415,7 @@ function createWindow(): BrowserWindow { minHeight: 600, show: false, backgroundColor: '#101014', - title: 'Reframe - Media Studio', + title: 'Reframe', webPreferences: { preload: join(__dirname, '../preload/preload.js'), contextIsolation: true, @@ -626,6 +626,10 @@ function bootstrap(): void { } app.whenReady().then(() => { + // The native "About Reframe" panel (Help ▸ About / macOS app menu) — the single + // user-facing About surface. applicationName is the display brand "Reframe"; + // applicationVersion reads package.json.version (1.3.0) via Electron. + app.setAboutPanelOptions({ applicationName: 'Reframe', applicationVersion: app.getVersion() }); bootstrap(); app.on('activate', () => { diff --git a/app/package.json b/app/package.json index 514f01b2..a82a6355 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,7 @@ { "name": "media-studio", - "version": "1.2.0", + "productName": "Reframe", + "version": "1.3.0", "description": "Local personal video-manager desktop app (Electron + Python sidecar)", "private": true, "type": "module", diff --git a/app/renderer/src/App.tsx b/app/renderer/src/App.tsx index 20223827..1b490cb4 100644 --- a/app/renderer/src/App.tsx +++ b/app/renderer/src/App.tsx @@ -355,7 +355,7 @@ function AppShell(): React.ReactElement { <>
- Reframe - Media Studio + Reframe +
+ + {shown} + + + + + + + + + +
+ + {replacing ? ( +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void submitReplace(); + }} + /> + +
+ ) : null} + + {status ? ( +

+ {status} +

+ ) : null} ); } diff --git a/app/renderer/src/features/ProvidersKeys.test.tsx b/app/renderer/src/features/ProvidersKeys.test.tsx index 341de9e3..3b3e8241 100644 --- a/app/renderer/src/features/ProvidersKeys.test.tsx +++ b/app/renderer/src/features/ProvidersKeys.test.tsx @@ -111,6 +111,7 @@ interface ApiOverrides { upsert?: ReturnType; remove?: ReturnType; testKey?: ReturnType; + revealKey?: ReturnType; setConsent?: ReturnType; settingsGet?: () => Promise<{ consent?: { perProvider?: Record } }>; } @@ -124,6 +125,7 @@ function makeApi(over: ApiOverrides = {}): ProvidersKeysProps['rpcClient'] { upsert: over.upsert ?? vi.fn(() => Promise.resolve({ providers: [] })), remove: over.remove ?? vi.fn(() => Promise.resolve({ providers: [] })), testKey: over.testKey ?? vi.fn(() => Promise.resolve({ ok: true } as TestKeyResult)), + revealKey: over.revealKey ?? vi.fn(() => Promise.resolve({ key: 'raw-revealed-key' })), setConsent: over.setConsent ?? vi.fn(() => Promise.resolve({ consent: { perProvider: {} } } as SetConsentResponse)), @@ -771,3 +773,117 @@ describe('ProvidersKeys — usage + secondary link', () => { expect(onOpenModels).toHaveBeenCalledTimes(1); }); }); + +// --- WU-D3: reveal / re-validate / replace wiring -------------------------- + +describe('ProvidersKeys — WU-D3 reveal / re-validate / replace', () => { + const REVEALED = 'gsk-live-RAW-SECRET-QRST'; + + function configuredApi(over: ApiOverrides = {}, apiKeys: string[] = ['…WXYZ']) { + return makeApi({ + list: () => + Promise.resolve({ + providers: [ + { id: 'groq', provider: 'Groq', baseUrl: 'https://b/v1', apiKeys }, + ], + }), + ...over, + }); + } + + it('reveals the full key via providers.revealKey and never stores it (no leak to store/log/upsert)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const revealKey = vi.fn(() => Promise.resolve({ key: REVEALED })); + const upsert = vi.fn(() => Promise.resolve({ providers: [] })); + const api = configuredApi({ revealKey, upsert }); + await mount({ rpcClient: api }); + + const revealBtn = container.querySelector('.provider-key-row__reveal')!; + await act(async () => revealBtn.click()); + await flush(); + + // Explicit-click reveal fetched exactly this provider's index-0 key. + expect(revealKey).toHaveBeenCalledWith('groq', 0); + expect(container.querySelector('.provider-key-row__value')?.textContent).toBe(REVEALED); + // The secret NEVER round-tripped into the store (no upsert) and never logged. + expect(upsert).not.toHaveBeenCalled(); + const logged = warn.mock.calls.flat().map(String); + expect(logged.some((s) => s.includes(REVEALED))).toBe(false); + }); + + it('re-validates a STORED key: revealKey -> testKey with the revealed plaintext', async () => { + const revealKey = vi.fn(() => Promise.resolve({ key: REVEALED })); + const testKey = vi.fn(() => Promise.resolve({ ok: true, capabilities: ['text'] })); + const api = configuredApi({ revealKey, testKey }); + await mount({ rpcClient: api }); + + const revalidateBtn = container.querySelector( + '.provider-key-row__revalidate', + )!; + await act(async () => revalidateBtn.click()); + await flush(); + + expect(revealKey).toHaveBeenCalledWith('groq', 0); + expect(testKey).toHaveBeenCalledWith({ + baseUrl: 'https://b/v1', + apiKey: REVEALED, + // No model on the entry → resolved from PROVIDER_META["Groq"]. + model: 'llama-3.3-70b-versatile', + capabilities: undefined, + }); + expect(container.querySelector('.provider-key-row__status')?.textContent).toBe( + 'Key verified — working.', + ); + }); + + it('replaces a key in place: re-validates the NEW key then upserts it at its index', async () => { + const testKey = vi.fn(() => Promise.resolve({ ok: true, capabilities: ['text'] })); + const upsert = vi.fn(() => + Promise.resolve({ + providers: [{ id: 'groq', provider: 'Groq', apiKeys: ['…QRST', '…1234'] }], + }), + ); + // Two keys: replacing index 0 must preserve the redacted sibling at index 1. + const api = configuredApi({ testKey, upsert }, ['…WXYZ', '…1234']); + await mount({ rpcClient: api }); + + // Open the first row's replace editor, type a new key, save. + const toggle = container.querySelector('.provider-key-row__replace-toggle')!; + await act(async () => toggle.click()); + const input = container.querySelector('.provider-key-row__replace-input')!; + await act(async () => setInputValue(input, 'sk-replacement-key')); + const save = container.querySelector('.provider-key-row__replace-save')!; + await act(async () => save.click()); + await flush(); + + expect(testKey).toHaveBeenCalledWith({ + baseUrl: 'https://b/v1', + apiKey: 'sk-replacement-key', + model: 'llama-3.3-70b-versatile', + capabilities: undefined, + }); + // Index 0 replaced with the raw new key; the redacted sibling round-trips. + expect(upsert).toHaveBeenCalledWith({ + id: 'groq', + apiKeys: ['sk-replacement-key', '…1234'], + }); + }); + + it('tolerates a malformed upsert response after replace (non-array → empty list)', async () => { + const upsert = vi.fn(() => Promise.resolve({} as ProvidersListResponse)); + const api = configuredApi({ upsert }); + await mount({ rpcClient: api }); + + const toggle = container.querySelector('.provider-key-row__replace-toggle')!; + await act(async () => toggle.click()); + const input = container.querySelector('.provider-key-row__replace-input')!; + await act(async () => setInputValue(input, 'sk-brand-new')); + const save = container.querySelector('.provider-key-row__replace-save')!; + await act(async () => save.click()); + await flush(); + + // Non-array providers → the panel falls back to an empty list (no crash). + expect(upsert).toHaveBeenCalled(); + expect(container.querySelector('.provider-card')).toBeNull(); + }); +}); diff --git a/app/renderer/src/features/ProvidersKeys.tsx b/app/renderer/src/features/ProvidersKeys.tsx index 65491dc2..6268c838 100644 --- a/app/renderer/src/features/ProvidersKeys.tsx +++ b/app/renderer/src/features/ProvidersKeys.tsx @@ -27,7 +27,7 @@ import './providersKeys.css'; import { KeyIcon } from './providersKeysIcon'; import { ExternalLinkIcon } from './providerLinkIcon'; import { AddKeyRow } from '../components/AddKeyRow'; -import { ProviderKeyRow } from '../components/ProviderKeyRow'; +import { ProviderKeyRow, type KeyCheckResult } from '../components/ProviderKeyRow'; import { ConsentToggle, type ConsentType } from '../components/ConsentToggle'; import { UsageBars } from '../components/UsageBar'; import { SpendCap, type SpendCapClient } from './SpendCap'; @@ -71,6 +71,27 @@ type SettingsConsentRead = { consent?: { perProvider?: Record }; }; +/** A provider's validation target (baseUrl/model/capabilities) for testKey. */ +interface KeyTarget { + baseUrl: string; + model: string | undefined; + capabilities: string[] | undefined; +} + +/** + * Resolve the baseUrl + model + capabilities a testKey ping needs from a provider + * entry, falling back to the provider's connection meta (a picker-added entry + * already carries both, but a key added before either was set still validates). + */ +function resolveKeyTarget(entry: ProviderEntry): KeyTarget { + const meta = entry.provider ? PROVIDER_META[entry.provider] : undefined; + return { + baseUrl: entry.baseUrl || meta?.baseUrl || '', + model: entry.model || meta?.defaultModel, + capabilities: entry.capabilities, + }; +} + export interface ProvidersKeysProps { /** Inject the typed client for tests; defaults to the real lib/rpc client. */ rpcClient?: Pick & { @@ -113,6 +134,9 @@ interface ProviderCardProps { onRemoveKey: (id: string, index: number) => void; onRemoveProvider: (id: string) => void; onConsentChange: (provider: string, type: ConsentType, value: boolean) => void; + onRevealKey: (id: string, index: number) => Promise; + onRevalidateKey: (id: string, index: number) => Promise; + onReplaceKey: (id: string, index: number, newKey: string) => Promise; } function ProviderCard({ @@ -125,6 +149,9 @@ function ProviderCard({ onRemoveKey, onRemoveProvider, onConsentChange, + onRevealKey, + onRevalidateKey, + onReplaceKey, }: ProviderCardProps): React.ReactElement { const name = entry.provider || entry.id; const keys = Array.isArray(entry.apiKeys) ? entry.apiKeys : []; @@ -155,6 +182,9 @@ function ProviderCard({ redactedKey={redactedKey} index={index} onRemove={onRemoveKey} + onReveal={onRevealKey} + onRevalidate={onRevalidateKey} + onReplace={onReplaceKey} /> ))} @@ -322,12 +352,7 @@ export function ProvidersKeys({ const entry = providers.find((p) => p.id === id); /* v8 ignore next -- addKey is only invoked from a card bound to a live entry; the guard is defensive. */ if (!entry) return; - // Resolve baseUrl + a REAL model id from the entry, falling back to the - // provider's connection meta (a picker-added entry already carries both, - // but a key pasted before either was set still validates correctly). - const meta = entry.provider ? PROVIDER_META[entry.provider] : undefined; - const baseUrl = entry.baseUrl || meta?.baseUrl || ''; - const model = entry.model || meta?.defaultModel; + const { baseUrl, model, capabilities } = resolveKeyTarget(entry); setBusy(true); setError(''); setAddStatus((s) => ({ ...s, [id]: 'Validating key…' })); @@ -336,7 +361,7 @@ export function ProvidersKeys({ baseUrl, apiKey: key, model, - capabilities: entry.capabilities, + capabilities, }); setTested((t) => ({ ...t, [id]: result.ok })); setAddStatus((s) => ({ @@ -383,6 +408,59 @@ export function ProvidersKeys({ [api, providers], ); + // WU-D3 reveal: fetch the ONE full key for a transient, masked-by-default display. + // The plaintext is RETURNED to the row (held in its ref only) — never stored in + // this panel's state/store, never logged. This is the sole full-key RPC. + const revealKey = useCallback( + async (id: string, index: number): Promise => { + const res = await api.providers.revealKey(id, index); + return res.key; + }, + [api], + ); + + // WU-D3 Re-validate: re-check an ALREADY-STORED key. The plaintext is revealed + // then piped straight into testKey as a LOCAL const — it is never assigned to + // state/store nor surfaced in an error (testKey scrubs its own error of the key). + const revalidateKey = useCallback( + async (id: string, index: number): Promise => { + const entry = providers.find((p) => p.id === id); + /* v8 ignore next -- bound to a live key row; the guard is defensive. */ + if (!entry) return { ok: false, error: 'unknown provider' }; + const { baseUrl, model, capabilities } = resolveKeyTarget(entry); + const revealed = await api.providers.revealKey(id, index); + const result = await api.providers.testKey({ + baseUrl, + apiKey: revealed.key, + model, + capabilities, + }); + setTested((t) => ({ ...t, [id]: result.ok })); + return result; + }, + [api, providers], + ); + + // WU-D3 Replace: edit a key by re-running validation on a NEW value, then storing + // it in place of the old one (preferred over silent in-place mutation). The + // surviving redacted siblings round-trip back to their RAW form server-side; the + // new raw key is written at its index. + const replaceKey = useCallback( + async (id: string, index: number, newKey: string): Promise => { + const entry = providers.find((p) => p.id === id); + /* v8 ignore next -- bound to a live key row, so entry AND its apiKeys array exist; defensive. */ + if (!entry || !Array.isArray(entry.apiKeys)) return { ok: false, error: 'unknown provider' }; + const { baseUrl, model, capabilities } = resolveKeyTarget(entry); + const result = await api.providers.testKey({ baseUrl, apiKey: newKey, model, capabilities }); + setTested((t) => ({ ...t, [id]: result.ok })); + const keys = entry.apiKeys.map((k, i) => (i === index ? newKey : k)); + const res = await api.providers.upsert({ id, apiKeys: keys }); + setProviders(Array.isArray(res?.providers) ? res.providers : []); + return result; + }, + [api, providers], + ); + // Drop a whole provider (providers.remove). const removeProvider = useCallback( async (id: string): Promise => { @@ -472,6 +550,9 @@ export function ProvidersKeys({ onRemoveKey={(id, index) => void removeKey(id, index)} onRemoveProvider={(id) => void removeProvider(id)} onConsentChange={(provider, type, value) => void changeConsent(provider, type, value)} + onRevealKey={revealKey} + onRevalidateKey={revalidateKey} + onReplaceKey={replaceKey} /> ))} diff --git a/app/renderer/src/features/providersKeys.css b/app/renderer/src/features/providersKeys.css index 83a879f3..506d7a30 100644 --- a/app/renderer/src/features/providersKeys.css +++ b/app/renderer/src/features/providersKeys.css @@ -193,22 +193,40 @@ .provider-key-row { display: flex; - align-items: center; - gap: var(--space-3); + flex-direction: column; + gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 1px solid var(--edge); border-radius: var(--radius-sm); background: var(--surface-bg); } +.provider-key-row__main { + display: flex; + align-items: center; + gap: var(--space-3); +} + .provider-key-row__value { flex: 1; + min-width: 0; + overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 13px; color: var(--text-secondary); letter-spacing: 0.08em; } +/* A revealed key is the ONE moment a full secret is on screen — mark it clearly. */ +.provider-key-row__value[data-revealed='true'] { + color: var(--text-primary); + letter-spacing: 0; +} + +.provider-key-row__reveal, +.provider-key-row__revalidate, +.provider-key-row__replace-toggle, +.provider-key-row__replace-save, .provider-key-row__remove { padding: 4px 10px; border: 1px solid var(--edge); @@ -225,12 +243,60 @@ background var(--dur-fast) var(--ease-out); } -.provider-key-row__remove:hover { +.provider-key-row__reveal:hover, +.provider-key-row__revalidate:hover, +.provider-key-row__replace-toggle:hover, +.provider-key-row__replace-save:hover { + color: var(--text-primary); + border-color: var(--accent); +} + +.provider-key-row__reveal[aria-pressed='true'] { + color: var(--text-primary); + border-color: var(--accent); + background: var(--accent-soft); +} + +.provider-key-row__reveal:disabled, +.provider-key-row__revalidate:disabled, +.provider-key-row__replace-toggle:disabled, +.provider-key-row__replace-save:disabled, +.provider-key-row__remove:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.provider-key-row__remove:hover:not(:disabled) { color: var(--status-error); border-color: var(--status-error); background: var(--status-error-soft); } +.provider-key-row__replace { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.provider-key-row__replace-input { + flex: 1; + min-width: 0; + padding: 5px 8px; + border: 1px solid var(--edge); + border-radius: var(--radius-xs); + background: var(--surface-bg-elevated, var(--surface-bg)); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; +} + +.provider-key-row__status { + margin: 0; + font-family: var(--font-ui); + font-size: 11px; + color: var(--text-muted); +} + /* --- AddKeyRow (paste-to-add) ------------------------------------------- */ .add-key-row { @@ -547,6 +613,10 @@ @media (prefers-reduced-motion: reduce) { .provider-card__remove, .provider-key-row__remove, + .provider-key-row__reveal, + .provider-key-row__revalidate, + .provider-key-row__replace-toggle, + .provider-key-row__replace-save, .add-key-row__input, .add-key-row__add, .picker-option, diff --git a/app/renderer/src/lib/rpc.test.ts b/app/renderer/src/lib/rpc.test.ts index 2ecba205..bfbcb0a4 100644 --- a/app/renderer/src/lib/rpc.test.ts +++ b/app/renderer/src/lib/rpc.test.ts @@ -766,6 +766,16 @@ describe('client.system / recipes', () => { expect(r).toHaveBeenCalledWith('providers.setConsent', { provider: 'Groq', frames: false }); }); + it('providers.revealKey forwards id + index, defaulting index to 0 (WU-D3)', async () => { + const r = installApi(); + // Explicit index rides along verbatim. + await client.providers.revealKey('groq', 2); + expect(r).toHaveBeenCalledWith('providers.revealKey', { id: 'groq', index: 2 }); + // Omitted index defaults to 0 (the first rotation-pool key). + await client.providers.revealKey('openrouter'); + expect(r).toHaveBeenCalledWith('providers.revealKey', { id: 'openrouter', index: 0 }); + }); + it('savePresets.* forward their params (WU-10/WU-11)', async () => { const r = installApi(); await client.savePresets.list(); diff --git a/app/renderer/src/lib/rpc/client.ts b/app/renderer/src/lib/rpc/client.ts index 5bb7d39e..1f8b83cb 100644 --- a/app/renderer/src/lib/rpc/client.ts +++ b/app/renderer/src/lib/rpc/client.ts @@ -59,6 +59,7 @@ import type { ProviderConsent, ProviderEntry, ProvidersListResponse, + RevealKeyResult, ProxyStateEvent, ReadinessItem, RecommendResponse, @@ -510,6 +511,16 @@ export const client = { model?: string; capabilities?: string[]; }): Promise => rpc('providers.testKey', args), + /** + * `providers.revealKey` — the ONE sanctioned plaintext exception (WU-D3). + * Returns exactly ONE raw key for a TRANSIENT, explicit-click, masked-by-default + * display. SECURITY: callers MUST hold the returned `key` in a transient ref + * only — never React state/store, logs, telemetry, or crash reports — and wipe + * it on re-mask/blur/timeout. `index` (default 0) selects among a provider's + * rotation-pool keys. + */ + revealKey: (id: string, index = 0): Promise => + rpc('providers.revealKey', { id, index }), /** * `providers.setConsent` — set per-data-type egress consent for a provider * (WU-keys / SE1). TEXT and FRAMES are independent: only the keys present in diff --git a/app/renderer/src/lib/rpc/schemas.ts b/app/renderer/src/lib/rpc/schemas.ts index e988e6c8..0211ef6e 100644 --- a/app/renderer/src/lib/rpc/schemas.ts +++ b/app/renderer/src/lib/rpc/schemas.ts @@ -766,6 +766,17 @@ export interface TestKeyResult { error?: string; } +/** + * `providers.revealKey` result (WU-D3): the ONE sanctioned plaintext exception to + * the redact-over-RPC invariant. Returns exactly ONE raw key, for a TRANSIENT, + * explicit-click, masked-by-default display. The renderer holds `key` ONLY in a + * transient ref, re-masks it on blur/timeout, and NEVER writes it into React + * state/store, logs, telemetry, or crash reports. + */ +export interface RevealKeyResult { + key: string; +} + /** * One provider's per-data-type consent (`consent.perProvider[provider]`). TEXT * (transcripts) and FRAMES (vision) are SEPARATE, independently-revocable diff --git a/sidecar/media_studio/handlers/_services.py b/sidecar/media_studio/handlers/_services.py index 8277d8f7..64846cdb 100644 --- a/sidecar/media_studio/handlers/_services.py +++ b/sidecar/media_studio/handlers/_services.py @@ -199,6 +199,7 @@ def __init__( providers_upsert = providers_ops.providers_upsert providers_remove = providers_ops.providers_remove providers_test_key = providers_ops.providers_test_key + providers_reveal_key = providers_ops.providers_reveal_key providers_set_consent = providers_ops.providers_set_consent providers_usage = providers_ops.providers_usage providers_openrouter_usage = providers_ops.providers_openrouter_usage diff --git a/sidecar/media_studio/handlers/composition.py b/sidecar/media_studio/handlers/composition.py index 0f04a22a..3ca829a2 100644 --- a/sidecar/media_studio/handlers/composition.py +++ b/sidecar/media_studio/handlers/composition.py @@ -160,6 +160,11 @@ def register_all( reg("providers.upsert", svc.providers_upsert) reg("providers.remove", svc.providers_remove) reg("providers.testKey", svc.providers_test_key) + # WU-D3: the ONE sanctioned plaintext exception — an explicit-click reveal that + # returns exactly one RAW key for a transient, masked-by-default UI display. The + # renderer holds it in a ref only (never state/store/logs/telemetry/crash) and + # re-masks on blur/timeout. Every other providers.* read stays last-4 redacted. + reg("providers.revealKey", svc.providers_reveal_key) reg("providers.setConsent", svc.providers_set_consent) # WU-usage-ui: per-key live usage (cached, persisted, stale-flagged; no poll # burst). The rotation pool already accounts usage from optimistic decrement + diff --git a/sidecar/media_studio/handlers/providers_ops.py b/sidecar/media_studio/handlers/providers_ops.py index 3c87d3a8..355c11a9 100644 --- a/sidecar/media_studio/handlers/providers_ops.py +++ b/sidecar/media_studio/handlers/providers_ops.py @@ -127,6 +127,49 @@ def providers_test_key(self: Services, params: dict[str, Any], ctx: RpcContext) return {"ok": True, "capabilities": capabilities} +def providers_reveal_key(self: Services, params: dict[str, Any], ctx: RpcContext) -> dict[str, Any]: + """``providers.revealKey({id, index?})`` -> ``{key}`` — the ONE sanctioned plaintext exception. + + Returns exactly ONE raw plaintext API key, for TRANSIENT display in direct + response to an EXPLICIT user click (a "reveal" affordance). This is the SOLE + ``providers.*`` RPC that returns a FULL key: every other provider read + (``providers.list`` / ``providers.usage`` / ``providers.openrouterUsage`` ...) + redacts to last-4. It is the deliberate, DOCUMENTED exception to the + redact-over-RPC invariant (PLAN §WU-D3, R7) — justified only because the user + is explicitly asking to see their OWN stored key to read/copy it. + + SECURITY CONTRACT (enforced end-to-end, R7): the renderer holds the returned + value ONLY in a transient ref, shows it masked-by-default, re-masks it on + blur/timeout, and NEVER writes it into React state/store, logs, telemetry, or + crash reports. Server-side the key is read RAW via :meth:`SettingsStore.get_raw` + (the same FACTORY accessor the rotation pool uses), returned once, and never + logged: ``rpc.py``'s param redaction keeps the ``{id, index}`` REQUEST out of + diagnostics, and the RESPONSE is never written to a log line. + + ``index`` (default 0) selects among a provider's rotation-pool keys. An unknown + id, an out-of-range / negative / non-``int`` (incl. ``bool``) index, or an empty + stored slot is a typed ``INVALID_PARAMS`` error — never a crash, and never a + silent empty reveal that the UI could mistake for a real key. + """ + provider_id = _require_str(params, "id") + index = params.get("index", 0) + # bool is an int subclass but never a valid slot index; reject it explicitly. + if isinstance(index, bool) or not isinstance(index, int) or index < 0: + raise _invalid("providers.revealKey index must be a non-negative integer") + raw_providers = self.settings.get_raw().get("providers") + providers = raw_providers if isinstance(raw_providers, list) else [] + entry = next((p for p in providers if isinstance(p, dict) and p.get("id") == provider_id), None) + if entry is None: + raise _invalid(f"providers.revealKey: unknown provider {provider_id!r}") + keys = entry.get("apiKeys") + if not isinstance(keys, list) or index >= len(keys): + raise _invalid(f"providers.revealKey: no key at index {index} for {provider_id!r}") + key = keys[index] + if not isinstance(key, str) or not key: + raise _invalid(f"providers.revealKey: no key at index {index} for {provider_id!r}") + return {"key": key} + + def providers_set_consent(self: Services, params: dict[str, Any], ctx: RpcContext) -> dict[str, Any]: """``providers.setConsent({provider, text?, frames?})`` -> ``{consent}`` (WU-keys / SE1). diff --git a/sidecar/tests/test_handlers_providers_reveal.py b/sidecar/tests/test_handlers_providers_reveal.py new file mode 100644 index 00000000..382ab110 --- /dev/null +++ b/sidecar/tests/test_handlers_providers_reveal.py @@ -0,0 +1,120 @@ +"""WU D3 (Reframe v1.3) — the transient ``providers.revealKey`` reveal contract. + +``providers.revealKey({id, index?})`` is the ONE sanctioned exception to the +redact-over-RPC invariant: it returns exactly ONE raw plaintext key, for a +transient masked-by-default display driven by an explicit user click. Every OTHER +``providers.*`` read stays last-4 redacted. These tests pin: + + * the method is registered through ``register_all`` (the single composition root); + * it returns the RAW stored key at the requested index (default 0) — and ONLY + that one key (a sibling key at another index never leaks into the response); + * a round-trip against ``get_raw`` (the FACTORY accessor) matches byte-for-byte; + * every error arm is a typed INVALID_PARAMS (never a crash, never a silent empty + reveal): unknown id, out-of-range / negative / non-int / bool index, missing + id, and an empty stored key. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from media_studio import protocol +from media_studio.handlers import Services, register_all +from media_studio.protocol import RpcContext, RpcError + +# Distinctive plaintext keys so a leak is unmistakable in the assertions. +KEY_A = "gsk-live-SECRET-AAAA" +KEY_B = "gsk-live-SECRET-BBBB" + + +def _ctx() -> RpcContext: + return RpcContext(emit_notification=lambda obj: None, jobs=None) + + +def _svc(tmp_path: Path) -> Services: + return Services(data_dir=tmp_path) + + +def _seed(svc: Services, keys: list[str]) -> Services: + svc.settings.set( + { + "providers": [ + { + "id": "groq", + "provider": "Groq", + "kind": "cloud", + "baseUrl": "https://api.groq.com/openai/v1", + "model": "llama-3.3-70b", + "apiKeys": list(keys), + "enabled": True, + "capabilities": ["text"], + "unit": "token", + } + ] + } + ) + return svc + + +def test_reveal_key_registered_through_composition_root(tmp_path: Path) -> None: + protocol.METHODS.clear() + register_all(Services(data_dir=tmp_path)) + assert "providers.revealKey" in protocol.METHODS + protocol.METHODS.clear() + + +def test_reveal_returns_the_raw_key_at_default_index(tmp_path: Path) -> None: + svc = _seed(_svc(tmp_path), [KEY_A]) + out = svc.providers_reveal_key({"id": "groq"}, _ctx()) + assert out == {"key": KEY_A} + # Byte-for-byte round-trip against the FACTORY accessor. + assert out["key"] == svc.settings.get_raw()["providers"][0]["apiKeys"][0] + + +def test_reveal_selects_the_requested_index_and_leaks_no_sibling(tmp_path: Path) -> None: + svc = _seed(_svc(tmp_path), [KEY_A, KEY_B]) + out = svc.providers_reveal_key({"id": "groq", "index": 1}, _ctx()) + assert out == {"key": KEY_B} + # ONLY the requested key is present — the sibling never rides along. + assert KEY_A not in json.dumps(out) + + +def test_reveal_unknown_provider_is_invalid_params(tmp_path: Path) -> None: + svc = _seed(_svc(tmp_path), [KEY_A]) + with pytest.raises(RpcError): + svc.providers_reveal_key({"id": "nope"}, _ctx()) + + +def test_reveal_index_out_of_range_is_invalid_params(tmp_path: Path) -> None: + svc = _seed(_svc(tmp_path), [KEY_A]) + with pytest.raises(RpcError): + svc.providers_reveal_key({"id": "groq", "index": 5}, _ctx()) + + +def test_reveal_missing_id_is_invalid_params(tmp_path: Path) -> None: + svc = _seed(_svc(tmp_path), [KEY_A]) + with pytest.raises(RpcError): + svc.providers_reveal_key({}, _ctx()) + + +@pytest.mark.parametrize("bad", [-1, True, "0", 1.0, None]) +def test_reveal_non_natural_index_is_invalid_params(tmp_path: Path, bad: object) -> None: + svc = _seed(_svc(tmp_path), [KEY_A]) + with pytest.raises(RpcError): + svc.providers_reveal_key({"id": "groq", "index": bad}, _ctx()) + + +def test_reveal_empty_stored_key_is_invalid_params(tmp_path: Path) -> None: + # A provider row whose slot holds an empty string has nothing to reveal. + svc = _seed(_svc(tmp_path), [""]) + with pytest.raises(RpcError): + svc.providers_reveal_key({"id": "groq", "index": 0}, _ctx()) + + +def test_reveal_no_providers_configured_is_invalid_params(tmp_path: Path) -> None: + # No providers list at all → unknown provider, not a crash. + svc = _svc(tmp_path) + with pytest.raises(RpcError): + svc.providers_reveal_key({"id": "groq"}, _ctx()) diff --git a/sidecar/tests/test_handlers_rpc_surface.py b/sidecar/tests/test_handlers_rpc_surface.py index 7ae446e7..05818522 100644 --- a/sidecar/tests/test_handlers_rpc_surface.py +++ b/sidecar/tests/test_handlers_rpc_surface.py @@ -81,6 +81,7 @@ "providers.list", "providers.openrouterUsage", "providers.remove", + "providers.revealKey", "providers.setConsent", "providers.setFunctionModel", "providers.spend", diff --git a/sidecar/tests/test_settings_provider_surface_characterization.py b/sidecar/tests/test_settings_provider_surface_characterization.py index 39284109..e4fc1a46 100644 --- a/sidecar/tests/test_settings_provider_surface_characterization.py +++ b/sidecar/tests/test_settings_provider_surface_characterization.py @@ -99,7 +99,8 @@ def test_provider_key_and_usage_rpc_surface_snapshot(tmp_path: Path) -> None: protocol.METHODS.clear() register_all(Services(data_dir=tmp_path)) surface = sorted(m for m in protocol.METHODS if m.startswith("providers.")) - # The EXACT provider RPC surface as of v1.2.0 + Wave-1 (WS-D reconcile baseline). + # The provider RPC surface as of v1.2.0 + Wave-1, PLUS the D3 addition + # ``providers.revealKey`` (the transient masked-reveal contract that closes G-3). assert surface == [ "providers.applyPreset", "providers.catalog", @@ -107,6 +108,7 @@ def test_provider_key_and_usage_rpc_surface_snapshot(tmp_path: Path) -> None: "providers.list", "providers.openrouterUsage", "providers.remove", + "providers.revealKey", "providers.setConsent", "providers.setFunctionModel", "providers.spend", @@ -114,8 +116,10 @@ def test_provider_key_and_usage_rpc_surface_snapshot(tmp_path: Path) -> None: "providers.upsert", "providers.usage", ] - # G-3 / G-4 gap lock: no reveal and no re-validate-stored-key handler exists yet. - assert "providers.revealKey" not in protocol.METHODS + # G-3 CLOSED (D3): the transient reveal handler now exists. Re-validate of a + # stored key (G-4) is orchestrated in the renderer (revealKey -> testKey), so + # there is deliberately NO separate ``providers.revalidateKey`` sidecar method. + assert "providers.revealKey" in protocol.METHODS assert "providers.revalidateKey" not in protocol.METHODS From 9d094e29a0b1b9bd22d7cbed4fb64b174dd3f495 Mon Sep 17 00:00:00 2001 From: Prekzursil Date: Sat, 4 Jul 2026 09:30:39 +0300 Subject: [PATCH 010/104] feat(usage): real per-provider usage surface + honest spend estimate (WU D4) Complete the usage surface without fakes: - Local request/token counters always surface (providers.usage, unchanged). - New providers.usageAvailability RPC states, per configured cloud provider, whether a provider-side usage API exists: OpenRouter yes (live GET /api/v1/key cost), OpenAI/Anthropic gate usage behind an org admin key a stored project key cannot use, others publish nothing per-key -> honest "Usage API not available for " instead of a fabricated number. Rendered next to OpenRouter spend in Models & System. - New provider_pricing model: real per-model price table (honestly empty today - no curated model publishes a per-request price) + the documented placeholder; providers.spend now returns isEstimate so the month-to-date figure is labelled an ESTIMATE in the spend meter rather than shown as a real invoiced charge. - _estimate_job_cents routes through provider_pricing so real pricing is honored when added (behaviour identical today). Sidecar + renderer at 100% line/branch/function/statement coverage; tsc/biome/ ruff/pyright clean. --- .../ProviderUsageAvailability.test.tsx | 76 +++++++++ .../components/ProviderUsageAvailability.tsx | 45 ++++++ .../src/features/ProvidersKeys.test.tsx | 1 + app/renderer/src/features/SpendCap.test.tsx | 30 ++++ app/renderer/src/features/SpendCap.tsx | 13 ++ app/renderer/src/features/spendCap.css | 14 ++ .../src/features/spendCapLogic.test.ts | 1 + app/renderer/src/lib/rpc.test.ts | 6 + app/renderer/src/lib/rpc/client.ts | 8 + app/renderer/src/lib/rpc/schemas.ts | 21 +++ .../src/panels/ModelsSystemPanel.test.tsx | 44 +++++ app/renderer/src/panels/ModelsSystemPanel.tsx | 15 +- app/renderer/src/panels/modelsSystem.css | 33 ++++ sidecar/media_studio/handlers/_services.py | 1 + sidecar/media_studio/handlers/ai_ops.py | 14 +- sidecar/media_studio/handlers/composition.py | 4 + .../media_studio/handlers/providers_ops.py | 25 +++ .../media_studio/models/provider_pricing.py | 69 ++++++++ .../models/provider_usage_availability.py | 105 ++++++++++++ sidecar/tests/test_handlers_d4_usage.py | 150 ++++++++++++++++++ sidecar/tests/test_handlers_rpc_surface.py | 1 + sidecar/tests/test_provider_pricing.py | 66 ++++++++ .../tests/test_provider_usage_availability.py | 91 +++++++++++ ...tings_provider_surface_characterization.py | 5 +- 24 files changed, 830 insertions(+), 8 deletions(-) create mode 100644 app/renderer/src/components/ProviderUsageAvailability.test.tsx create mode 100644 app/renderer/src/components/ProviderUsageAvailability.tsx create mode 100644 sidecar/media_studio/models/provider_pricing.py create mode 100644 sidecar/media_studio/models/provider_usage_availability.py create mode 100644 sidecar/tests/test_handlers_d4_usage.py create mode 100644 sidecar/tests/test_provider_pricing.py create mode 100644 sidecar/tests/test_provider_usage_availability.py diff --git a/app/renderer/src/components/ProviderUsageAvailability.test.tsx b/app/renderer/src/components/ProviderUsageAvailability.test.tsx new file mode 100644 index 00000000..350c7b98 --- /dev/null +++ b/app/renderer/src/components/ProviderUsageAvailability.test.tsx @@ -0,0 +1,76 @@ +// ProviderUsageAvailability.test.tsx — WU-D4 honest per-provider usage-API notes. +// Proves the component renders the honest message per provider (available vs not), +// never a fabricated number, and renders nothing when there is nothing to say. + +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { ProviderUsageAvailability } from './ProviderUsageAvailability'; +import type { ProviderUsageAvailability as UsageAvailabilityRow } from '../lib/rpc'; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +function render(rows: UsageAvailabilityRow[]): void { + act(() => root.render()); +} + +describe('ProviderUsageAvailability', () => { + it('renders nothing when there are no rows', () => { + render([]); + expect(container.querySelector('[data-usage-availability]')).toBeNull(); + expect(container.textContent).toBe(''); + }); + + it('shows the honest "not available" message for a provider without a usage API', () => { + render([ + { provider: 'Groq', hasUsageApi: false, message: 'Usage API not available for Groq.' }, + ]); + const row = container.querySelector('[data-provider="Groq"]'); + expect(row).not.toBeNull(); + expect(row?.getAttribute('data-available')).toBe('false'); + expect(row?.classList.contains('is-unavailable')).toBe(true); + expect(container.textContent).toContain('Usage API not available for Groq.'); + }); + + it('marks a provider WITH a usage API as available', () => { + render([ + { + provider: 'OpenRouter', + hasUsageApi: true, + message: 'Live per-key credit usage is available from OpenRouter.', + }, + ]); + const row = container.querySelector('[data-provider="OpenRouter"]'); + expect(row?.getAttribute('data-available')).toBe('true'); + expect(row?.classList.contains('is-available')).toBe(true); + expect(container.textContent).toContain('available from OpenRouter'); + }); + + it('renders one row per provider with the count', () => { + render([ + { provider: 'Groq', hasUsageApi: false, message: 'Usage API not available for Groq.' }, + { + provider: 'OpenRouter', + hasUsageApi: true, + message: 'Live per-key credit usage is available from OpenRouter.', + }, + ]); + const list = container.querySelector('[data-usage-availability="rows"]'); + expect(list?.getAttribute('data-row-count')).toBe('2'); + expect(container.querySelectorAll('.usage-availability__row').length).toBe(2); + }); +}); diff --git a/app/renderer/src/components/ProviderUsageAvailability.tsx b/app/renderer/src/components/ProviderUsageAvailability.tsx new file mode 100644 index 00000000..6e2dfffd --- /dev/null +++ b/app/renderer/src/components/ProviderUsageAvailability.tsx @@ -0,0 +1,45 @@ +// ProviderUsageAvailability.tsx — honest per-provider "is there a provider-side +// usage API?" notes (WU-D4). The LOCAL request/token counters live in +// and OpenRouter's live COST in ; this states the truth for every +// OTHER configured cloud provider: OpenAI/Anthropic gate usage behind an org admin +// key and others publish nothing per-key, so we show "Usage API not available for +// " instead of a fabricated number. Pure presentation (no RPC). +import React from 'react'; +import type { ProviderUsageAvailability as UsageAvailabilityRow } from '../lib/rpc'; + +export interface ProviderUsageAvailabilityProps { + /** Per-provider availability rows from providers.usageAvailability. */ + rows: UsageAvailabilityRow[]; +} + +export function ProviderUsageAvailability({ + rows, +}: ProviderUsageAvailabilityProps): React.ReactElement | null { + // Nothing configured yet → render nothing (the surrounding usage section already + // shows its own empty state); an empty note list would just be visual noise. + if (rows.length === 0) return null; + return ( +
    + {rows.map((row) => ( +
  • + + {row.message} +
  • + ))} +
+ ); +} + +export default ProviderUsageAvailability; diff --git a/app/renderer/src/features/ProvidersKeys.test.tsx b/app/renderer/src/features/ProvidersKeys.test.tsx index 3b3e8241..a4dff070 100644 --- a/app/renderer/src/features/ProvidersKeys.test.tsx +++ b/app/renderer/src/features/ProvidersKeys.test.tsx @@ -147,6 +147,7 @@ function makeSpendClient(): SpendCapClient { softLimitCents: 0, hardLimitCents: 0, enforceHardLimit: false, + isEstimate: false, }; return { providers: { spend: () => Promise.resolve(spend) }, diff --git a/app/renderer/src/features/SpendCap.test.tsx b/app/renderer/src/features/SpendCap.test.tsx index d07d8baa..ffb31310 100644 --- a/app/renderer/src/features/SpendCap.test.tsx +++ b/app/renderer/src/features/SpendCap.test.tsx @@ -53,6 +53,7 @@ function spendInfo(over: Partial = {}): SpendInfo { softLimitCents: 0, hardLimitCents: 0, enforceHardLimit: false, + isEstimate: false, ...over, }; } @@ -135,6 +136,35 @@ describe('SpendCap — load + zero/empty state', () => { }); }); +describe('SpendCap — estimate honesty (WU-D4)', () => { + it('labels a non-zero month-to-date "estimated" when the figure is placeholder-derived', async () => { + const { api } = makeApi({ + spend: () => Promise.resolve(spendInfo({ monthToDateCents: 2500, isEstimate: true })), + }); + await mount(api); + const badge = $('.spend-cap__readout-estimate'); + expect(badge).not.toBeNull(); + expect(badge?.getAttribute('data-estimate')).toBe('true'); + expect(badge?.textContent).toBe('estimated'); + }); + + it('does not label a zero month-to-date as estimated (nothing to qualify)', async () => { + const { api } = makeApi({ + spend: () => Promise.resolve(spendInfo({ monthToDateCents: 0, isEstimate: true })), + }); + await mount(api); + expect($('.spend-cap__readout-estimate')).toBeNull(); + }); + + it('does not label a figure estimated when real pricing backs it', async () => { + const { api } = makeApi({ + spend: () => Promise.resolve(spendInfo({ monthToDateCents: 2500, isEstimate: false })), + }); + await mount(api); + expect($('.spend-cap__readout-estimate')).toBeNull(); + }); +}); + describe('SpendCap — zone rendering', () => { it('ok zone: bounded meter under the soft cap', async () => { const { api } = makeApi({ diff --git a/app/renderer/src/features/SpendCap.tsx b/app/renderer/src/features/SpendCap.tsx index f757a13e..9b744f69 100644 --- a/app/renderer/src/features/SpendCap.tsx +++ b/app/renderer/src/features/SpendCap.tsx @@ -67,6 +67,19 @@ function SpendMeter({ info }: { info: SpendInfo }): React.ReactElement { {mtd} + {/* WU-D4 honesty: the figure is derived from placeholder pricing (no + provider publishes a real per-request price we can read), so it is + labelled an ESTIMATE — never presented as a real invoiced charge. Only + shown once there is a non-zero figure to qualify. */} + {info.isEstimate && info.monthToDateCents > 0 ? ( + + estimated + + ) : null} {info.month}
{bounded ? ( diff --git a/app/renderer/src/features/spendCap.css b/app/renderer/src/features/spendCap.css index d496bf05..d48698f3 100644 --- a/app/renderer/src/features/spendCap.css +++ b/app/renderer/src/features/spendCap.css @@ -86,6 +86,20 @@ color: var(--text-primary); } +/* WU-D4: the "estimated" qualifier — the month-to-date is derived from placeholder + pricing, so it is visibly marked an estimate rather than a real invoiced charge. */ +.spend-cap__readout-estimate { + align-self: center; + padding: 2px var(--space-2); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-pill); + font-size: var(--type-caption-size); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + .spend-cap__readout-month { margin-left: auto; font-family: var(--font-mono); diff --git a/app/renderer/src/features/spendCapLogic.test.ts b/app/renderer/src/features/spendCapLogic.test.ts index f76a287f..e4105c7b 100644 --- a/app/renderer/src/features/spendCapLogic.test.ts +++ b/app/renderer/src/features/spendCapLogic.test.ts @@ -22,6 +22,7 @@ function info(over: Partial = {}): SpendInfo { softLimitCents: 0, hardLimitCents: 0, enforceHardLimit: false, + isEstimate: false, ...over, }; } diff --git a/app/renderer/src/lib/rpc.test.ts b/app/renderer/src/lib/rpc.test.ts index bfbcb0a4..e8527a85 100644 --- a/app/renderer/src/lib/rpc.test.ts +++ b/app/renderer/src/lib/rpc.test.ts @@ -700,6 +700,12 @@ describe('client.system / recipes', () => { expect(r).toHaveBeenCalledWith('providers.openrouterUsage', undefined); }); + it('providers.usageAvailability calls the bare method (WU-D4 honest notes)', async () => { + const r = installApi(); + await client.providers.usageAvailability(); + expect(r).toHaveBeenCalledWith('providers.usageAvailability', undefined); + }); + it('readiness.summary calls the bare method (WU-8 roll-up)', async () => { const r = installApi(); await client.readiness.summary(); diff --git a/app/renderer/src/lib/rpc/client.ts b/app/renderer/src/lib/rpc/client.ts index 1f8b83cb..1eb130f2 100644 --- a/app/renderer/src/lib/rpc/client.ts +++ b/app/renderer/src/lib/rpc/client.ts @@ -58,6 +58,7 @@ import type { Project, ProviderConsent, ProviderEntry, + ProviderUsageAvailability, ProvidersListResponse, RevealKeyResult, ProxyStateEvent, @@ -541,6 +542,13 @@ export const client = { */ openrouterUsage: (): Promise<{ usage: OpenRouterUsageRow[] }> => rpc('providers.openrouterUsage'), + /** + * `providers.usageAvailability` (WU-D4) — honest per-provider note on whether a + * provider-side usage API exists (OpenRouter yes; OpenAI/Anthropic need an org + * admin key; others publish nothing per-key). Never a fabricated number. + */ + usageAvailability: (): Promise<{ availability: ProviderUsageAvailability[] }> => + rpc('providers.usageAvailability'), /** * `providers.spend` — month-to-date cumulative cloud spend + the configured * monthly caps (WU-spend-cap). Read-only; all money is integer cents. diff --git a/app/renderer/src/lib/rpc/schemas.ts b/app/renderer/src/lib/rpc/schemas.ts index 0211ef6e..1e09683a 100644 --- a/app/renderer/src/lib/rpc/schemas.ts +++ b/app/renderer/src/lib/rpc/schemas.ts @@ -752,6 +752,27 @@ export interface SpendInfo { hardLimitCents: number; /** Master switch: only when true does an over-hard-cap run get refused. */ enforceHardLimit: boolean; + /** + * WU-D4 honesty: `true` when `monthToDateCents` is derived from PLACEHOLDER + * pricing (no curated model publishes a real per-request price) — so the UI + * labels it an ESTIMATE rather than presenting it as a real invoiced charge. + */ + isEstimate: boolean; +} + +/** + * `providers.usageAvailability` row (WU-D4): whether a configured cloud provider + * exposes a provider-side usage API reachable with the stored key. OpenRouter does + * (`hasUsageApi: true`); OpenAI/Anthropic gate usage behind an organization admin + * key and other providers publish nothing per-key (`hasUsageApi: false`), so the + * UI shows the honest `message` ("Usage API not available for ") instead + * of a fabricated number. Carries the provider name only — never a key. Mirrors the + * sidecar `provider_usage_availability.UsageAvailabilityRow`. + */ +export interface ProviderUsageAvailability { + provider: string; + hasUsageApi: boolean; + message: string; } /** diff --git a/app/renderer/src/panels/ModelsSystemPanel.test.tsx b/app/renderer/src/panels/ModelsSystemPanel.test.tsx index 05a3cbf5..524bff8f 100644 --- a/app/renderer/src/panels/ModelsSystemPanel.test.tsx +++ b/app/renderer/src/panels/ModelsSystemPanel.test.tsx @@ -35,6 +35,7 @@ import type { LocalModelPlan, ModelsOverview, OpenRouterUsageRow, + ProviderUsageAvailability as UsageAvailabilityRow, ReadinessItem, Recommendation, UsageRow, @@ -373,10 +374,12 @@ function makeClient( overview?: ModelsOverview; eligibility?: Eligibility; openrouterUsage?: OpenRouterUsageRow[]; + usageAvailability?: UsageAvailabilityRow[]; initialSettings?: Record; rejectAnalyze?: boolean; rejectUsage?: boolean; rejectOpenrouter?: boolean; + rejectUsageAvailability?: boolean; readiness?: ReadinessItem[]; rejectEnsure?: boolean; rejectRoutingPolicy?: boolean; @@ -461,6 +464,11 @@ function makeClient( if (over.rejectOpenrouter) throw new Error('openrouter usage failed'); return { usage: over.openrouterUsage ?? [] }; }), + usageAvailability: vi.fn(async () => { + calls.push({ method: 'providers.usageAvailability', args: [] }); + if (over.rejectUsageAvailability) throw new Error('usage availability failed'); + return { availability: over.usageAvailability ?? [] }; + }), catalog: vi.fn(async () => { calls.push({ method: 'providers.catalog', args: [] }); return over.catalog ?? emptyCatalog(); @@ -2256,6 +2264,42 @@ describe(' WU-B3 card', () => { expect(cost.querySelector('[data-openrouter="empty"]')).not.toBeNull(); }); + it('shows the honest per-provider usage-API availability notes (WU-D4)', async () => { + const c = makeClient({ + usageAvailability: [ + { provider: 'Groq', hasUsageApi: false, message: 'Usage API not available for Groq.' }, + { + provider: 'OpenRouter', + hasUsageApi: true, + message: 'Live per-key credit usage is available from OpenRouter.', + }, + ], + }); + await mount(c); + const notes = container.querySelector('[data-usage-availability="rows"]') as HTMLElement; + expect(notes).not.toBeNull(); + expect(notes.querySelector('[data-provider="Groq"]')?.getAttribute('data-available')).toBe( + 'false', + ); + expect(notes.textContent).toContain('Usage API not available for Groq.'); + expect(c.calls.some((x) => x.method === 'providers.usageAvailability')).toBe(true); + }); + + it('renders no availability notes when the read rejects (degrades quietly)', async () => { + const c = makeClient({ rejectUsageAvailability: true }); + await mount(c); + expect(container.querySelector('[data-usage-availability]')).toBeNull(); + }); + + it('tolerates a non-array availability payload (degrades to empty)', async () => { + const c = makeClient(); + (c.client.providers.usageAvailability as ReturnType).mockResolvedValueOnce( + {} as unknown as { availability: UsageAvailabilityRow[] }, + ); + await mount(c); + expect(container.querySelector('[data-usage-availability]')).toBeNull(); + }); + it('hides the device-models sections when models.runners yields nothing', async () => { const c = makeClient({ initialSettings: optedIn }); (c.client.models.runners as ReturnType).mockResolvedValueOnce( diff --git a/app/renderer/src/panels/ModelsSystemPanel.tsx b/app/renderer/src/panels/ModelsSystemPanel.tsx index bab94ad8..ac51713c 100644 --- a/app/renderer/src/panels/ModelsSystemPanel.tsx +++ b/app/renderer/src/panels/ModelsSystemPanel.tsx @@ -28,6 +28,7 @@ import { type LocalModelPlan, type ModelsOverview, type OpenRouterUsageRow, + type ProviderUsageAvailability as UsageAvailabilityRow, type Recommendation, type RoutingBlock, type RoutingPolicy, @@ -47,6 +48,7 @@ import { RoutingOverrideTable } from '../components/RoutingOverrideTable'; import { AlignModelSelect } from '../components/AlignModelSelect'; import { LocalRunners } from '../components/LocalRunners'; import { OpenRouterUsage } from '../components/OpenRouterUsage'; +import { ProviderUsageAvailability } from '../components/ProviderUsageAvailability'; import { PresetPicker } from '../components/PresetPicker'; import { FirstRunChooser } from '../components/FirstRunChooser'; import { ReadinessRollup } from '../components/ReadinessRollup'; @@ -270,6 +272,10 @@ export function ModelsSystemPanel({ // reason strip + device card (real quant + VRAM estimate, null-RAM graceful). const [overview, setOverview] = useState(null); const [openrouterUsage, setOpenrouterUsage] = useState([]); + // WU-D4: honest per-provider note on whether a provider-side usage API exists + // (OpenRouter yes; OpenAI/Anthropic need an org admin key; others none). Shown so + // a provider without a usage API says so instead of implying a fabricated 0. + const [usageAvailability, setUsageAvailability] = useState([]); const [catalog, setCatalog] = useState(null); const [presetBusy, setPresetBusy] = useState(false); // WU-B3 device-aware recommendation card + one-click Apply. @@ -307,13 +313,16 @@ export function ModelsSystemPanel({ api.asr.engines().catch(() => null), api.providers.usage().catch(() => null), api.providers.openrouterUsage().catch(() => null), + api.providers.usageAvailability().catch(() => null), ]) - .then(([catalogRes, engineRes, usageRes, orRes]) => { + .then(([catalogRes, engineRes, usageRes, orRes, availRes]) => { if (alive) { if (catalogRes) setCatalog(catalogRes); if (engineRes) setEngines(Array.isArray(engineRes.engines) ? engineRes.engines : []); if (usageRes) setUsage(Array.isArray(usageRes.usage) ? usageRes.usage : []); if (orRes) setOpenrouterUsage(Array.isArray(orRes.usage) ? orRes.usage : []); + if (availRes) + setUsageAvailability(Array.isArray(availRes.availability) ? availRes.availability : []); } }) /* v8 ignore next 2 -- every inner read already .catch()es to null, so the outer Promise.all never rejects; this is a belt-and-braces guard. */ @@ -987,6 +996,10 @@ export function ModelsSystemPanel({

OpenRouter spend

+ {/* WU-D4: honest per-provider note on provider-side usage APIs — a provider + without one (OpenAI/Anthropic without an org admin key, or others) says + so plainly instead of implying a fabricated number. */} + {catalog && ( diff --git a/app/renderer/src/panels/modelsSystem.css b/app/renderer/src/panels/modelsSystem.css index 0fc195ae..0fc4f7ae 100644 --- a/app/renderer/src/panels/modelsSystem.css +++ b/app/renderer/src/panels/modelsSystem.css @@ -576,6 +576,39 @@ line-height: var(--type-body-leading); } +/* ---- WU-D4: honest per-provider usage-API availability notes ---------------- */ + +.usage-availability { + list-style: none; + margin: var(--space-3) 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.usage-availability__row { + display: flex; + align-items: baseline; + gap: var(--space-2); + color: var(--text-secondary); + font-size: var(--type-caption-size); + line-height: var(--type-body-leading); +} + +.usage-availability__row.is-available { + color: var(--text-primary); +} + +.usage-availability__glyph { + flex: none; + color: var(--text-muted); +} + +.usage-availability__row.is-available .usage-availability__glyph { + color: var(--status-success); +} + /* ---- M4: OpenRouter key-pool rows (per-key usage + cooldown status) --------- */ .openrouter-usage { diff --git a/sidecar/media_studio/handlers/_services.py b/sidecar/media_studio/handlers/_services.py index 64846cdb..68da1d79 100644 --- a/sidecar/media_studio/handlers/_services.py +++ b/sidecar/media_studio/handlers/_services.py @@ -203,6 +203,7 @@ def __init__( providers_set_consent = providers_ops.providers_set_consent providers_usage = providers_ops.providers_usage providers_openrouter_usage = providers_ops.providers_openrouter_usage + providers_usage_availability = providers_ops.providers_usage_availability providers_spend = providers_ops.providers_spend providers_apply_preset = providers_ops.providers_apply_preset providers_set_function_model = providers_ops.providers_set_function_model diff --git a/sidecar/media_studio/handlers/ai_ops.py b/sidecar/media_studio/handlers/ai_ops.py index 3b2448f0..6fa2a47c 100644 --- a/sidecar/media_studio/handlers/ai_ops.py +++ b/sidecar/media_studio/handlers/ai_ops.py @@ -103,16 +103,18 @@ def _estimate_job_cents(self: Services, envelope: Any) -> int: A run that will not egress (cache hit / local-only pool) costs nothing. For an egressing cloud run the estimate is the planned request count times the - documented placeholder per-request rate (the catalog has no structured - numeric price yet — see ``spend_ledger.PLACEHOLDER_CENTS_PER_REQUEST``). The - SAME helper feeds both the pre-egress hard-cap check and the completion - record, so the predicted and recorded costs always agree. + per-request price for the run's model — a REAL price where the pricing table + has one, else the documented placeholder (see ``provider_pricing``; the catalog + has no structured numeric price yet, so today every estimate is placeholder- + derived and ``providers.spend`` flags the aggregate ``isEstimate``). The SAME + helper feeds both the pre-egress hard-cap check and the completion record, so + the predicted and recorded costs always agree. """ - from ..models.spend_ledger import PLACEHOLDER_CENTS_PER_REQUEST # local: pure + from ..models import provider_pricing # local: import-light pure if not envelope.route.willEgress: return 0 - return int(envelope.costEst.requests) * PLACEHOLDER_CENTS_PER_REQUEST + return int(envelope.costEst.requests) * provider_pricing.request_cents(envelope.inputs.model) def _enforce_monthly_hard_cap(self: Services, envelope: Any) -> None: diff --git a/sidecar/media_studio/handlers/composition.py b/sidecar/media_studio/handlers/composition.py index 3ca829a2..02c0fbad 100644 --- a/sidecar/media_studio/handlers/composition.py +++ b/sidecar/media_studio/handlers/composition.py @@ -174,6 +174,10 @@ def register_all( # — the cost axis alongside providers.usage's calls/tokens. Best-effort GET per # RAW key through the GET-transport seam; no full key ever crosses RPC. reg("providers.openrouterUsage", svc.providers_openrouter_usage) + # WU-D4: honest per-provider usage-API availability. OpenRouter has a per-key + # usage API; OpenAI/Anthropic gate usage behind an org admin key and others + # publish nothing per-key — this states that truthfully instead of a fake 0. + reg("providers.usageAvailability", svc.providers_usage_availability) # WU-spend-cap: month-to-date cumulative spend + the configured monthly caps # (read-only). The persisted ledger is written at job completion; this RPC just # surfaces it (+ the soft/hard cap settings) for the renderer's spend view. diff --git a/sidecar/media_studio/handlers/providers_ops.py b/sidecar/media_studio/handlers/providers_ops.py index 355c11a9..ba3ea25b 100644 --- a/sidecar/media_studio/handlers/providers_ops.py +++ b/sidecar/media_studio/handlers/providers_ops.py @@ -271,6 +271,8 @@ def providers_spend(self: Services, params: dict[str, Any], ctx: RpcContext) -> With the default off/0 settings every cap reads zero/false so an unconfigured install shows a benign "no cap" view. """ + from ..models import provider_pricing as _pricing # local: import-light pure + ledger = self._spend_ledger() settings = self.settings.get() return { @@ -279,9 +281,32 @@ def providers_spend(self: Services, params: dict[str, Any], ctx: RpcContext) -> "softLimitCents": int(settings.get("monthlySoftLimitCents") or 0), "hardLimitCents": int(settings.get("monthlyHardLimitCents") or 0), "enforceHardLimit": bool(settings.get("enforceMonthlyHardLimit")), + # HONESTY (WU-D4): the month-to-date total is derived from PLACEHOLDER + # pricing (no curated model publishes a real per-request price), so it is + # flagged an ESTIMATE — the UI must NOT present it as a real invoiced charge. + "isEstimate": _pricing.spend_is_estimated(), } +def providers_usage_availability(self: Services, params: dict[str, Any], ctx: RpcContext) -> dict[str, Any]: + """``providers.usageAvailability()`` -> ``{availability:[...]}`` honest per-provider notes (WU-D4). + + The LOCAL request/token counters (``providers.usage``) are always surfaced and + OpenRouter's per-key COST is fetched live (``providers.openrouterUsage``). This + read states, for every OTHER configured cloud provider, whether a provider-side + usage API exists — OpenAI/Anthropic gate usage behind an organization ADMIN key + a stored project key cannot use, and other providers publish nothing per-key. + Rather than fabricate a 0, each such provider gets an honest "Usage API not + available for " message. Rows carry the provider name only — NEVER a + key (the classifier reads no key material). + """ + from ..models import provider_usage_availability as _availability # local: import-light pure + + raw_providers = self.settings.get_raw().get("providers") + providers = raw_providers if isinstance(raw_providers, list) else [] + return {"availability": _availability.usage_availability(providers)} + + def providers_apply_preset(self: Services, params: dict[str, Any], ctx: RpcContext) -> dict[str, Any]: """``providers.applyPreset({name})`` -> ``{activePreset, routing}`` (WU-presets). diff --git a/sidecar/media_studio/models/provider_pricing.py b/sidecar/media_studio/models/provider_pricing.py new file mode 100644 index 00000000..9d768670 --- /dev/null +++ b/sidecar/media_studio/models/provider_pricing.py @@ -0,0 +1,69 @@ +"""Real per-request cloud pricing + the honest "this is an estimate" flag (WU-D4). + +The spend ledger stores integer cents but never PRICES a model — the handler +derives a cents estimate and hands it in. Historically that estimate was a flat +1c/request placeholder with NO signal it was a stand-in, so the month-to-date +readout looked like a real invoiced amount. This module is the single home that +turns a run into cents HONESTLY: + + * :data:`PRICE_CENTS_PER_REQUEST` — a real per-model price table, populated ONLY + with prices we can actually cite. Every curated catalog model today is + FREE/FREEMIUM with no published per-request price (pricing is per-TOKEN, not + per-request), so the table is HONESTLY empty and every estimate falls back to + the placeholder. + * :data:`PLACEHOLDER_CENTS_PER_REQUEST` — the documented non-zero stand-in used + when no real price is known. Non-zero so an enabled monthly hard cap can still + trip; it is NEVER presented as a real charge (see :func:`is_estimated`). + * :func:`request_cents` / :func:`is_estimated` / :func:`spend_is_estimated` — + the per-model price, whether a per-model cost is a placeholder, and whether the + aggregate spend figure is estimate-derived (so the UI can label it honestly). + +Pure arithmetic + a static table: NO I/O, NO network, NO catalog import (avoids a +cycle). When real pricing is confirmed, add ``"model-id": cents`` entries here — +do not scatter the magic number into callers; every estimate derives from here. +""" + +from __future__ import annotations + +#: DOCUMENTED PLACEHOLDER rate (cents) for one cloud request when no real per-model +#: price is known. Non-zero so an enabled monthly hard cap can trip; flagged as an +#: estimate by :func:`is_estimated` so it is NEVER shown as a real charge. +PLACEHOLDER_CENTS_PER_REQUEST: int = 1 + +#: Real per-request price (cents) keyed by model id, populated ONLY with prices we +#: can cite. Empty today: every curated model is free/freemium with no published +#: per-request price. Add ``"model-id": cents`` as real pricing is confirmed. +PRICE_CENTS_PER_REQUEST: dict[str, int] = {} + + +def request_cents(model: str | None) -> int: + """The per-request cost (cents) for ``model``: a real price if known, else the placeholder.""" + if isinstance(model, str): + price = PRICE_CENTS_PER_REQUEST.get(model) + if price is not None: + return price + return PLACEHOLDER_CENTS_PER_REQUEST + + +def is_estimated(model: str | None) -> bool: + """True when ``model``'s cost is the PLACEHOLDER (no real price) — label it an estimate.""" + return not (isinstance(model, str) and model in PRICE_CENTS_PER_REQUEST) + + +def spend_is_estimated() -> bool: + """True while the aggregate spend figure is derived from placeholder pricing. + + The month-to-date total is an estimate as long as we hold NO real per-request + price at all (every model falls back to the placeholder). Once even one real + price is confirmed, the aggregate is no longer purely placeholder-derived. + """ + return not PRICE_CENTS_PER_REQUEST + + +__all__ = [ + "PLACEHOLDER_CENTS_PER_REQUEST", + "PRICE_CENTS_PER_REQUEST", + "is_estimated", + "request_cents", + "spend_is_estimated", +] diff --git a/sidecar/media_studio/models/provider_usage_availability.py b/sidecar/media_studio/models/provider_usage_availability.py new file mode 100644 index 00000000..f1a0f590 --- /dev/null +++ b/sidecar/media_studio/models/provider_usage_availability.py @@ -0,0 +1,105 @@ +"""Honest per-provider usage-API availability (WU-D4 — no fabricated numbers). + +The LOCAL request/token counters (``provider.RotatingProvider.usage``) are always +surfaced, and OpenRouter uniquely exposes a per-KEY cost endpoint reachable with a +normal stored key (``GET /api/v1/key`` — see :mod:`openrouter_usage`). Every OTHER +cloud provider either gates usage behind an ORGANIZATION ADMIN key that a stored +project key cannot use (OpenAI's ``/v1/organization/usage`` needs an ``sk-admin`` +key; Anthropic's ``/v1/organizations/usage_report`` needs an ``sk-ant-admin`` key) +or publishes no per-key usage endpoint at all. + +Rather than invent a 0 (or reuse the 1c/request placeholder) for those providers, +:func:`usage_availability` states the truth: one row per configured cloud provider +saying whether a provider-side usage API exists and, when it does not, an honest +"Usage API not available for " message the UI shows verbatim. + +Pure classification over the configured (RAW-keyed) provider entries — it reads no +keys and returns none (rows carry the provider display name only, never a key). +""" + +from __future__ import annotations + +from typing import Any, TypedDict + +from . import openrouter_usage as _oru +from .provider import LOCAL_PROVIDER_ID + + +class UsageAvailabilityRow(TypedDict): + """One provider's provider-side-usage-API availability (never carries a key).""" + + provider: str + hasUsageApi: bool + message: str + + +#: Host / id fragments for the admin-key-gated providers, with the phrase naming +#: the credential their usage API actually requires (so the note is specific, not +#: a hand-wave). Matched against the entry's base-url host and its id/name. +_ADMIN_KEY_PROVIDERS: tuple[tuple[str, str], ...] = ( + ("api.openai.com", "openai"), + ("api.anthropic.com", "anthropic"), +) + + +def _provider_name(entry: dict[str, Any]) -> str: + """The display provider name for a row (falls back to id).""" + return str(entry.get("provider") or entry.get("id") or "") + + +def _is_local(entry: dict[str, Any]) -> bool: + """Whether an entry is the local backstop (no provider-side usage API applies).""" + identity = str(entry.get("provider") or entry.get("id") or "").strip().lower() + return str(entry.get("kind") or "").strip().lower() == "local" or identity == LOCAL_PROVIDER_ID + + +def _needs_admin_key(entry: dict[str, Any]) -> bool: + """Whether this provider's usage API is gated behind an organization admin key.""" + base_url = str(entry.get("baseUrl") or "").lower() + identity = str(entry.get("provider") or entry.get("id") or "").strip().lower() + return any(host in base_url or ident == identity for host, ident in _ADMIN_KEY_PROVIDERS) + + +def _classify(entry: dict[str, Any]) -> UsageAvailabilityRow: + """Classify ONE configured cloud entry into an honest availability row.""" + name = _provider_name(entry) + if _oru.is_openrouter(entry): + return UsageAvailabilityRow( + provider=name, + hasUsageApi=True, + message=f"Live per-key credit usage is available from {name}.", + ) + if _needs_admin_key(entry): + return UsageAvailabilityRow( + provider=name, + hasUsageApi=False, + message=f"Usage API not available for {name} without an organization admin key.", + ) + return UsageAvailabilityRow( + provider=name, + hasUsageApi=False, + message=f"Usage API not available for {name}.", + ) + + +def usage_availability(providers: list[Any]) -> list[UsageAvailabilityRow]: + """One honest availability row per configured CLOUD provider (deduped, in order). + + Skips non-dict entries, entries with no id/name, and the local backstop (which + has no provider-side usage API to speak of). Multiple keys for the same + provider collapse to a single row. No key is ever read or returned. + """ + rows: list[UsageAvailabilityRow] = [] + seen: set[str] = set() + for entry in providers: + if not isinstance(entry, dict) or _is_local(entry): + continue + name = _provider_name(entry) + if not name or name in seen: + continue + seen.add(name) + rows.append(_classify(entry)) + return rows + + +__all__ = ["UsageAvailabilityRow", "usage_availability"] diff --git a/sidecar/tests/test_handlers_d4_usage.py b/sidecar/tests/test_handlers_d4_usage.py new file mode 100644 index 00000000..11ee2c2a --- /dev/null +++ b/sidecar/tests/test_handlers_d4_usage.py @@ -0,0 +1,150 @@ +"""WU D4 — usage handlers: honest spend estimate flag + usage-availability RPC. + +Completes the usage surface without fakes: + * ``providers.spend`` now carries ``isEstimate`` so the month-to-date figure — + derived from PLACEHOLDER pricing (no catalog carries a real per-request price) + — is labelled an ESTIMATE rather than shown as a real invoiced charge. + * ``providers.usageAvailability`` states, per configured cloud provider, whether + a provider-side usage API exists — OpenRouter yes, everything else an honest + "Usage API not available for " (never a fabricated 0). + * the LOCAL request/token counters keep incrementing on real ops (the pool's + per-key ``used`` is always surfaced). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import pytest +from media_studio import protocol +from media_studio.handlers import Services, register_all +from media_studio.protocol import RpcContext + + +class SpyProvider: + def chat(self, messages: Sequence[Any], **_kw: Any) -> str: + return "ok" + + +def _ctx() -> RpcContext: + return RpcContext(emit_notification=lambda obj: None, jobs=None) + + +def _svc(tmp_path: Path, **kw: Any) -> Services: + return Services(data_dir=tmp_path, provider=SpyProvider(), library=None, **kw) + + +def _with_providers(svc: Services) -> Services: + svc.settings.set( + { + "providers": [ + { + "id": "groq", + "provider": "Groq", + "kind": "cloud", + "baseUrl": "https://api.groq.com/openai/v1", + "apiKeys": ["gsk-secret-ABCDWXYZ"], + "enabled": True, + "capabilities": ["text"], + "unit": "token", + }, + { + "id": "openrouter", + "provider": "OpenRouter", + "kind": "cloud", + "baseUrl": "https://openrouter.ai/api/v1", + "apiKeys": ["sk-or-secret-MNOP"], + "enabled": True, + "capabilities": ["text"], + "unit": "req", + }, + ] + } + ) + return svc + + +# --------------------------------------------------------------------------- # +# providers.spend — the honest ESTIMATE flag +# --------------------------------------------------------------------------- # +def test_spend_is_flagged_estimate_when_only_placeholder_pricing(tmp_path: Path) -> None: + svc = _svc(tmp_path) + svc._spend_ledger().record(123) + out = svc.providers_spend({}, _ctx()) + assert out["monthToDateCents"] == 123 + # No catalog model has a real per-request price today, so the figure is an + # estimate — the UI must NOT present it as a real charge. + assert out["isEstimate"] is True + + +def test_spend_estimate_flag_flips_when_real_pricing_exists(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from media_studio.models import provider_pricing + + monkeypatch.setitem(provider_pricing.PRICE_CENTS_PER_REQUEST, "priced-model", 5) + svc = _svc(tmp_path) + svc._spend_ledger().record(10) + out = svc.providers_spend({}, _ctx()) + assert out["isEstimate"] is False + + +def test_spend_still_reports_caps_and_month(tmp_path: Path) -> None: + svc = _svc(tmp_path) + svc.settings.set({"monthlySoftLimitCents": 500, "monthlyHardLimitCents": 2000}) + out = svc.providers_spend({}, _ctx()) + assert out["softLimitCents"] == 500 + assert out["hardLimitCents"] == 2000 + assert "month" in out + + +# --------------------------------------------------------------------------- # +# providers.usageAvailability — honest per-provider provider-side-API notes +# --------------------------------------------------------------------------- # +def test_usage_availability_lists_each_provider_honestly(tmp_path: Path) -> None: + svc = _with_providers(_svc(tmp_path)) + out = svc.providers_usage_availability({}, _ctx()) + rows = out["availability"] + by_provider = {r["provider"]: r for r in rows} + assert by_provider["OpenRouter"]["hasUsageApi"] is True + assert by_provider["Groq"]["hasUsageApi"] is False + assert "Usage API not available for Groq" in by_provider["Groq"]["message"] + + +def test_usage_availability_is_key_free(tmp_path: Path) -> None: + import json + + svc = _with_providers(_svc(tmp_path)) + out = svc.providers_usage_availability({}, _ctx()) + assert "gsk-secret-ABCDWXYZ" not in json.dumps(out) + assert "sk-or-secret-MNOP" not in json.dumps(out) + + +def test_usage_availability_empty_with_no_providers(tmp_path: Path) -> None: + svc = _svc(tmp_path) + out = svc.providers_usage_availability({}, _ctx()) + assert out == {"availability": []} + + +def test_usage_availability_tolerates_non_list_providers(tmp_path: Path) -> None: + svc = _svc(tmp_path) + svc.settings.set({"providers": "corrupt"}) + out = svc.providers_usage_availability({}, _ctx()) + assert out == {"availability": []} + + +def test_usage_availability_registered(tmp_path: Path) -> None: + protocol.METHODS.clear() + register_all(_svc(tmp_path)) + assert "providers.usageAvailability" in protocol.METHODS + + +# --------------------------------------------------------------------------- # +# LOCAL counters always surface (real, never fabricated) +# --------------------------------------------------------------------------- # +def test_local_usage_counters_always_present(tmp_path: Path) -> None: + svc = _with_providers(_svc(tmp_path)) + rows = svc.providers_usage({}, _ctx())["usage"] + # Every configured key has a local row with a real (>=0) used counter and unit. + groq = [r for r in rows if r["provider"] == "Groq"] + assert groq and groq[0]["used"] >= 0 and groq[0]["unit"] == "token" diff --git a/sidecar/tests/test_handlers_rpc_surface.py b/sidecar/tests/test_handlers_rpc_surface.py index 05818522..56b2c431 100644 --- a/sidecar/tests/test_handlers_rpc_surface.py +++ b/sidecar/tests/test_handlers_rpc_surface.py @@ -88,6 +88,7 @@ "providers.testKey", "providers.upsert", "providers.usage", + "providers.usageAvailability", "readiness.summary", "recipes.delete", "recipes.list", diff --git a/sidecar/tests/test_provider_pricing.py b/sidecar/tests/test_provider_pricing.py new file mode 100644 index 00000000..049d527a --- /dev/null +++ b/sidecar/tests/test_provider_pricing.py @@ -0,0 +1,66 @@ +"""WU D4 — real per-request cloud pricing + the honest 'estimate' flag. + +The spend ledger stores integer cents but never PRICES a model; the handler +derives the cents estimate. Historically that estimate was a flat 1c/request +placeholder with NO signal it was a stand-in, so the month-to-date readout looked +like a real invoiced charge. :mod:`media_studio.models.provider_pricing` is the +single home that turns a run into cents HONESTLY: a real per-model price when we +can cite one, else the documented placeholder — always flagged as an estimate so +the UI never presents the placeholder as a real charge. +""" + +from __future__ import annotations + +import pytest +from media_studio.models import provider_pricing as pricing + + +def test_placeholder_is_non_zero() -> None: + # A non-zero placeholder is required so an enabled monthly hard cap can trip; + # a zero rate would make the cap un-trippable. + assert pricing.PLACEHOLDER_CENTS_PER_REQUEST > 0 + + +def test_real_price_table_is_empty_today() -> None: + # Every curated catalog model is FREE/FREEMIUM with no published per-request + # price, so the real table is HONESTLY empty — every estimate is a placeholder. + assert pricing.PRICE_CENTS_PER_REQUEST == {} + + +def test_unknown_model_falls_back_to_placeholder() -> None: + assert pricing.request_cents("some-unpriced-model") == pricing.PLACEHOLDER_CENTS_PER_REQUEST + + +def test_none_model_falls_back_to_placeholder() -> None: + assert pricing.request_cents(None) == pricing.PLACEHOLDER_CENTS_PER_REQUEST + + +def test_known_model_uses_the_real_price(monkeypatch: pytest.MonkeyPatch) -> None: + # When a real per-request price is confirmed and added to the table, it is used + # verbatim instead of the placeholder (proves the real-price branch). + monkeypatch.setitem(pricing.PRICE_CENTS_PER_REQUEST, "priced-model", 7) + assert pricing.request_cents("priced-model") == 7 + + +def test_is_estimated_true_for_unpriced_model() -> None: + assert pricing.is_estimated("some-unpriced-model") is True + + +def test_is_estimated_true_for_none_model() -> None: + assert pricing.is_estimated(None) is True + + +def test_is_estimated_false_for_known_model(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(pricing.PRICE_CENTS_PER_REQUEST, "priced-model", 7) + assert pricing.is_estimated("priced-model") is False + + +def test_spend_is_estimated_true_while_table_empty() -> None: + # The month-to-date figure is derived from placeholder pricing as long as ANY + # routable model lacks a real price — which is every model today. + assert pricing.spend_is_estimated() is True + + +def test_spend_is_estimated_false_only_when_a_real_price_exists(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setitem(pricing.PRICE_CENTS_PER_REQUEST, "priced-model", 7) + assert pricing.spend_is_estimated() is False diff --git a/sidecar/tests/test_provider_usage_availability.py b/sidecar/tests/test_provider_usage_availability.py new file mode 100644 index 00000000..5410cf96 --- /dev/null +++ b/sidecar/tests/test_provider_usage_availability.py @@ -0,0 +1,91 @@ +"""WU D4 — honest per-provider usage-API availability (no fabricated numbers). + +Only OpenRouter exposes a per-KEY usage/cost endpoint reachable with a normal +stored key (``GET /api/v1/key``). OpenAI and Anthropic gate their usage/cost +reports behind an ORGANIZATION ADMIN key (``sk-admin`` / ``sk-ant-admin``), which +a stored project key cannot use; other cloud providers publish nothing per-key. +:func:`provider_usage_availability.usage_availability` states this HONESTLY — one +row per configured cloud provider saying whether a provider-side usage API exists, +so the UI can show "Usage API not available for " instead of a fake 0. +""" + +from __future__ import annotations + +from media_studio.models.provider_usage_availability import usage_availability + + +def _entry(**kw: object) -> dict[str, object]: + base: dict[str, object] = {"id": "x", "provider": "X", "kind": "cloud", "apiKeys": ["k"]} + base.update(kw) + return base + + +def test_openrouter_reports_available() -> None: + rows = usage_availability([_entry(id="openrouter", provider="OpenRouter", baseUrl="https://openrouter.ai/api/v1")]) + assert len(rows) == 1 + assert rows[0]["provider"] == "OpenRouter" + assert rows[0]["hasUsageApi"] is True + + +def test_openai_reports_unavailable_with_admin_key_reason() -> None: + rows = usage_availability([_entry(id="openai", provider="OpenAI", baseUrl="https://api.openai.com/v1")]) + assert rows[0]["hasUsageApi"] is False + msg = rows[0]["message"] + assert "Usage API not available for OpenAI" in msg + assert "admin key" in msg + + +def test_anthropic_reports_unavailable_with_admin_key_reason() -> None: + rows = usage_availability([_entry(id="anthropic", provider="Anthropic", baseUrl="https://api.anthropic.com/v1")]) + assert rows[0]["hasUsageApi"] is False + assert "Usage API not available for Anthropic" in rows[0]["message"] + assert "admin key" in rows[0]["message"] + + +def test_generic_cloud_provider_reports_unavailable() -> None: + rows = usage_availability([_entry(id="groq", provider="Groq", baseUrl="https://api.groq.com/openai/v1")]) + assert rows[0]["hasUsageApi"] is False + assert rows[0]["message"] == "Usage API not available for Groq." + + +def test_provider_name_falls_back_to_id() -> None: + rows = usage_availability([{"id": "groq", "kind": "cloud", "apiKeys": ["k"]}]) + assert rows[0]["provider"] == "groq" + assert rows[0]["message"] == "Usage API not available for groq." + + +def test_multiple_keys_collapse_to_one_row_per_provider() -> None: + rows = usage_availability([_entry(id="groq", provider="Groq", apiKeys=["k1", "k2"])]) + assert len(rows) == 1 + + +def test_multiple_providers_each_get_a_row_in_order() -> None: + rows = usage_availability( + [ + _entry(id="groq", provider="Groq"), + _entry(id="openrouter", provider="OpenRouter", baseUrl="https://openrouter.ai/api/v1"), + ] + ) + assert [r["provider"] for r in rows] == ["Groq", "OpenRouter"] + + +def test_local_backstop_entry_is_skipped() -> None: + rows = usage_availability([{"id": "local", "provider": "local", "kind": "local"}]) + assert rows == [] + + +def test_entry_without_identity_is_skipped() -> None: + rows = usage_availability([{"kind": "cloud", "apiKeys": ["k"]}, "not-a-dict"]) # type: ignore[list-item] + assert rows == [] + + +def test_keyless_cloud_entry_is_still_listed() -> None: + # A provider added from the picker before pasting a key still deserves an honest + # "no provider-side usage API" note (it is configured, just not yet keyed). + rows = usage_availability([{"id": "groq", "provider": "Groq", "kind": "cloud"}]) + assert len(rows) == 1 + assert rows[0]["hasUsageApi"] is False + + +def test_empty_providers_returns_empty() -> None: + assert usage_availability([]) == [] diff --git a/sidecar/tests/test_settings_provider_surface_characterization.py b/sidecar/tests/test_settings_provider_surface_characterization.py index e4fc1a46..f156c424 100644 --- a/sidecar/tests/test_settings_provider_surface_characterization.py +++ b/sidecar/tests/test_settings_provider_surface_characterization.py @@ -100,7 +100,9 @@ def test_provider_key_and_usage_rpc_surface_snapshot(tmp_path: Path) -> None: register_all(Services(data_dir=tmp_path)) surface = sorted(m for m in protocol.METHODS if m.startswith("providers.")) # The provider RPC surface as of v1.2.0 + Wave-1, PLUS the D3 addition - # ``providers.revealKey`` (the transient masked-reveal contract that closes G-3). + # ``providers.revealKey`` (the transient masked-reveal contract that closes G-3) + # and the D4 addition ``providers.usageAvailability`` (honest per-provider + # provider-side-usage-API notes — never a fabricated number). assert surface == [ "providers.applyPreset", "providers.catalog", @@ -115,6 +117,7 @@ def test_provider_key_and_usage_rpc_surface_snapshot(tmp_path: Path) -> None: "providers.testKey", "providers.upsert", "providers.usage", + "providers.usageAvailability", ] # G-3 CLOSED (D3): the transient reveal handler now exists. Re-validate of a # stored key (G-4) is orchestrated in the renderer (revealKey -> testKey), so From aed0291028991cfac38d18dc28cfbfd82d708a36 Mon Sep 17 00:00:00 2001 From: Prekzursil Date: Sat, 4 Jul 2026 09:47:54 +0300 Subject: [PATCH 011/104] fix(director): plan against the selected video, gate the no-video empty state (WU E1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread App's selected editVideo into and plan against video?.id ?? plan?.videoId — NEVER the goal text. The prop-less mount previously dispatched the goal string as the videoId on a first run (DirectorPanel.tsx: plan(plan?.videoId ?? trimmed, trimmed)). When no video is open and no prior plan is on screen the panel now shows a "Choose a video" empty state whose CTA routes to the Library, so a first run can never mis-fire. Claude-Session: https://claude.ai/code/session_01W4LPidyKbYghtHeCHqrpAy --- app/renderer/src/App.test.tsx | 50 ++++++++++- app/renderer/src/App.tsx | 5 +- .../src/panels/DirectorPanel.test.tsx | 88 +++++++++++++++++-- app/renderer/src/panels/DirectorPanel.tsx | 60 ++++++++++++- app/renderer/src/panels/directorPanel.css | 27 ++++++ 5 files changed, 219 insertions(+), 11 deletions(-) diff --git a/app/renderer/src/App.test.tsx b/app/renderer/src/App.test.tsx index c05c9ad7..4a55a0e8 100644 --- a/app/renderer/src/App.test.tsx +++ b/app/renderer/src/App.test.tsx @@ -58,9 +58,16 @@ vi.mock('./views/MakeShorts', () => ({ ), })); -// Stub the lazy AI Director panel (it owns its own tests). +// Stub the lazy AI Director panel (it owns its own tests). The marker echoes the +// threaded video id + exposes the empty-state CTA so App's WU-E1 wiring is testable. vi.mock('./panels/DirectorPanel', () => ({ - default: () =>
, + default: ({ video, onChooseVideo }: { video: Video | null; onChooseVideo?: () => void }) => ( +
+ +
+ ), })); // Stub the Settings view; expose the initialSection App wired in (it owns tests). @@ -192,6 +199,45 @@ describe('App top-level tabs', () => { expect(tab('Director').getAttribute('aria-selected')).toBe('true'); }); + it('WU-E1: threads the open video into the Director and the CTA routes to Library', async () => { + await act(async () => { + root.render(); + }); + await flush(); + // Open a video from the Library, then switch to the Director tab. + await act(async () => { + container.querySelector('[data-testid="library"] button')!.click(); + }); + await flush(); + await act(async () => { + tab('Director').click(); + }); + await flush(); + // The app-selected video id is threaded into the panel. + const director = container.querySelector('[data-testid="director"]')!; + expect(director.getAttribute('data-video-id')).toBe('v1'); + // The empty-state CTA is wired to route back to the Library (real selection). + await act(async () => { + container.querySelector('[data-testid="director"] button')!.click(); + }); + await flush(); + expect(container.querySelector('[data-testid="library"]')).not.toBeNull(); + expect(tab('Library').getAttribute('aria-selected')).toBe('true'); + }); + + it('WU-E1: opening the Director with no video threads a null video (empty id)', async () => { + await act(async () => { + root.render(); + }); + await flush(); + await act(async () => { + tab('Director').click(); + }); + await flush(); + const director = container.querySelector('[data-testid="director"]')!; + expect(director.getAttribute('data-video-id')).toBe(''); + }); + it('navigates to Settings (default section) via the tab', async () => { await act(async () => { root.render(); diff --git a/app/renderer/src/App.tsx b/app/renderer/src/App.tsx index 1b490cb4..2a6bf7b4 100644 --- a/app/renderer/src/App.tsx +++ b/app/renderer/src/App.tsx @@ -339,7 +339,10 @@ function AppShell(): React.ReactElement { case 'director': return ( Loading…
}> - + {/* WU-E1: thread the app-selected video so the Director plans against + video.id (never the goal text); the empty-state CTA routes to the + Library to pick one. */} + ); case 'settings': diff --git a/app/renderer/src/panels/DirectorPanel.test.tsx b/app/renderer/src/panels/DirectorPanel.test.tsx index 4ae50d5d..7767d676 100644 --- a/app/renderer/src/panels/DirectorPanel.test.tsx +++ b/app/renderer/src/panels/DirectorPanel.test.tsx @@ -31,6 +31,7 @@ import type { DirectorPreview, DoneEvent, ProgressEvent, + Video, client as RealClient, } from '../lib/rpc'; @@ -79,6 +80,19 @@ function planFixture(ops: DirectorOp[], goal = 'make it smooth'): DirectorEditPl return { planId: 'plan-1', videoId: 'vid-1', goal, sourceHash: 'h', ops, inverse: [] }; } +/** The app-selected video threaded into the panel (its id is the plan target). */ +function videoFixture(over: Partial