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..cefab17 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 local Whisper on your computer or one of OpenRouter's Whisper models (Whisper, Whisper Large v3, Whisper Large v3 Turbo). These are the only OpenRouter transcription models that return the per-word timestamps captions need, so other transcription models aren't offered. **Check key** validates the key without making a model request. +- OpenRouter transcription sends five-minute audio chunks so each request finishes inside OpenRouter's 60-second limit. A reply without word timestamps fails with a message saying what to change, rather than producing captions that can't be timed. + +### Improved + +- 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..d5057a1 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' @@ -38,6 +39,7 @@ import { getExportPreferences, getModelPreferences, getSettings, + missingCredentialName, updateSettings } from './settings' @@ -332,7 +334,7 @@ export function registerIpcHandlers(): void { const clip = project.clips.find((c) => c.id === clipId) if (!clip) throw new Error('Clip not found') const apiKey = getAnalysisCredential() - if (!apiKey) throw new Error('Add your OpenAI API key in Settings first.') + if (!apiKey) throw new Error(`Add your ${missingCredentialName()} in Settings first.`) const caption = await generateSocialCaption( apiKey, getModelPreferences().analysisModel, @@ -363,6 +365,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..8e27e46 --- /dev/null +++ b/src/main/openrouter.ts @@ -0,0 +1,94 @@ +import { readFile } from 'node:fs/promises' +import { + OPENROUTER_API_BASE, + parseOpenRouterModels, + suggestedAsModels, + SUGGESTED_OPENROUTER_MODELS, + OPENROUTER_TRANSCRIPTION_MODELS, + isSupportedTranscriptionModel, + 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 that + * Cutawan can use (word timestamps required). 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') + // Only models that return word timestamps; see OPENROUTER_TRANSCRIPTION_MODELS. + // A short or failed transcription listing keeps the whole allowlist. + const supported = transcription.filter(m => isSupportedTranscriptionModel(m.id)) + const catalog: OpenRouterCatalog = { + llm, + transcription: supported.length ? supported : suggestedAsModels(OPENROUTER_TRANSCRIPTION_MODELS), + live: true + } + cached = { at: Date.now(), catalog } + return catalog + } catch (error) { + return { + llm: suggestedAsModels(SUGGESTED_OPENROUTER_MODELS), + transcription: suggestedAsModels(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/ffmpeg.ts b/src/main/pipeline/ffmpeg.ts index 00936f1..306e063 100644 --- a/src/main/pipeline/ffmpeg.ts +++ b/src/main/pipeline/ffmpeg.ts @@ -220,7 +220,8 @@ export interface AudioChunk { * so 20-minute chunks (~7.2 MB) leave plenty of headroom. Consecutive chunks * overlap by a few seconds so no word is cut in half at a boundary — Whisper * sees full context on both sides and the stitcher picks each word from the - * chunk that owns its timestamp. + * chunk that owns its timestamp. Routes with a short request timeout pass a + * smaller `chunkSec`. */ export const AUDIO_CHUNK_SEC = 20 * 60 export const AUDIO_CHUNK_OVERLAP_SEC = 8 @@ -230,19 +231,20 @@ export async function extractAudioChunks( workDir: string, durationSec: number, onProgress?: (fraction: number) => void, - signal?: AbortSignal + signal?: AbortSignal, + chunkSec = AUDIO_CHUNK_SEC ): Promise { await mkdir(workDir, { recursive: true }) - const stride = AUDIO_CHUNK_SEC - AUDIO_CHUNK_OVERLAP_SEC + const stride = chunkSec - AUDIO_CHUNK_OVERLAP_SEC const count = - durationSec <= AUDIO_CHUNK_SEC ? 1 : 1 + Math.ceil((durationSec - AUDIO_CHUNK_SEC) / stride) + durationSec <= chunkSec ? 1 : 1 + Math.ceil((durationSec - chunkSec) / stride) const chunks: AudioChunk[] = [] for (let i = 0; i < count; i++) { const offset = i * stride const out = join(workDir, `audio-${i}.mp3`) const args = [ '-ss', String(offset), - '-t', String(AUDIO_CHUNK_SEC), + '-t', String(chunkSec), '-i', videoPath, '-vn', '-ac', '1', @@ -250,7 +252,7 @@ export async function extractAudioChunks( '-b:a', '48k', out ] - const chunkDur = Math.min(AUDIO_CHUNK_SEC, durationSec - offset) + const chunkDur = Math.min(chunkSec, durationSec - offset) await runFfmpeg(args, { onProgress: (t) => onProgress?.(Math.min(1, (offset + Math.min(t, chunkDur)) / durationSec)), signal @@ -260,7 +262,7 @@ export async function extractAudioChunks( offsetSec: offset, keepFromSec: i === 0 ? 0 : offset + AUDIO_CHUNK_OVERLAP_SEC / 2, keepToSec: - i === count - 1 ? Number.POSITIVE_INFINITY : offset + AUDIO_CHUNK_SEC - AUDIO_CHUNK_OVERLAP_SEC / 2 + i === count - 1 ? Number.POSITIVE_INFINITY : offset + chunkSec - AUDIO_CHUNK_OVERLAP_SEC / 2 }) } onProgress?.(1) diff --git a/src/main/pipeline/index.ts b/src/main/pipeline/index.ts index b90d14d..dfc6111 100644 --- a/src/main/pipeline/index.ts +++ b/src/main/pipeline/index.ts @@ -29,7 +29,7 @@ import { YtDlpError, type CookieAuthOptions } from './ytdlp' -import { getAnalysisCredential, getImportPreferences, getModelPreferences } from '../settings' +import { getAnalysisCredential, getImportPreferences, getModelPreferences, missingCredentialName } from '../settings' import { projectDir, saveProject, updateProject } from '../projects' export async function createProject(videoPath: string): Promise { @@ -142,7 +142,7 @@ export async function analyzeProject( ): Promise { const apiKey = getAnalysisCredential() if (!apiKey) { - throw new Error('No API key configured. Add one in Settings before generating clips.') + throw new Error(`No ${missingCredentialName()} configured. Add one in Settings before generating clips.`) } const settings = getModelPreferences() const generationId = randomUUID() diff --git a/src/main/pipeline/openai.ts b/src/main/pipeline/openai.ts index 281d6cd..f94069f 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,45 @@ export function resolveOpenAiApiBase( */ let settingsChatBase: string | undefined let settingsTranscriptionBase: string | undefined +let openRouter = false +let routeCredential: (() => string) | undefined 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 + /** + * 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 }): void { settingsChatBase = opts.chatBase?.trim() || undefined settingsTranscriptionBase = opts.transcriptionBase?.trim() || undefined + openRouter = opts.openRouter === true + routeCredential = opts.credential +} + +/** + * 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 (routeCredential && apiKey !== routeCredential()) { + throw new OpenAIError('The AI connection changed in Settings while this was running. Start it again.', 409) + } } /** 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 +107,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 +116,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, @@ -218,6 +253,7 @@ export async function transcribeAudioFile( opts: TranscribeFileOptions = {} ): Promise { if (usesLocalTranscription()) return transcribeLocally(filePath, opts) + if (openRouter) return transcribeWithOpenRouter(apiKey, filePath, model, opts) const bytes = await readFile(filePath) return withRetries( async () => { @@ -230,6 +266,7 @@ export async function transcribeAudioFile( if (opts.contextPrompt) form.append('prompt', opts.contextPrompt) if (opts.language && opts.language !== 'auto') form.append('language', opts.language) + assertKeyMatchesRoute(apiKey) const res = await fetch(`${transcriptionApiBase()}/audio/transcriptions`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}` }, @@ -243,6 +280,69 @@ export async function transcribeAudioFile( ) } +/** + * OpenRouter's documented JSON form: base64 audio plus the fields its + * transcription schema lists. It has no top-level `prompt` (that is a + * per-provider option), so chunks are transcribed without the previous + * chunk's text; the overlap and seam repair still cover the joins. + */ +async function transcribeWithOpenRouter( + apiKey: string, + filePath: string, + model: string, + opts: TranscribeFileOptions +): Promise { + const body = JSON.stringify({ + model, + input_audio: { + data: (await readFile(filePath)).toString('base64'), + format: filePath.toLowerCase().endsWith('.wav') ? 'wav' : 'mp3' + }, + response_format: 'verbose_json', + timestamp_granularities: ['word', 'segment'], + ...(opts.language && opts.language !== 'auto' ? { language: opts.language } : {}) + }) + const response = await withRetries( + async () => { + assertKeyMatchesRoute(apiKey) + const res = await fetch(`${OPENROUTER_API_BASE}/audio/transcriptions`, { + method: 'POST', + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', ...providerHeaders() }, + body, + signal: withTimeout(TRANSCRIBE_TIMEOUT_MS, opts.signal) + }) + await raiseForStatus(res, 'Transcription') + return (await res.json()) as WhisperResponse + }, + { signal: opts.signal } + ) + // Outside the retry loop: a model that ignores the timestamp request does + // so every time, and each retry would be billed. + return requireWordTimings(response, model) +} + +/** + * Captions, cut tightening and clip timing need per-word timestamps. A reply + * with speech but no words (a provider that ignored the timestamp request) + * fails with a fix rather than producing a transcript that cannot be timed. + */ +export function requireWordTimings(res: WhisperResponse, model: string): WhisperResponse { + if (res.words?.length || !res.text?.trim()) return res + throw new OpenAIError( + `${model} returned no word timestamps. Choose another Whisper model or local transcription in Settings.` + ) +} + +/** + * Audio chunk length for the active transcription route. OpenRouter stops + * waiting on its upstream after 60 seconds, so its chunks are short enough to + * transcribe well inside that; direct APIs keep the long default. + */ +export const OPENROUTER_AUDIO_CHUNK_SEC = 5 * 60 +export function transcriptionChunkSec(defaultSec: number): number { + return openRouter && !usesLocalTranscription() ? OPENROUTER_AUDIO_CHUNK_SEC : defaultSec +} + export type ChatContentPart = | { type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string; detail?: 'low' | 'high' | 'auto' } } @@ -352,11 +452,13 @@ async function completeChatContent( const format = formats[i] const isLast = i === formats.length - 1 try { + assertKeyMatchesRoute(apiKey) const res = await fetch(`${chatApiBase()}/chat/completions`, { 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/pipeline/projectTranscript.ts b/src/main/pipeline/projectTranscript.ts index 76b2365..c151b1e 100644 --- a/src/main/pipeline/projectTranscript.ts +++ b/src/main/pipeline/projectTranscript.ts @@ -1,5 +1,6 @@ import type { PipelineProgress, Project, Transcript } from '@shared/types' -import { extractAudioChunks } from './ffmpeg' +import { AUDIO_CHUNK_SEC, extractAudioChunks } from './ffmpeg' +import { transcriptionChunkSec } from './openai' import { transcribeChunks } from './transcribe' import { annotateEnergy } from './energy' import { detectSpeech, vadAvailable } from './vad' @@ -81,7 +82,8 @@ export async function ensureTranscript( project.video.durationSec, (f) => onProgress({ stage: 'audio', progress: at(f * AUDIO_SHARE), message: 'Extracting audio…' }), - signal + signal, + transcriptionChunkSec(AUDIO_CHUNK_SEC) ) onProgress({ diff --git a/src/main/settings.ts b/src/main/settings.ts index c15b271..ce86b55 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, isSupportedTranscriptionModel } 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,9 @@ function applyEndpoints(s: StoredSettings): void { configureSubscription(s.subscription) configureOpenAiEndpoints({ chatBase: s.openaiBaseUrl, - transcriptionBase: s.transcriptionBaseUrl + transcriptionBase: s.transcriptionBaseUrl, + openRouter: s.subscription.provider === 'openrouter', + credential: getAnalysisCredential }) } @@ -95,6 +105,12 @@ function storedBaseUrl(raw: unknown): string { return raw.trim().replace(/\/$/, '') } +/** Only allowlisted OpenRouter transcription models; anything else reverts to the default. */ +function supportedTranscriptionModel(raw: unknown): string { + return typeof raw === 'string' && isSupportedTranscriptionModel(raw.trim()) + ? raw.trim() : DEFAULT_OPENROUTER_TRANSCRIPTION_MODEL +} + function load(): StoredSettings { if (cache) return cache try { @@ -108,6 +124,7 @@ function load(): StoredSettings { subscription: normalizeSubscription(parsed.subscription), openaiBaseUrl: storedBaseUrl(parsed.openaiBaseUrl), transcriptionBaseUrl: storedBaseUrl(parsed.transcriptionBaseUrl), + openRouterTranscriptionModel: supportedTranscriptionModel(parsed.openRouterTranscriptionModel), // Nested objects: merge so settings saved before new fields stay valid. sizeTargetMb: normalizeSizeTargetMb(parsed.sizeTargetMb), branding: { @@ -162,23 +179,45 @@ 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 ? '•••' : '' +} + +/** What to ask for when the selected provider has no credential. */ +export function missingCredentialName(): string { + return load().subscription.provider === 'openrouter' ? 'OpenRouter key' : 'OpenAI API 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 +272,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 +297,16 @@ 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..06c195c --- /dev/null +++ b/src/renderer/src/components/ModelPicker.tsx @@ -0,0 +1,165 @@ +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, unless `allowCustom` is + * off (a closed list, such as the transcription models Cutawan supports). + */ +export default function ModelPicker({ + label, value, onChange, models, suggested, pinned = [], loading = false, testId, + allowCustom = true, suggestedTitle = 'Suggested' +}: { + label: string + value: string + onChange: (id: string) => void + models: OpenRouterModel[] + suggested: SuggestedModel[] + pinned?: PinnedOption[] + loading?: boolean + testId?: string + allowCustom?: boolean + suggestedTitle?: string +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + // Tracked by id, not index, so rows arriving while the list is open (the + // catalogue loading) cannot move the highlight onto a different model. + const [activeId, setActiveId] = useState(null) + 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[] = allowCustom && 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: suggestedTitle, 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, allowCustom, suggestedTitle]) + const flat = useMemo(() => groups.flatMap(g => g.rows), [groups]) + const active = Math.max(0, flat.findIndex(row => row.id === activeId)) + const setActive = (index: number): void => setActiveId(flat[index]?.id ?? null) + + 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('') + setActiveId(null) + } + const onKey = (event: React.KeyboardEvent): void => { + if (event.key === 'ArrowDown') { event.preventDefault(); setActive(Math.min(flat.length - 1, active + 1)) } + else if (event.key === 'ArrowUp') { event.preventDefault(); setActive(Math.max(0, active - 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); setActiveId(null) }} 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..9545595 --- /dev/null +++ b/src/renderer/src/components/OpenRouterSetup.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from 'react' +import { ShieldCheck, TriangleAlert } from 'lucide-react' +import type { SubscriptionSettings } from '@shared/subscription' +import { + OPENROUTER_KEYS_URL, + SUGGESTED_OPENROUTER_MODELS, + suggestedAsModels, + 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 + window.cutawan.listOpenRouterModels() + .then(c => { if (active) setCatalog(c) }) + .catch((error: unknown) => { + if (active) setCatalog({ + llm: suggestedAsModels(SUGGESTED_OPENROUTER_MODELS), + transcription: suggestedAsModels(OPENROUTER_TRANSCRIPTION_MODELS), + live: false, + error: error instanceof Error ? error.message : String(error) + }) + }) + 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={OPENROUTER_TRANSCRIPTION_MODELS} pinned={[LOCAL_OPTION]} loading={!catalog} + allowCustom={false} suggestedTitle="Via OpenRouter · word timestamps" /> +

+ Captions need a timestamp for every word. On OpenRouter only the Whisper models return them, so other transcription models aren’t offered. +

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

+ Couldn’t load OpenRouter’s model list{catalog.error ? ` (${catalog.error})` : ''}. Showing suggested models; you can also type any clip-finding 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..a8e06b8 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 && (