From f806a814ea221a390f01cd0a4f1474282f008882 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:02:23 +0000 Subject: [PATCH 1/5] Add OpenRouter as a setup connection with searchable model pickers OpenRouter replaces the non-functional Claude subscription card in the first-run wizard and joins the AI connection choices in Settings. Users add an OpenRouter key (encrypted with safeStorage, like the OpenAI key), pick the clip-finding LLM from OpenRouter's live model catalogue in a searchable dropdown with suggested models on top, and pick transcription from OpenRouter-hosted models or local Whisper. - Chat and transcription route to openrouter.ai with the OpenRouter key; OpenAI base URL settings and env vars do not apply on this route. - The catalogue is fetched in the main process and falls back to the suggested models offline. Check key uses /key and makes no model call. - Transcripts with segment timings but no word timings get estimated word timings; no timings at all fails with a clear fix. - The smoke walk captures the OpenRouter wizard screens from a sample catalogue fixture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012fRPuZ4E7P2EcTR4c9BzhS --- AGENTS.md | 2 +- CHANGELOG.md | 11 + README.md | 9 +- docs/getting-started.md | 3 +- scripts/smoke-test.sh | 2 + src/main/index.ts | 24 ++ src/main/ipc.ts | 3 + src/main/openrouter.ts | 89 ++++ src/main/pipeline/openai.ts | 51 ++- src/main/settings.ts | 44 +- src/preload/index.ts | 3 + src/renderer/src/components/HomeScreen.tsx | 5 +- src/renderer/src/components/ModelPicker.tsx | 155 +++++++ .../src/components/OpenRouterSetup.tsx | 117 ++++++ .../src/components/ProcessingScreen.tsx | 8 +- src/renderer/src/components/SettingsModal.tsx | 17 +- src/renderer/src/components/SetupWizard.tsx | 46 +- .../src/components/SubscriptionSettings.tsx | 1 + src/renderer/src/components/TopBar.tsx | 8 + src/shared/openrouter.ts | 146 +++++++ src/shared/subscription.ts | 6 +- src/shared/types.ts | 15 + tests/fixtures/openrouter-catalog.json | 396 ++++++++++++++++++ tests/openrouter.test.ts | 106 +++++ 24 files changed, 1232 insertions(+), 35 deletions(-) create mode 100644 src/main/openrouter.ts create mode 100644 src/renderer/src/components/ModelPicker.tsx create mode 100644 src/renderer/src/components/OpenRouterSetup.tsx create mode 100644 src/shared/openrouter.ts create mode 100644 tests/fixtures/openrouter-catalog.json create mode 100644 tests/openrouter.test.ts diff --git a/AGENTS.md b/AGENTS.md index 78cee61..76b3fe3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Cutawan is a single **Electron + Vite + React + TypeScript** desktop app (npm, N `xvfb-run -a --server-args="-screen 0 1600x1000x24" npx electron . --no-sandbox --disable-gpu` (Requires a prior `npm run build` so `out/` exists.) `npm run dev` also works but expects a display. - Harmless `Failed to connect to the bus` (DBus) and GPU warnings are expected under Xvfb and can be ignored. -- `scripts/smoke-test.sh [out-dir]` is the fastest end-to-end GUI check: it builds, seeds a demo project via `scripts/seed-demo.ts`, launches under Xvfb with `CUTAWAN_SMOKE` set, and writes `home.png`, `clips.png`, `editor.png`, `setup-clips.png`, `setup-caption-video.png`, `editor-caption-video.png`, `settings.png` and `settings-export.png` screenshots. The walk assumes a freshly seeded demo project (the script seeds one every run), and it runs "caption whole video" for real using the seeded transcript, so it makes no API calls. Set `CUTAWAN_SMOKE` to an output dir to trigger this auto-screenshot-and-exit mode. +- `scripts/smoke-test.sh [out-dir]` is the fastest end-to-end GUI check: it builds, seeds a demo project via `scripts/seed-demo.ts`, launches under Xvfb with `CUTAWAN_SMOKE` set, and writes the first-run wizard shots (`setup-wizard.png` plus `setup-openrouter*.png`, which read the sample catalogue in `tests/fixtures/openrouter-catalog.json` because the walk is offline), `home.png`, `clips.png`, `editor.png`, `setup-clips.png`, `setup-caption-video.png`, `editor-caption-video.png`, `settings.png` and `settings-export.png` screenshots. The walk assumes a freshly seeded demo project (the script seeds one every run), and it runs "caption whole video" for real using the seeded transcript, so it makes no API calls. Set `CUTAWAN_SMOKE` to an output dir to trigger this auto-screenshot-and-exit mode. ### Tests / lint - `npm test` (vitest), `npm run typecheck` and `npm run lint` (eslint) are the static gates, and CI enforces all three on every push. Run them before proposing a change. diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a9c69..029988d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ the [releases page](https://github.com/JeremySNR/cutawan/releases). This project uses [semantic versioning](https://semver.org/), loosely: while still pre-1.0, minor bumps carry new features and patch bumps carry fixes. +## [Unreleased] + +### Added + +- **OpenRouter** is now a connection in first-run setup and Settings. Add an OpenRouter key (encrypted with your system keychain), then pick the clip-finding model from OpenRouter's full model list. The list is searchable, suggests models at the top, and shows prices, context size and image support. Transcription can use an OpenRouter-hosted Whisper model or local Whisper on your computer. **Check key** validates the key without making a model request. +- If a hosted transcription model returns only segment timings, word timings are estimated so captions still work. A model that returns no timings at all fails with a message saying what to change. + +### Changed + +- The setup wizard no longer shows the unavailable "Claude subscription" card. Claude models can be used through OpenRouter instead. + ## [0.13.0] - 2026-09-25 ### Added diff --git a/README.md b/README.md index 8f6e922..aa8a9b7 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ ## Download and get started 1. **[Download the latest release](https://github.com/JeremySNR/cutawan/releases/latest).** Choose the Windows `.exe` installer, macOS `.dmg`, or Linux `.AppImage` under **Assets**. You do not need Node.js or a source checkout to use the app. -2. **Set up your connection.** In v0.10.0 and newer, the first-run wizard offers **ChatGPT sign-in** via Codex for AI clip finding with local transcription, an **OpenAI-compatible API** for separately billed analysis, or **Local captions only** to caption a whole video without an AI connection. [Setup requirements and choices](docs/getting-started.md) are explained step by step. You can also explore the editor before setting up a connection. +2. **Set up your connection.** In v0.10.0 and newer, the first-run wizard offers **ChatGPT sign-in** via Codex for AI clip finding with local transcription, an **OpenAI-compatible API** for separately billed analysis, **OpenRouter** for any model OpenRouter lists with one key, or **Local captions only** to caption a whole video without an AI connection. [Setup requirements and choices](docs/getting-started.md) are explained step by step. You can also explore the editor before setting up a connection. 3. **Import a video or paste a supported URL.** Choose **Find viral clips** to review suggested moments, or **Caption whole video** to make one captioned edit. Adjust the trim, captions and framing, then export an MP4. The ChatGPT/Codex option is a beta integration with your plan's Codex allowance, not an included OpenAI API. It needs the Codex CLI signed in with ChatGPT and Python 3.10+; the wizard can install local faster-whisper and a speech model after you choose it. AI clip finding still needs either Codex or an API connection. Builds before v0.10.0 do not show the wizard; configure the connection in **Settings → General → AI connection** instead. @@ -217,6 +217,7 @@ for analysis. Rendering, face tracking, zoom and export are local. Not as Cutawan's AI connection. [Anthropic's guidance](https://support.claude.com/en/articles/13189465-log-in-to-your-claude-account) directs developers of third-party apps, including open-source apps, to use API-key authentication. Cutawan will not route automated requests through a personal Claude login. +You can use Claude models with an API key through the **OpenRouter** connection instead. **How is this different from Opus Clip's free tier?** Free SaaS tiers cap your processing minutes and usually watermark the output. @@ -260,8 +261,10 @@ Signing and notarisation are wanted; see are configurable in Settings, including a cheaper legacy option. **Can I run it against a local or non-OpenAI model?** -Yes, if it speaks the OpenAI REST shape. Set the API base URL in Settings -(or `OPENAI_BASE_URL`) to Azure OpenAI, OpenRouter, Groq, LM Studio, Ollama, +Yes. The **OpenRouter** connection takes one OpenRouter key and lets you pick +any chat model it lists, plus a hosted Whisper model or local transcription. +Your key is encrypted with the OS keychain. For anything else that speaks the OpenAI REST shape, set the API base URL in Settings +(or `OPENAI_BASE_URL`) to Azure OpenAI, Groq, LM Studio, Ollama, or anything else with `/v1/chat/completions`. Transcription can point at a separate local Whisper server (faster-whisper, whisper.cpp’s compatible endpoint) — it must return **word-level timestamps**, because captions and diff --git a/docs/getting-started.md b/docs/getting-started.md index 376cfbe..1f2e34a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -18,12 +18,13 @@ Signing and notarisation are planned. Until then, only download installers from ## Choose a connection -The first-run wizard offers three paths. You can switch later in **Settings → General → AI connection**. **Explore without setup** lets you look around, but processing a new video still needs one of the routes below. +The first-run wizard offers four paths. You can switch later in **Settings → General → AI connection**. **Explore without setup** lets you look around, but processing a new video still needs one of the routes below. | Route | What it does | What you need | | --- | --- | --- | | **ChatGPT sign-in** (beta) | Uses Codex for clip finding and local Whisper for transcription. No OpenAI API key or automatic paid-API fallback. | An installed Codex CLI signed in with ChatGPT, Python 3.10+, and a local Whisper model. Your plan's Codex limits apply. [Detailed setup](chatgpt-subscription.md). | | **OpenAI-compatible API** | Uses your configured API endpoint for analysis. Speech can go to the transcription API or run locally with Whisper. | An API key and separately billed provider usage. A local-speech choice also needs Python 3.10+ and a model. | +| **OpenRouter** | One OpenRouter key for clip finding with any chat model OpenRouter lists (OpenAI, Anthropic, Google and others), picked from a searchable list with suggestions at the top. Speech goes to an OpenRouter-hosted Whisper model or runs locally. | An [OpenRouter key](https://openrouter.ai/keys) and OpenRouter credits. A local-speech choice also needs Python 3.10+ and a model. | | **Local captions only** | Transcribes and captions a whole video without sending it to an AI service. It does not find or score short clips. | Python 3.10+ and a local Whisper model. | For either local-speech route, install Python separately first. The wizard can then create a private environment and download faster-whisper plus a **Small** or **Large v3** model into Cutawan's app-data folder after you request it. Small is the lighter download; Large v3 needs several gigabytes. The setup check does not make an AI model request. CPU transcription works but can be slow. diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 4777d1f..07aa6d0 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -10,7 +10,9 @@ OUT="${1:-.tmp/smoke}" mkdir -p "$OUT" npm run build >/dev/null SMOKE_OUT="$(realpath "$OUT")" +# The OpenRouter pickers read a saved sample catalogue so the walk needs no network. CUTAWAN_USER_DATA="$SMOKE_OUT/wizard-profile" CUTAWAN_SMOKE="$SMOKE_OUT" CUTAWAN_SMOKE_WIZARD=1 \ + CUTAWAN_OPENROUTER_CATALOG="$(realpath tests/fixtures/openrouter-catalog.json)" \ xvfb-run -a --server-args="-screen 0 1600x1000x24" \ npx electron . --no-sandbox --disable-gpu npx tsx --tsconfig tsconfig.node.json scripts/seed-demo.ts diff --git a/src/main/index.ts b/src/main/index.ts index 83e7ad6..1e78d79 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -64,6 +64,30 @@ async function runSmokeCapture(win: BrowserWindow, dir: string): Promise { await sleep(2500) if (process.env.CUTAWAN_SMOKE_WIZARD) { await shot('setup-wizard') + // OpenRouter route: key entry plus the searchable model pickers. + const reveal = (selector: string): Promise => win.webContents.executeJavaScript( + `document.querySelector(${JSON.stringify(selector)}).scrollIntoView({ block: 'start' })` + ) + await click('[data-testid="setup-route-openrouter"]') + await reveal('[data-testid="openrouter-setup"]') + await shot('setup-openrouter') + await click('[data-testid="openrouter-model-toggle"]') + await reveal('[data-testid="openrouter-model"]') + await shot('setup-openrouter-models') + await win.webContents.executeJavaScript(`(() => { + const input = document.querySelector('[data-testid="openrouter-model-search"]') + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(input, 'claude') + input.dispatchEvent(new Event('input', { bubbles: true })) + })()`) + await sleep(400) + await shot('setup-openrouter-search') + await click('[data-testid="openrouter-model-toggle"]') + await click('[data-testid="openrouter-transcription-toggle"]') + await reveal('[data-testid="openrouter-transcription"]') + await shot('setup-openrouter-transcription') + await click('[data-testid="openrouter-transcription-option-local-whisper"]') + await reveal('[data-testid="openrouter-transcription"]') + await shot('setup-openrouter-local') app.quit() return } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index bd201b8..b7a6f56 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,4 +1,5 @@ import { checkLocalWhisperSetup, checkSubscriptionSetup } from './subscription' +import { checkOpenRouterKey, listOpenRouterModels } from './openrouter' import { cancelLocalWhisperInstall, installLocalWhisper, type LocalWhisperModel } from './localWhisper' import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron' import { existsSync } from 'node:fs' @@ -363,6 +364,8 @@ export function registerIpcHandlers(): void { handle('settings:checkSubscription', () => checkSubscriptionSetup()) handle('settings:checkLocalWhisper', () => checkLocalWhisperSetup()) + handle('openrouter:models', (_e, refresh?: boolean) => listOpenRouterModels(refresh === true)) + handle('openrouter:checkKey', (_e, key?: string) => checkOpenRouterKey(key)) handle('settings:installLocalWhisper', async (event, model: LocalWhisperModel, pythonPath: string) => { const result = await installLocalWhisper(model, pythonPath, progress => { if (!event.sender.isDestroyed()) event.sender.send('whisper:installProgress', progress) diff --git a/src/main/openrouter.ts b/src/main/openrouter.ts new file mode 100644 index 0000000..29998d2 --- /dev/null +++ b/src/main/openrouter.ts @@ -0,0 +1,89 @@ +import { readFile } from 'node:fs/promises' +import { + OPENROUTER_API_BASE, + parseOpenRouterModels, + suggestedAsModels, + SUGGESTED_OPENROUTER_MODELS, + SUGGESTED_OPENROUTER_TRANSCRIPTION_MODELS, + type OpenRouterCatalog +} from '@shared/openrouter' +import { getOpenRouterKey } from './settings' + +const CATALOG_TTL_MS = 10 * 60_000 +const REQUEST_TIMEOUT_MS = 12_000 + +let cached: { at: number; catalog: OpenRouterCatalog } | null = null + +async function getJson(path: string, key?: string): Promise<{ status: number; body: unknown }> { + const res = await fetch(`${OPENROUTER_API_BASE}${path}`, { + headers: key ? { Authorization: `Bearer ${key}` } : {}, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) + }) + let body: unknown = null + try { body = await res.json() } catch { /* non-JSON body */ } + return { status: res.status, body } +} + +/** + * The public model catalogue (no key needed): chat models, and the + * transcription models OpenRouter serves at /audio/transcriptions. Offline, + * the suggested models stand in so setup still works. + * `CUTAWAN_OPENROUTER_CATALOG` points at a saved `/models` response, for + * headless checks without network access. + */ +export async function listOpenRouterModels(refresh = false): Promise { + if (!refresh && cached && Date.now() - cached.at < CATALOG_TTL_MS) return cached.catalog + const fixture = process.env.CUTAWAN_OPENROUTER_CATALOG + try { + let llm, transcription + if (fixture) { + const saved = JSON.parse(await readFile(fixture, 'utf8')) as { models: unknown; transcription: unknown } + llm = parseOpenRouterModels(saved.models, 'llm') + transcription = parseOpenRouterModels(saved.transcription, 'transcription') + } else { + const [chat, speech] = await Promise.all([ + getJson('/models'), + getJson('/models?output_modalities=transcription').catch(() => ({ status: 0, body: null })) + ]) + if (chat.status !== 200) throw new Error(`OpenRouter returned HTTP ${chat.status}`) + llm = parseOpenRouterModels(chat.body, 'llm') + transcription = parseOpenRouterModels(speech.body, 'transcription') + } + if (!llm.length) throw new Error('OpenRouter returned no models') + const catalog: OpenRouterCatalog = { + llm, + transcription: transcription.length ? transcription : suggestedAsModels(SUGGESTED_OPENROUTER_TRANSCRIPTION_MODELS), + live: true + } + cached = { at: Date.now(), catalog } + return catalog + } catch (error) { + return { + llm: suggestedAsModels(SUGGESTED_OPENROUTER_MODELS), + transcription: suggestedAsModels(SUGGESTED_OPENROUTER_TRANSCRIPTION_MODELS), + live: false, + error: error instanceof Error ? error.message : String(error) + } + } +} + +/** + * Validate a key (the typed one, else the stored one) with OpenRouter's + * key-info endpoint. Makes no model request and costs nothing. + */ +export async function checkOpenRouterKey(typed?: string): Promise<{ message: string }> { + const key = typed?.trim() || getOpenRouterKey() + if (!key) throw new Error('Enter an OpenRouter API key first.') + let res: { status: number; body: unknown } + try { + res = await getJson('/key', key) + } catch (error) { + throw new Error(`Could not reach OpenRouter: ${error instanceof Error ? error.message : String(error)}`, { cause: error }) + } + if (res.status === 401 || res.status === 403) throw new Error('OpenRouter rejected this key. Copy it again from openrouter.ai/keys.') + if (res.status !== 200) throw new Error(`OpenRouter key check failed (HTTP ${res.status}).`) + const data = (res.body as { data?: { limit_remaining?: unknown; is_free_tier?: unknown } } | null)?.data + const remaining = typeof data?.limit_remaining === 'number' ? ` $${data.limit_remaining.toFixed(2)} of this key’s limit remains.` : '' + const free = data?.is_free_tier === true ? ' This account is on the free tier; add credits to use paid models.' : '' + return { message: `OpenRouter accepted the key.${remaining}${free}` } +} diff --git a/src/main/pipeline/openai.ts b/src/main/pipeline/openai.ts index 281d6cd..28e4cda 100644 --- a/src/main/pipeline/openai.ts +++ b/src/main/pipeline/openai.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { basename } from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' import { analysisRequests } from './mediaJobs' +import { OPENROUTER_API_BASE } from '@shared/openrouter' /** * Minimal OpenAI REST client using Node's built-in fetch, so the app has no @@ -58,17 +59,26 @@ export function resolveOpenAiApiBase( */ let settingsChatBase: string | undefined let settingsTranscriptionBase: string | undefined +let openRouter = false export function configureOpenAiEndpoints(opts: { chatBase?: string transcriptionBase?: string + /** + * OpenRouter is its own provider: both chat and transcription go to + * openrouter.ai with the OpenRouter key, and the OpenAI base URL settings + * and env vars do not apply. + */ + openRouter?: boolean }): void { settingsChatBase = opts.chatBase?.trim() || undefined settingsTranscriptionBase = opts.transcriptionBase?.trim() || undefined + openRouter = opts.openRouter === true } /** Chat completions base (analysis, captions, B-roll, visual scoring). */ export function chatApiBase(): string { + if (openRouter) return OPENROUTER_API_BASE return resolveOpenAiApiBase(process.env.OPENAI_BASE_URL || settingsChatBase) } @@ -78,6 +88,7 @@ export function chatApiBase(): string { * Whisper server can sit next to a hosted LLM. */ export function transcriptionApiBase(): string { + if (openRouter) return OPENROUTER_API_BASE return resolveOpenAiApiBase( process.env.OPENAI_TRANSCRIPTION_BASE_URL || process.env.OPENAI_BASE_URL || @@ -86,6 +97,11 @@ export function transcriptionApiBase(): string { ) } +/** OpenRouter's optional app attribution headers; nothing for other endpoints. */ +function providerHeaders(): Record { + return openRouter ? { 'HTTP-Referer': 'https://github.com/JeremySNR/cutawan', 'X-Title': 'Cutawan' } : {} +} + export class OpenAIError extends Error { constructor( message: string, @@ -232,17 +248,45 @@ export async function transcribeAudioFile( const res = await fetch(`${transcriptionApiBase()}/audio/transcriptions`, { method: 'POST', - headers: { Authorization: `Bearer ${apiKey}` }, + headers: { Authorization: `Bearer ${apiKey}`, ...providerHeaders() }, body: form, signal: withTimeout(TRANSCRIBE_TIMEOUT_MS, opts.signal) }) await raiseForStatus(res, 'Transcription') - return (await res.json()) as WhisperResponse + return withWordTimings((await res.json()) as WhisperResponse, model) }, { signal: opts.signal } ) } +/** + * Captions need word timings. Some hosted transcription models (reachable via + * OpenRouter) return only segment timings: spread each segment's words across + * it, weighted by length. With no timings at all, fail with a fix rather than + * producing a transcript that cannot be captioned. + */ +export function withWordTimings(res: WhisperResponse, model: string): WhisperResponse { + if (res.words?.length || !res.text?.trim()) return res + const segments = res.segments?.filter(seg => seg.text.trim() && seg.end > seg.start) ?? [] + if (!segments.length) { + throw new OpenAIError( + `The transcription model ${model} did not return timestamps. Choose a Whisper model or local transcription in Settings.` + ) + } + const words: WhisperWord[] = [] + for (const seg of segments) { + const tokens = seg.text.trim().split(/\s+/) + const total = tokens.reduce((sum, token) => sum + token.length, 0) + let at = seg.start + for (const token of tokens) { + const end = at + ((seg.end - seg.start) * token.length) / total + words.push({ word: token, start: at, end }) + at = end + } + } + return { ...res, words } +} + export type ChatContentPart = | { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string; detail?: 'low' | 'high' | 'auto' } } @@ -356,7 +400,8 @@ async function completeChatContent( method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...providerHeaders() }, body: JSON.stringify(format.body), signal: withTimeout(CHAT_TIMEOUT_MS, signal) diff --git a/src/main/settings.ts b/src/main/settings.ts index c15b271..066644c 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -17,6 +17,7 @@ import { clearImportCookiesFile, getImportCookiesPath } from './cookies' import { DEFAULT_BRAND_COLORS } from '@shared/captionStyles' import { normalizeSizeTargetMb } from '@shared/uploadBudget' import { chatApiBase, configureOpenAiEndpoints } from './pipeline/openai' +import { DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_TRANSCRIPTION_MODEL } from '@shared/openrouter' interface StoredSettings { @@ -28,6 +29,10 @@ interface StoredSettings { /** ISO-639-1 language code forced on Whisper, or 'auto' to auto-detect. */ transcriptionLanguage: string analysisModel: string + /** OpenRouter key, encrypted the same way as `apiKeyEncrypted`. */ + openRouterKeyEncrypted: string + openRouterModel: string + openRouterTranscriptionModel: string openaiBaseUrl: string transcriptionBaseUrl: string encoder: EncoderPreference @@ -66,6 +71,9 @@ const DEFAULTS: StoredSettings = { // theirs — or 'auto' — in Settings. transcriptionLanguage: 'en', analysisModel: 'gpt-5.4-mini', + openRouterKeyEncrypted: '', + openRouterModel: DEFAULT_OPENROUTER_MODEL, + openRouterTranscriptionModel: DEFAULT_OPENROUTER_TRANSCRIPTION_MODEL, openaiBaseUrl: '', transcriptionBaseUrl: '', encoder: 'auto', @@ -86,7 +94,8 @@ function applyEndpoints(s: StoredSettings): void { configureSubscription(s.subscription) configureOpenAiEndpoints({ chatBase: s.openaiBaseUrl, - transcriptionBase: s.transcriptionBaseUrl + transcriptionBase: s.transcriptionBaseUrl, + openRouter: s.subscription.provider === 'openrouter' }) } @@ -162,23 +171,40 @@ export function getApiKey(): string { return stored || envKey || '' } +export function getOpenRouterKey(): string { + return decryptKey(load().openRouterKeyEncrypted) || process.env.OPENROUTER_API_KEY || '' +} + +function maskKey(key: string): string { + return key.length > 8 ? `${key.slice(0, 5)}…${key.slice(-4)}` : key ? '•••' : '' +} + +/** Credential for analysis (and hosted transcription) on the selected provider. */ export function getAnalysisCredential(): string { - return load().subscription.provider === 'chatgpt' ? 'local-codex-subscription' : getApiKey() + const provider = load().subscription.provider + if (provider === 'chatgpt') return 'local-codex-subscription' + if (provider === 'openrouter') return getOpenRouterKey() + return getApiKey() } export async function getSettings(): Promise { const s = load() applyEndpoints(s) const key = getApiKey() + const openRouterKey = getOpenRouterKey() return { setupComplete: s.setupComplete || Boolean(process.env.CUTAWAN_SMOKE && !process.env.CUTAWAN_SMOKE_WIZARD), subscription: { ...s.subscription }, hasApiKey: key.length > 0, - apiKeyMasked: key.length > 8 ? `${key.slice(0, 5)}…${key.slice(-4)}` : key ? '•••' : '', + apiKeyMasked: maskKey(key), keyStorageSecure: safeStorage.isEncryptionAvailable(), transcriptionModel: s.transcriptionModel, transcriptionLanguage: s.transcriptionLanguage, analysisModel: s.analysisModel, + hasOpenRouterKey: openRouterKey.length > 0, + openRouterKeyMasked: maskKey(openRouterKey), + openRouterModel: s.openRouterModel, + openRouterTranscriptionModel: s.openRouterTranscriptionModel, openaiBaseUrl: s.openaiBaseUrl, transcriptionBaseUrl: s.transcriptionBaseUrl, openaiBaseUrlFromEnv: Boolean(process.env.OPENAI_BASE_URL?.trim()), @@ -233,10 +259,11 @@ export function getModelPreferences(): { analysisProviderKey: string } { const s = load() + const openRouter = s.subscription.provider === 'openrouter' return { - transcriptionModel: s.transcriptionModel, + transcriptionModel: openRouter ? s.openRouterTranscriptionModel : s.transcriptionModel, transcriptionLanguage: s.transcriptionLanguage, - analysisModel: s.analysisModel, + analysisModel: openRouter ? s.openRouterModel : s.analysisModel, analysisProviderKey: s.subscription.provider === 'chatgpt' ? `chatgpt:${s.subscription.codexModel}:low` : `api:${chatApiBase()}` @@ -257,6 +284,13 @@ export async function updateSettings(update: SettingsUpdate): Promise => ipcRenderer.invoke('settings:checkSubscription'), checkLocalWhisperSetup: (): Promise<{ message: string }> => ipcRenderer.invoke('settings:checkLocalWhisper'), + listOpenRouterModels: (refresh = false): Promise => ipcRenderer.invoke('openrouter:models', refresh), + checkOpenRouterKey: (key?: string): Promise<{ message: string }> => ipcRenderer.invoke('openrouter:checkKey', key), installLocalWhisper: (model: 'small' | 'large-v3', pythonPath: string): Promise<{ pythonPath: string; modelPath: string }> => ipcRenderer.invoke('settings:installLocalWhisper', model, pythonPath), cancelLocalWhisperInstall: (): Promise => ipcRenderer.invoke('settings:cancelLocalWhisperInstall'), diff --git a/src/renderer/src/components/HomeScreen.tsx b/src/renderer/src/components/HomeScreen.tsx index 05a237f..218267f 100644 --- a/src/renderer/src/components/HomeScreen.tsx +++ b/src/renderer/src/components/HomeScreen.tsx @@ -306,7 +306,8 @@ function SetupPanel(): React.JSX.Element { // need a key; clip finding always does. const needsKey = settings !== null && - !settings.hasApiKey && settings.subscription.provider !== 'chatgpt' && + settings.subscription.provider !== 'chatgpt' && + !(settings.subscription.provider === 'openrouter' ? settings.hasOpenRouterKey : settings.hasApiKey) && !(mode === 'whole-video' && (project.transcript !== null || settings.subscription.localTranscription)) const highlights = highlightClips(project) @@ -545,7 +546,7 @@ function SetupPanel(): React.JSX.Element { onClick={() => setSettingsOpen(true)} className="flex items-center justify-center gap-2 rounded-xl bg-amber-500/15 px-5 py-3.5 text-sm font-semibold text-amber-400 transition hover:bg-amber-500/25" > - Add your OpenAI API key first + {settings?.subscription.provider === 'openrouter' ? 'Add your OpenRouter key first' : 'Add your OpenAI API key first'} ) : (
diff --git a/src/renderer/src/components/ModelPicker.tsx b/src/renderer/src/components/ModelPicker.tsx new file mode 100644 index 0000000..a887c14 --- /dev/null +++ b/src/renderer/src/components/ModelPicker.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Check, ChevronDown, Loader2, Search } from 'lucide-react' +import { + formatContext, + formatModelPrice, + looksLikeModelId, + matchesModelQuery, + type OpenRouterModel, + type SuggestedModel +} from '@shared/openrouter' + +/** A choice outside the model catalogue, e.g. "Local Whisper on this computer". */ +export interface PinnedOption { + id: string + name: string + note: string +} + +interface Row { + id: string + name: string + detail: string | null + badges: string[] +} + +function modelRow(model: OpenRouterModel, note?: string): Row { + const badges = [formatModelPrice(model), formatContext(model.contextLength), model.vision ? 'Vision' : null] + .filter((b): b is string => Boolean(b)) + return { id: model.id, name: model.name, detail: note ?? model.id, badges } +} + +/** + * Searchable dropdown over a model catalogue, with pinned choices (not + * models) and suggested models above the full list. Typing a full model id + * that is not listed offers it as a custom choice. + */ +export default function ModelPicker({ label, value, onChange, models, suggested, pinned = [], loading = false, testId }: { + label: string + value: string + onChange: (id: string) => void + models: OpenRouterModel[] + suggested: SuggestedModel[] + pinned?: PinnedOption[] + loading?: boolean + testId?: string +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [active, setActive] = useState(0) + const root = useRef(null) + const list = useRef(null) + + const byId = useMemo(() => new Map(models.map(m => [m.id, m])), [models]) + const groups = useMemo(() => { + const q = query.trim() + const match = (row: { id: string; name: string }): boolean => !q || matchesModelQuery(row, q) + const pinnedRows: Row[] = pinned.filter(match).map(p => ({ id: p.id, name: p.name, detail: p.note, badges: [] })) + // Suggest only models the catalogue still carries. + const suggestedRows: Row[] = suggested + .filter(s => byId.has(s.id)) + .map(s => modelRow(byId.get(s.id)!, s.note)) + .filter(match) + const suggestedIds = new Set(suggestedRows.map(r => r.id)) + const allRows = models.filter(m => !suggestedIds.has(m.id) && match(m)).map(m => modelRow(m)) + const custom: Row[] = q && looksLikeModelId(q) && !byId.has(q) && !pinned.some(p => p.id === q) + ? [{ id: q, name: `Use “${q}”`, detail: 'Custom model id', badges: [] }] : [] + return [ + { title: 'On this computer', rows: pinnedRows }, + { title: 'Suggested', rows: suggestedRows }, + { title: q ? 'Matching models' : `${suggestedRows.length ? 'More models' : 'All models'} · ${allRows.length}`, rows: allRows }, + { title: 'Custom', rows: custom } + ].filter(g => g.rows.length > 0) + }, [query, pinned, suggested, models, byId]) + const flat = useMemo(() => groups.flatMap(g => g.rows), [groups]) + + useEffect(() => { + if (!open) return + const close = (event: MouseEvent): void => { + if (!root.current?.contains(event.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [open]) + + useEffect(() => { + list.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: 'nearest' }) + }, [active]) + + const choose = (id: string): void => { + onChange(id) + setOpen(false) + setQuery('') + } + const toggle = (): void => { + setOpen(o => !o) + setQuery('') + setActive(0) + } + const onKey = (event: React.KeyboardEvent): void => { + if (event.key === 'ArrowDown') { event.preventDefault(); setActive(i => Math.min(flat.length - 1, i + 1)) } + else if (event.key === 'ArrowUp') { event.preventDefault(); setActive(i => Math.max(0, i - 1)) } + else if (event.key === 'Enter') { event.preventDefault(); if (flat[active]) choose(flat[active].id) } + else if (event.key === 'Escape') { event.preventDefault(); setOpen(false) } + } + + const current = pinned.find(p => p.id === value) + const currentModel = byId.get(value) + const currentName = current?.name ?? currentModel?.name ?? suggested.find(s => s.id === value)?.name ?? value + let index = -1 + + return
+ {label} + + {/* In the flow, not floating: pages and modals scroll it into view instead of clipping it. */} + {open &&
+
+ + { setQuery(e.target.value); setActive(0) }} onKeyDown={onKey} + placeholder="Search models by name or id…" aria-label={`Search ${label}`} data-testid={testId && `${testId}-search`} + className="w-full bg-transparent text-sm text-zinc-100 placeholder:text-zinc-600 focus:outline-none" /> +
+
+ {groups.length === 0 &&

No models match “{query}”.

} + {groups.map(group =>
+

{group.title}

+ {group.rows.map(row => { + index++ + const i = index + const selected = row.id === value + return + })} +
)} +
+
} +
+} diff --git a/src/renderer/src/components/OpenRouterSetup.tsx b/src/renderer/src/components/OpenRouterSetup.tsx new file mode 100644 index 0000000..5c73dd0 --- /dev/null +++ b/src/renderer/src/components/OpenRouterSetup.tsx @@ -0,0 +1,117 @@ +import { useEffect, useState } from 'react' +import { ShieldCheck, TriangleAlert } from 'lucide-react' +import type { SubscriptionSettings } from '@shared/subscription' +import { + OPENROUTER_KEYS_URL, + SUGGESTED_OPENROUTER_MODELS, + SUGGESTED_OPENROUTER_TRANSCRIPTION_MODELS, + type OpenRouterCatalog +} from '@shared/openrouter' +import { useStore } from '../store' +import LocalWhisperSetup from './LocalWhisperSetup' +import ModelPicker, { type PinnedOption } from './ModelPicker' + +const LOCAL_WHISPER = 'local-whisper' +const LOCAL_OPTION: PinnedOption = { + id: LOCAL_WHISPER, + name: 'Local Whisper (on this computer)', + note: 'Free and private · needs Python and a one-time model download' +} + +export interface OpenRouterChoice { + apiKey: string + model: string + transcriptionModel: string +} + +/** + * OpenRouter key, analysis model and transcription choice. Transcription is + * either an OpenRouter-hosted model or local Whisper (the shared + * `localTranscription` preference). + */ +export default function OpenRouterSetup({ value, onChange, subscription, onSubscriptionChange }: { + value: OpenRouterChoice + onChange: (patch: Partial) => void + subscription: SubscriptionSettings + onSubscriptionChange: (patch: Partial) => void +}): React.JSX.Element { + const settings = useStore(s => s.settings) + const [catalog, setCatalog] = useState(null) + const [checking, setChecking] = useState(false) + const [keyStatus, setKeyStatus] = useState<{ ok: boolean; message: string } | null>(null) + + useEffect(() => { + let active = true + void window.cutawan.listOpenRouterModels().then(c => { if (active) setCatalog(c) }) + return () => { active = false } + }, []) + + const checkKey = async (): Promise => { + setChecking(true) + setKeyStatus(null) + try { + const result = await window.cutawan.checkOpenRouterKey(value.apiKey.trim() || undefined) + setKeyStatus({ ok: true, message: result.message }) + } catch (error) { + setKeyStatus({ ok: false, message: (error instanceof Error ? error.message : String(error)).replace(/^Error invoking remote method '[^']+': (Error: )?/, '') }) + } finally { setChecking(false) } + } + + const inputClass = 'mt-1 w-full rounded-lg border border-surface-600 bg-surface-850 px-3 py-2 text-sm' + const transcription = subscription.localTranscription ? LOCAL_WHISPER : value.transcriptionModel + + return
+
+ +
+ { onChange({ apiKey: e.target.value }); setKeyStatus(null) }} + placeholder={settings?.hasOpenRouterKey ? `Current: ${settings.openRouterKeyMasked}` : 'sk-or-v1-…'} + className="w-full rounded-lg border border-surface-600 bg-surface-850 px-3 py-2 text-sm" /> + +
+
+ Create an OpenRouter key ↗ + {settings?.keyStorageSecure !== false + ? Encrypted with your system keychain + : No keychain: stored obfuscated only} +
+ {keyStatus &&

{keyStatus.message}

} +

+ The key is sent only to openrouter.ai. OpenRouter bills usage to your OpenRouter credits. +

+
+ + onChange({ model })} models={catalog?.llm ?? []} suggested={SUGGESTED_OPENROUTER_MODELS} loading={!catalog} /> +

+ Models tagged Vision can also judge sampled frames for visual moments and framing. +

+ + { + if (id === LOCAL_WHISPER) onSubscriptionChange({ localTranscription: true }) + else { onSubscriptionChange({ localTranscription: false }); onChange({ transcriptionModel: id }) } + }} + models={catalog?.transcription ?? []} suggested={SUGGESTED_OPENROUTER_TRANSCRIPTION_MODELS} pinned={[LOCAL_OPTION]} loading={!catalog} /> +

+ Captions need word timestamps; Whisper models provide them. Other models fall back to estimated word timing. +

+ + {catalog && !catalog.live &&

+ Couldn’t load OpenRouter’s model list{catalog.error ? ` (${catalog.error})` : ''}. Showing suggested models; you can also type any model id. +

} + + {subscription.localTranscription &&
+ + onSubscriptionChange({ pythonPath, whisperModelPath })} /> + +
} +
+} diff --git a/src/renderer/src/components/ProcessingScreen.tsx b/src/renderer/src/components/ProcessingScreen.tsx index 0075890..88eed81 100644 --- a/src/renderer/src/components/ProcessingScreen.tsx +++ b/src/renderer/src/components/ProcessingScreen.tsx @@ -114,7 +114,9 @@ export default function ProcessingScreen(): React.JSX.Element { <> {settings?.subscription.provider === 'chatgpt' || settings?.subscription.localTranscription ? 'Speech is transcribed locally. ' - : 'Only the audio leaves your machine, for Whisper transcription. '}Long videos are + : settings?.subscription.provider === 'openrouter' + ? 'Only the audio leaves your machine, for transcription through OpenRouter. ' + : 'Only the audio leaves your machine, for Whisper transcription. '}Long videos are transcribed in chunks and the transcript is saved as soon as it lands, so redoing this skips straight past it. {followSpeaker && ' Speaker tracking runs locally and is the slow part.'} @@ -125,7 +127,9 @@ export default function ProcessingScreen(): React.JSX.Element { ? 'Speech is transcribed locally; transcript text and sampled frames go to Codex for analysis. ' : settings?.subscription.localTranscription ? 'Speech is transcribed locally; analysis uses your configured API. ' - : 'Transcription and analysis use your configured API. '}Long videos are transcribed in + : settings?.subscription.provider === 'openrouter' + ? 'Transcription and analysis go through OpenRouter. ' + : 'Transcription and analysis use your configured API. '}Long videos are transcribed in chunks. Local speech recognition can take longer on a CPU. The transcript is saved as soon as it completes, so retries and regenerations skip straight to analysis. diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index 8c14d2c..4eaaa06 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -1,4 +1,6 @@ import SubscriptionSettings from './SubscriptionSettings' +import OpenRouterSetup, { type OpenRouterChoice } from './OpenRouterSetup' +import { DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_TRANSCRIPTION_MODEL } from '@shared/openrouter' import { DEFAULT_SUBSCRIPTION } from '@shared/subscription' import { useEffect, useState } from 'react' import { @@ -95,6 +97,11 @@ export default function SettingsModal(): React.JSX.Element { const [subscription, setSubscription] = useState(settings?.subscription ?? DEFAULT_SUBSCRIPTION) const [apiKey, setApiKey] = useState('') const [model, setModel] = useState(settings?.analysisModel ?? 'gpt-5.4-mini') + const [openRouter, setOpenRouter] = useState({ + apiKey: '', + model: settings?.openRouterModel ?? DEFAULT_OPENROUTER_MODEL, + transcriptionModel: settings?.openRouterTranscriptionModel ?? DEFAULT_OPENROUTER_TRANSCRIPTION_MODEL + }) const [openaiBaseUrl, setOpenaiBaseUrl] = useState(settings?.openaiBaseUrl ?? '') const [transcriptionBaseUrl, setTranscriptionBaseUrl] = useState( settings?.transcriptionBaseUrl ?? '' @@ -115,9 +122,13 @@ export default function SettingsModal(): React.JSX.Element { subscription, analysisModel: model, openaiBaseUrl, - transcriptionBaseUrl + transcriptionBaseUrl, + openRouterModel: openRouter.model, + openRouterTranscriptionModel: openRouter.transcriptionModel, + ...(openRouter.apiKey.trim() ? { openRouterKey: openRouter.apiKey.trim() } : {}) }) setApiKey('') + setOpenRouter(current => ({ ...current, apiKey: '' })) setSaved(true) setTimeout(() => setSaved(false), 1500) } finally { @@ -179,6 +190,10 @@ export default function SettingsModal(): React.JSX.Element { {section === 'general' && (
+ {subscription.provider === 'openrouter' &&
+ setOpenRouter(current => ({ ...current, ...patch }))} + subscription={subscription} onSubscriptionChange={patch => setSubscription(current => ({ ...current, ...patch }))} /> +
} {subscription.provider === 'api' && <>
@@ -188,19 +203,22 @@ export default function SetupWizard(): React.JSX.Element { {verified &&

Ready to caption videos locally.

}
} - {route === 'claude' &&
-

Claude subscriptions cannot be connected here

-

- Anthropic directs developers of third-party apps, including open-source apps, to use API-key authentication. - Claude’s web or desktop login is not an API credential, and Cutawan will not route its automated requests through a personal subscription. -

- Anthropic’s guidance ↗ + {route === 'openrouter' &&
+
+

OpenRouter connection

+

+ Pick the model that finds your clips and how speech gets transcribed. You can change both later in Settings. +

+
+ { setOpenRouter(current => ({ ...current, ...patch })); setMessage('') }} + subscription={subscription} onSubscriptionChange={updateSubscription} />
} {message &&

{message}

}
-
diff --git a/src/renderer/src/components/SubscriptionSettings.tsx b/src/renderer/src/components/SubscriptionSettings.tsx index 144434d..fd5fb50 100644 --- a/src/renderer/src/components/SubscriptionSettings.tsx +++ b/src/renderer/src/components/SubscriptionSettings.tsx @@ -36,6 +36,7 @@ export default function SubscriptionSettings({ value, onChange, onSave }: { {value.provider === 'api' && <> diff --git a/src/renderer/src/components/TopBar.tsx b/src/renderer/src/components/TopBar.tsx index 2f25c00..a190225 100644 --- a/src/renderer/src/components/TopBar.tsx +++ b/src/renderer/src/components/TopBar.tsx @@ -81,6 +81,14 @@ export default function TopBar(): React.JSX.Element { : download.status === 'error' ? 'Update needs attention' : 'Update available'} )} + {settings && settings.subscription.provider === 'openrouter' && !settings.hasOpenRouterKey && ( + + )} {settings && settings.subscription.provider === 'api' && !settings.hasApiKey && ( )} {settings && settings.subscription.provider === 'api' && !settings.hasApiKey && ( diff --git a/tests/openrouter.test.ts b/tests/openrouter.test.ts index 2961801..56d5d5a 100644 --- a/tests/openrouter.test.ts +++ b/tests/openrouter.test.ts @@ -174,6 +174,31 @@ describe('OpenRouter transcription requests', () => { expect(body).not.toHaveProperty('language') }) + it('does not retry (and re-bill) a reply without word timestamps', async () => { + dir = await mkdtemp(join(tmpdir(), 'cutawan-or-')) + const file = join(dir, 'audio-0.mp3') + await writeFile(file, Buffer.from('mp3')) + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ language: 'en', duration: 2, text: 'hello world' }), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + configureOpenAiEndpoints({ openRouter: true }) + await expect(transcribeAudioFile('k', file, 'openai/whisper-1')).rejects.toThrow(/no word timestamps/) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('refuses to send a key from another route after a provider switch mid-job', async () => { + dir = await mkdtemp(join(tmpdir(), 'cutawan-or-')) + const file = join(dir, 'audio-0.mp3') + await writeFile(file, Buffer.from('mp3')) + const fetchMock = vi.fn(async () => new Response(JSON.stringify(reply), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + // The job read the OpenAI key, then Settings switched to OpenRouter. + configureOpenAiEndpoints({ openRouter: true, credential: 'sk-or-current' }) + await expect(transcribeAudioFile('sk-openai-old', file, 'openai/whisper-1')).rejects.toThrow(/connection changed/) + expect(fetchMock).not.toHaveBeenCalled() + await transcribeAudioFile('sk-or-current', file, 'openai/whisper-1') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + it('uses short chunks for OpenRouter to stay inside its 60 s upstream timeout', () => { expect(transcriptionChunkSec(1200)).toBe(1200) configureOpenAiEndpoints({ openRouter: true }) From fbb4cd321315c1f16369664bc77ad342bf488160 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 10:48:33 +0000 Subject: [PATCH 5/5] Check the route's key live, and keep the guard on when it has none The key/route guard captured the credential at configure time and turned itself off when it was empty, so switching mid-job to a provider with no stored key still sent the job's key there (Bugbot). It now reads the route's current credential on each request, and an empty one matches no key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012fRPuZ4E7P2EcTR4c9BzhS --- src/main/pipeline/openai.ts | 23 ++++++++++++----------- src/main/settings.ts | 5 +---- tests/openrouter.test.ts | 13 ++++++++++++- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/main/pipeline/openai.ts b/src/main/pipeline/openai.ts index 4c46b67..f94069f 100644 --- a/src/main/pipeline/openai.ts +++ b/src/main/pipeline/openai.ts @@ -1,7 +1,6 @@ import { usesSubscription, usesLocalTranscription, subscriptionJSON, transcribeLocally } from '../subscription' import { readFile } from 'node:fs/promises' import { basename } from 'node:path' -import { createHash } from 'node:crypto' import { setTimeout as sleep } from 'node:timers/promises' import { analysisRequests } from './mediaJobs' import { OPENROUTER_API_BASE } from '@shared/openrouter' @@ -61,9 +60,7 @@ export function resolveOpenAiApiBase( let settingsChatBase: string | undefined let settingsTranscriptionBase: string | undefined let openRouter = false -let routeCredentialHash: string | undefined - -const hashKey = (key: string): string => createHash('sha256').update(key).digest('hex') +let routeCredential: (() => string) | undefined export function configureOpenAiEndpoints(opts: { chatBase?: string @@ -75,21 +72,25 @@ export function configureOpenAiEndpoints(opts: { */ openRouter?: boolean /** - * The credential the current route uses. Jobs read their key once at the - * start while the endpoint is read per request, so a provider switch during - * a job would otherwise send one vendor's key to the other's endpoint. + * Reads the credential the current route uses, at request time. Jobs read + * their key once at the start while the endpoint is read per request, so a + * provider switch during a job would otherwise send one vendor's key to the + * other's endpoint. Omitted (scripts, tests) means no check. */ - credential?: string + credential?: () => string }): void { settingsChatBase = opts.chatBase?.trim() || undefined settingsTranscriptionBase = opts.transcriptionBase?.trim() || undefined openRouter = opts.openRouter === true - routeCredentialHash = opts.credential ? hashKey(opts.credential) : undefined + routeCredential = opts.credential } -/** Refuse to send a key the current route did not issue (see `credential` above). */ +/** + * Refuse to send a key the current route did not issue (see `credential` + * above). A route with no key of its own matches no key. + */ function assertKeyMatchesRoute(apiKey: string): void { - if (routeCredentialHash !== undefined && hashKey(apiKey) !== routeCredentialHash) { + if (routeCredential && apiKey !== routeCredential()) { throw new OpenAIError('The AI connection changed in Settings while this was running. Start it again.', 409) } } diff --git a/src/main/settings.ts b/src/main/settings.ts index 7980822..ce86b55 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -96,10 +96,7 @@ function applyEndpoints(s: StoredSettings): void { chatBase: s.openaiBaseUrl, transcriptionBase: s.transcriptionBaseUrl, openRouter: s.subscription.provider === 'openrouter', - credential: s.subscription.provider === 'chatgpt' ? undefined - : s.subscription.provider === 'openrouter' - ? decryptKey(s.openRouterKeyEncrypted) || process.env.OPENROUTER_API_KEY || '' - : decryptKey(s.apiKeyEncrypted) || process.env.OPENAI_API_KEY || '' + credential: getAnalysisCredential }) } diff --git a/tests/openrouter.test.ts b/tests/openrouter.test.ts index 56d5d5a..112ba82 100644 --- a/tests/openrouter.test.ts +++ b/tests/openrouter.test.ts @@ -192,13 +192,24 @@ describe('OpenRouter transcription requests', () => { const fetchMock = vi.fn(async () => new Response(JSON.stringify(reply), { status: 200 })) vi.stubGlobal('fetch', fetchMock) // The job read the OpenAI key, then Settings switched to OpenRouter. - configureOpenAiEndpoints({ openRouter: true, credential: 'sk-or-current' }) + configureOpenAiEndpoints({ openRouter: true, credential: () => 'sk-or-current' }) await expect(transcribeAudioFile('sk-openai-old', file, 'openai/whisper-1')).rejects.toThrow(/connection changed/) expect(fetchMock).not.toHaveBeenCalled() await transcribeAudioFile('sk-or-current', file, 'openai/whisper-1') expect(fetchMock).toHaveBeenCalledTimes(1) }) + it('also refuses when the new route has no key stored', async () => { + dir = await mkdtemp(join(tmpdir(), 'cutawan-or-')) + const file = join(dir, 'audio-0.mp3') + await writeFile(file, Buffer.from('mp3')) + const fetchMock = vi.fn(async () => new Response(JSON.stringify(reply), { status: 200 })) + vi.stubGlobal('fetch', fetchMock) + configureOpenAiEndpoints({ openRouter: true, credential: () => '' }) + await expect(transcribeAudioFile('sk-openai-old', file, 'openai/whisper-1')).rejects.toThrow(/connection changed/) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('uses short chunks for OpenRouter to stay inside its 60 s upstream timeout', () => { expect(transcriptionChunkSec(1200)).toBe(1200) configureOpenAiEndpoints({ openRouter: true })