diff --git a/frontend/src-tauri/migrations/20260330000000_add_endpoint_url_to_transcript_settings.sql b/frontend/src-tauri/migrations/20260330000000_add_endpoint_url_to_transcript_settings.sql new file mode 100644 index 0000000000..1ba186fbb7 --- /dev/null +++ b/frontend/src-tauri/migrations/20260330000000_add_endpoint_url_to_transcript_settings.sql @@ -0,0 +1,50 @@ +-- Add endpointUrl column to transcript_settings table. +-- For 'remote' provider, the model column currently stores the endpoint URL. +-- This migration adds a dedicated endpointUrl column and copies the URL there, +-- while keeping the URL in model as a backward-compatible fallback. +-- +-- TWO-PHASE DEPLOYMENT: +-- Phase 1 (this migration): URL is stored in BOTH model and endpointUrl for remote rows. +-- The Rust code reads endpointUrl first and falls back to model. This ensures that if +-- anything goes wrong, the app still works using the old model column. +-- Phase 2 (future, optional): A follow-up migration can clear the URL from model for +-- remote rows (e.g. SET model = '' WHERE provider = 'remote') once Phase 1 is stable. +-- This is safe to skip -- the fallback in engine.rs handles both states. + +PRAGMA foreign_keys=off; + +CREATE TABLE IF NOT EXISTS transcript_settings_new ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + model TEXT NOT NULL, + endpointUrl TEXT, + whisperApiKey TEXT, + deepgramApiKey TEXT, + elevenLabsApiKey TEXT, + groqApiKey TEXT, + openaiApiKey TEXT, + runpodApiKey TEXT +); + +-- Plain INSERT (no OR IGNORE) -- the target table is empty so no PK conflicts. +-- For remote provider: copy URL to endpointUrl AND keep it in model (two-phase safety). +-- For other providers: copy as-is, endpointUrl stays NULL. +INSERT INTO transcript_settings_new (id, provider, model, endpointUrl, whisperApiKey, deepgramApiKey, elevenLabsApiKey, groqApiKey, openaiApiKey, runpodApiKey) +SELECT + id, + provider, + model, + CASE WHEN provider = 'remote' THEN model ELSE NULL END, + whisperApiKey, + deepgramApiKey, + elevenLabsApiKey, + groqApiKey, + openaiApiKey, + runpodApiKey +FROM transcript_settings; + +DROP TABLE transcript_settings; + +ALTER TABLE transcript_settings_new RENAME TO transcript_settings; + +PRAGMA foreign_keys=on; diff --git a/frontend/src-tauri/src/api/api.rs b/frontend/src-tauri/src/api/api.rs index 8ad080e411..eb3b822864 100644 --- a/frontend/src-tauri/src/api/api.rs +++ b/frontend/src-tauri/src/api/api.rs @@ -99,14 +99,44 @@ pub struct GetApiKeyRequest { pub struct TranscriptConfig { pub provider: String, pub model: String, + #[serde(rename = "endpointUrl")] + pub endpoint_url: Option, #[serde(rename = "apiKey")] pub api_key: Option, } +impl TranscriptConfig { + /// Resolve the remote endpoint URL and model name from the config. + /// + /// Two-phase deployment: `endpoint_url` is the canonical URL location. + /// Falls back to `model` for users who upgraded but still have the URL in `model` + /// (from before the endpointUrl migration). This fallback can be removed once + /// a Phase 2 migration clears `model` for remote rows. + /// See: migrations/20260330000000_add_endpoint_url_to_transcript_settings.sql + pub fn resolve_remote_params(&self) -> (String, String) { + let has_endpoint_url = self.endpoint_url.as_ref() + .filter(|u| !u.is_empty()) + .is_some(); + let url = self.endpoint_url.clone() + .filter(|u| !u.is_empty()) + .unwrap_or_else(|| self.model.clone()); + let model_name = if has_endpoint_url { + let m = self.model.clone(); + // Don't send the URL as the model name (migrated data has URL in both columns) + if m == url { String::new() } else { m } + } else { + String::new() + }; + (url, model_name) + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct SaveTranscriptConfigRequest { pub provider: String, pub model: String, + #[serde(rename = "endpointUrl")] + pub endpoint_url: Option, #[serde(rename = "apiKey")] pub api_key: Option, } @@ -622,6 +652,7 @@ pub async fn api_get_transcript_config( Ok(Some(TranscriptConfig { provider: config.provider, model: config.model, + endpoint_url: config.endpoint_url, api_key, })) } @@ -640,6 +671,7 @@ pub async fn api_get_transcript_config( Ok(Some(TranscriptConfig { provider: "parakeet".to_string(), model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(), + endpoint_url: None, api_key: None, })) } @@ -656,6 +688,7 @@ pub async fn api_save_transcript_config( state: tauri::State<'_, AppState>, provider: String, model: String, + endpoint_url: Option, api_key: Option, _auth_token: Option, ) -> Result { @@ -665,7 +698,14 @@ pub async fn api_save_transcript_config( ); let pool = state.db_manager.pool(); - if let Err(e) = SettingsRepository::save_transcript_config(pool, &provider, &model).await { + if let Err(e) = SettingsRepository::save_transcript_config( + pool, + &provider, + &model, + endpoint_url.as_deref(), + ) + .await + { log_error!("Failed to save transcript config: {}", e); return Err(e.to_string()); } diff --git a/frontend/src-tauri/src/audio/transcription/engine.rs b/frontend/src-tauri/src/audio/transcription/engine.rs index 2e6048cd6c..cc507e8b27 100644 --- a/frontend/src-tauri/src/audio/transcription/engine.rs +++ b/frontend/src-tauri/src/audio/transcription/engine.rs @@ -73,6 +73,7 @@ pub async fn validate_transcription_model_ready(app: &AppHandle) crate::api::api::TranscriptConfig { provider: "parakeet".to_string(), model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(), + endpoint_url: None, api_key: None, } } @@ -81,6 +82,7 @@ pub async fn validate_transcription_model_ready(app: &AppHandle) crate::api::api::TranscriptConfig { provider: "parakeet".to_string(), model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(), + endpoint_url: None, api_key: None, } } @@ -137,12 +139,14 @@ pub async fn validate_transcription_model_ready(app: &AppHandle) } "remote" => { info!("🔍 Validating remote transcription configuration..."); + let (url, model_name) = config.resolve_remote_params(); // Delegate to RemoteProvider::new for validation (single source of truth) super::remote_provider::RemoteProvider::new( - config.model.clone(), + url, config.api_key.clone().unwrap_or_default(), + model_name, )?; - info!("✅ Remote transcription configuration valid for URL: {}", config.model); + info!("✅ Remote transcription configuration valid"); Ok(()) } other => { @@ -179,6 +183,7 @@ pub async fn get_or_init_transcription_engine( crate::api::api::TranscriptConfig { provider: "parakeet".to_string(), model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(), + endpoint_url: None, api_key: None, } } @@ -187,6 +192,7 @@ pub async fn get_or_init_transcription_engine( crate::api::api::TranscriptConfig { provider: "parakeet".to_string(), model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(), + endpoint_url: None, api_key: None, } } @@ -224,9 +230,11 @@ pub async fn get_or_init_transcription_engine( } "remote" => { info!("☁️ Initializing remote transcription engine"); + let (url, model_name) = config.resolve_remote_params(); let provider = super::remote_provider::RemoteProvider::new( - config.model.clone(), + url, config.api_key.unwrap_or_default(), + model_name, ).map_err(|e| format!("Failed to create remote transcription provider: {}", e))?; Ok(TranscriptionEngine::Provider(Arc::new(provider))) } diff --git a/frontend/src-tauri/src/audio/transcription/remote_provider.rs b/frontend/src-tauri/src/audio/transcription/remote_provider.rs index 6afddcae11..8c37dc20e7 100644 --- a/frontend/src-tauri/src/audio/transcription/remote_provider.rs +++ b/frontend/src-tauri/src/audio/transcription/remote_provider.rs @@ -12,11 +12,12 @@ use log::{info, warn}; pub struct RemoteProvider { url: String, api_key: String, + model_name: String, client: reqwest::Client, } impl RemoteProvider { - pub fn new(url: String, api_key: String) -> Result { + pub fn new(url: String, api_key: String, model_name: String) -> Result { if url.is_empty() { return Err("Remote transcription URL not configured".to_string()); } @@ -24,8 +25,16 @@ impl RemoteProvider { return Err("Remote transcription API key not configured".to_string()); } + // Validate model name length and characters + if model_name.len() > 256 { + return Err("Model name must be 256 characters or fewer".to_string()); + } + if model_name.chars().any(|c| c.is_control() && c != '\t') { + return Err("Model name must not contain control characters".to_string()); + } + // Validate URL scheme — require HTTPS except for localhost - match url::Url::parse(&url) { + let parsed_url = match url::Url::parse(&url) { Ok(parsed) => { let is_localhost = parsed.host_str() .map(|h| h == "localhost" || h == "127.0.0.1" || h == "::1") @@ -36,19 +45,25 @@ impl RemoteProvider { parsed.scheme() )); } + parsed } Err(e) => return Err(format!("Invalid remote transcription URL: {}", e)), - } + }; let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(5)) .timeout(std::time::Duration::from_secs(15)) .build() .map_err(|e| format!("Failed to build HTTP client: {}", e))?; - info!("Remote transcription provider initialized for URL: {}", url); + + // Log URL without query params to avoid leaking tokens + let sanitized_url = format!("{}://{}{}", parsed_url.scheme(), parsed_url.host_str().unwrap_or("unknown"), parsed_url.path()); + info!("Remote transcription provider initialized for URL: {}, model: {}", sanitized_url, if model_name.is_empty() { "(none)" } else { &model_name }); + Ok(Self { url, api_key, + model_name, client, }) } @@ -110,6 +125,11 @@ impl TranscriptionProvider for RemoteProvider { let mut form = reqwest::multipart::Form::new() .part("file", file_part); + // Send model parameter if configured (required by OpenAI, optional for self-hosted) + if !self.model_name.is_empty() { + form = form.text("model", self.model_name.clone()); + } + // Forward language parameter if provided (OpenAI-compatible endpoints accept this) if let Some(lang) = language { form = form.text("language", lang); @@ -168,7 +188,11 @@ impl TranscriptionProvider for RemoteProvider { } async fn get_current_model(&self) -> Option { - Some("remote".to_string()) + if self.model_name.is_empty() { + Some("remote".to_string()) + } else { + Some(self.model_name.clone()) + } } fn provider_name(&self) -> &'static str { diff --git a/frontend/src-tauri/src/database/commands.rs b/frontend/src-tauri/src/database/commands.rs index 83fc6332ff..bf834c1461 100644 --- a/frontend/src-tauri/src/database/commands.rs +++ b/frontend/src-tauri/src/database/commands.rs @@ -206,6 +206,7 @@ pub async fn initialize_fresh_database(app: AppHandle) -> Result<(), String> { pool, "parakeet", crate::config::DEFAULT_PARAKEET_MODEL, + None, ).await { error!("Failed to set default transcription model config: {}", e); } diff --git a/frontend/src-tauri/src/database/models.rs b/frontend/src-tauri/src/database/models.rs index 720d396eb8..5c61f96878 100644 --- a/frontend/src-tauri/src/database/models.rs +++ b/frontend/src-tauri/src/database/models.rs @@ -114,6 +114,9 @@ pub struct TranscriptSetting { pub id: String, pub provider: String, pub model: String, + #[sqlx(rename = "endpointUrl")] + #[serde(rename = "endpointUrl")] + pub endpoint_url: Option, #[sqlx(rename = "whisperApiKey")] #[serde(rename = "whisperApiKey")] pub whisper_api_key: Option, @@ -129,4 +132,7 @@ pub struct TranscriptSetting { #[sqlx(rename = "openaiApiKey")] #[serde(rename = "openaiApiKey")] pub openai_api_key: Option, + #[sqlx(rename = "runpodApiKey")] + #[serde(rename = "runpodApiKey")] + pub runpod_api_key: Option, } diff --git a/frontend/src-tauri/src/database/repositories/setting.rs b/frontend/src-tauri/src/database/repositories/setting.rs index 3970ce7527..024cd2f4c5 100644 --- a/frontend/src-tauri/src/database/repositories/setting.rs +++ b/frontend/src-tauri/src/database/repositories/setting.rs @@ -154,18 +154,21 @@ impl SettingsRepository { pool: &SqlitePool, provider: &str, model: &str, + endpoint_url: Option<&str>, ) -> std::result::Result<(), sqlx::Error> { sqlx::query( r#" - INSERT INTO transcript_settings (id, provider, model) - VALUES ('1', $1, $2) + INSERT INTO transcript_settings (id, provider, model, endpointUrl) + VALUES ('1', $1, $2, $3) ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, - model = excluded.model + model = excluded.model, + endpointUrl = excluded.endpointUrl "#, ) .bind(provider) .bind(model) + .bind(endpoint_url) .execute(pool) .await?; diff --git a/frontend/src-tauri/src/onboarding.rs b/frontend/src-tauri/src/onboarding.rs index dcd71e863d..cc6f059757 100644 --- a/frontend/src-tauri/src/onboarding.rs +++ b/frontend/src-tauri/src/onboarding.rs @@ -193,6 +193,7 @@ pub async fn complete_onboarding( pool, "parakeet", crate::config::DEFAULT_PARAKEET_MODEL, + None, ).await { error!("Failed to save transcription model config: {}", e); return Err(format!("Failed to save transcription model config: {}", e)); diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index c6b04b704d..5f93341350 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -41,6 +41,7 @@ export default function SettingsPage() { setTranscriptModelConfig({ provider: config.provider || 'localWhisper', model: config.model || 'large-v3', + endpointUrl: config.endpointUrl || null, apiKey: config.apiKey || null }); } diff --git a/frontend/src/components/ParakeetModelManager.tsx b/frontend/src/components/ParakeetModelManager.tsx index 438c29babc..7717ce34f3 100644 --- a/frontend/src/components/ParakeetModelManager.tsx +++ b/frontend/src/components/ParakeetModelManager.tsx @@ -200,6 +200,7 @@ export function ParakeetModelManager({ await invoke('api_save_transcript_config', { provider: 'parakeet', model: modelName, + endpointUrl: null, apiKey: null }); } catch (error) { diff --git a/frontend/src/components/Sidebar/index.tsx b/frontend/src/components/Sidebar/index.tsx index 52b1ba1a7b..3ebaedd48d 100644 --- a/frontend/src/components/Sidebar/index.tsx +++ b/frontend/src/components/Sidebar/index.tsx @@ -220,6 +220,7 @@ const Sidebar: React.FC = () => { const payload = { provider: configToSave.provider, model: configToSave.model, + endpointUrl: configToSave.endpointUrl ?? null, apiKey: configToSave.apiKey ?? null }; console.log('Saving transcript config with payload:', payload); @@ -227,6 +228,7 @@ const Sidebar: React.FC = () => { await invoke('api_save_transcript_config', { provider: payload.provider, model: payload.model, + endpointUrl: payload.endpointUrl, apiKey: payload.apiKey, }); diff --git a/frontend/src/components/TranscriptSettings.tsx b/frontend/src/components/TranscriptSettings.tsx index 12ed19019f..9bef881b1c 100644 --- a/frontend/src/components/TranscriptSettings.tsx +++ b/frontend/src/components/TranscriptSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Input } from './ui/input'; @@ -13,6 +13,7 @@ import { toast } from 'sonner'; export interface TranscriptModelProps { provider: 'localWhisper' | 'parakeet' | 'remote' | 'deepgram' | 'elevenLabs' | 'groq' | 'openai'; model: string; + endpointUrl?: string | null; apiKey?: string | null; } @@ -28,14 +29,26 @@ export function TranscriptSettings({ transcriptModelConfig, setTranscriptModelCo // Local draft state -- only pushed to context on Save (remote) or model select (local) const [uiProvider, setUiProvider] = useState(transcriptModelConfig.provider); const [uiModel, setUiModel] = useState(transcriptModelConfig.model); + const [uiEndpointUrl, setUiEndpointUrl] = useState(transcriptModelConfig.endpointUrl || ''); const [uiApiKey, setUiApiKey] = useState(transcriptModelConfig.apiKey || null); const [apiKeyDirty, setApiKeyDirty] = useState(false); const [isSaving, setIsSaving] = useState(false); + // Sync local draft state when the context config updates (e.g., async load on mount) + useEffect(() => { + setUiProvider(transcriptModelConfig.provider); + setUiModel(transcriptModelConfig.model); + setUiEndpointUrl(transcriptModelConfig.endpointUrl || ''); + if (!apiKeyDirty) { + setUiApiKey(transcriptModelConfig.apiKey || null); + } + }, [transcriptModelConfig]); + const isRemoteProvider = !LOCAL_PROVIDERS.has(uiProvider); const requiresApiKey = isRemoteProvider; const isDoneDisabled = + (uiProvider === 'remote' && !uiEndpointUrl?.trim()) || (isRemoteProvider && !uiModel?.trim()) || (requiresApiKey && !uiApiKey?.trim()); @@ -60,11 +73,13 @@ export function TranscriptSettings({ transcriptModelConfig, setTranscriptModelCo const config: TranscriptModelProps = { provider: uiProvider, model: uiModel, + endpointUrl: uiProvider === 'remote' ? uiEndpointUrl?.trim() || null : null, apiKey: uiApiKey?.trim() || null, }; await invoke('api_save_transcript_config', { provider: config.provider, model: config.model, + endpointUrl: config.endpointUrl, apiKey: config.apiKey, }); @@ -113,13 +128,17 @@ export function TranscriptSettings({ transcriptModelConfig, setTranscriptModelCo if (provider === 'remote') { const existingUrl = transcriptModelConfig.provider === 'remote' + ? (transcriptModelConfig.endpointUrl || '') : ''; + const existingModel = transcriptModelConfig.provider === 'remote' ? transcriptModelConfig.model : ''; - setUiModel(existingUrl); + setUiEndpointUrl(existingUrl); + setUiModel(existingModel); fetchApiKey('remote'); } else if (LOCAL_PROVIDERS.has(provider)) { const existingModel = transcriptModelConfig.provider === provider ? transcriptModelConfig.model : ''; setUiModel(existingModel); + setUiEndpointUrl(''); } }} > @@ -156,19 +175,34 @@ export function TranscriptSettings({ transcriptModelConfig, setTranscriptModelCo )} {uiProvider === 'remote' && ( -
- - setUiModel(e.target.value)} - placeholder="e.g. https://your-server/v1/audio/transcriptions" - /> -

- The full URL of your OpenAI-compatible transcription endpoint -

-
+ <> +
+ + setUiEndpointUrl(e.target.value)} + placeholder="e.g. https://your-server/v1/audio/transcriptions" + /> +

+ The full URL of your OpenAI-compatible transcription endpoint +

+
+
+ + setUiModel(e.target.value)} + placeholder="e.g. whisper-1, whisper-large-v3" + maxLength={256} + /> +

+ The model identifier to send with transcription requests +

+
+ )} {requiresApiKey && ( diff --git a/frontend/src/components/WhisperModelManager.tsx b/frontend/src/components/WhisperModelManager.tsx index 020c62f43a..a8f365fec3 100644 --- a/frontend/src/components/WhisperModelManager.tsx +++ b/frontend/src/components/WhisperModelManager.tsx @@ -247,6 +247,7 @@ export function ModelManager({ await invoke('api_save_transcript_config', { provider: 'localWhisper', model: modelName, + endpointUrl: null, apiKey: null }); } catch (error) { diff --git a/frontend/src/contexts/ConfigContext.tsx b/frontend/src/contexts/ConfigContext.tsx index 3873124eb7..763581258e 100644 --- a/frontend/src/contexts/ConfigContext.tsx +++ b/frontend/src/contexts/ConfigContext.tsx @@ -200,6 +200,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { setTranscriptModelConfig({ provider: config.provider || 'parakeet', model: config.model || 'parakeet-tdt-0.6b-v3-int8', + endpointUrl: config.endpointUrl || null, apiKey: config.apiKey || null }); }