Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
42 changes: 41 additions & 1 deletion frontend/src-tauri/src/api/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,44 @@ pub struct GetApiKeyRequest {
pub struct TranscriptConfig {
pub provider: String,
pub model: String,
#[serde(rename = "endpointUrl")]
pub endpoint_url: Option<String>,
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
}

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<String>,
#[serde(rename = "apiKey")]
pub api_key: Option<String>,
}
Expand Down Expand Up @@ -622,6 +652,7 @@ pub async fn api_get_transcript_config<R: Runtime>(
Ok(Some(TranscriptConfig {
provider: config.provider,
model: config.model,
endpoint_url: config.endpoint_url,
api_key,
}))
}
Expand All @@ -640,6 +671,7 @@ pub async fn api_get_transcript_config<R: Runtime>(
Ok(Some(TranscriptConfig {
provider: "parakeet".to_string(),
model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(),
endpoint_url: None,
api_key: None,
}))
}
Expand All @@ -656,6 +688,7 @@ pub async fn api_save_transcript_config<R: Runtime>(
state: tauri::State<'_, AppState>,
provider: String,
model: String,
endpoint_url: Option<String>,
api_key: Option<String>,
_auth_token: Option<String>,
) -> Result<serde_json::Value, String> {
Expand All @@ -665,7 +698,14 @@ pub async fn api_save_transcript_config<R: Runtime>(
);
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());
}
Expand Down
14 changes: 11 additions & 3 deletions frontend/src-tauri/src/audio/transcription/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub async fn validate_transcription_model_ready<R: Runtime>(app: &AppHandle<R>)
crate::api::api::TranscriptConfig {
provider: "parakeet".to_string(),
model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(),
endpoint_url: None,
api_key: None,
}
}
Expand All @@ -81,6 +82,7 @@ pub async fn validate_transcription_model_ready<R: Runtime>(app: &AppHandle<R>)
crate::api::api::TranscriptConfig {
provider: "parakeet".to_string(),
model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(),
endpoint_url: None,
api_key: None,
}
}
Expand Down Expand Up @@ -137,12 +139,14 @@ pub async fn validate_transcription_model_ready<R: Runtime>(app: &AppHandle<R>)
}
"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 => {
Expand Down Expand Up @@ -179,6 +183,7 @@ pub async fn get_or_init_transcription_engine<R: Runtime>(
crate::api::api::TranscriptConfig {
provider: "parakeet".to_string(),
model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(),
endpoint_url: None,
api_key: None,
}
}
Expand All @@ -187,6 +192,7 @@ pub async fn get_or_init_transcription_engine<R: Runtime>(
crate::api::api::TranscriptConfig {
provider: "parakeet".to_string(),
model: crate::config::DEFAULT_PARAKEET_MODEL.to_string(),
endpoint_url: None,
api_key: None,
}
}
Expand Down Expand Up @@ -224,9 +230,11 @@ pub async fn get_or_init_transcription_engine<R: Runtime>(
}
"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)))
}
Expand Down
34 changes: 29 additions & 5 deletions frontend/src-tauri/src/audio/transcription/remote_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,29 @@ 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<Self, String> {
pub fn new(url: String, api_key: String, model_name: String) -> Result<Self, String> {
if url.is_empty() {
return Err("Remote transcription URL not configured".to_string());
}
if api_key.is_empty() {
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")
Expand All @@ -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,
})
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -168,7 +188,11 @@ impl TranscriptionProvider for RemoteProvider {
}

async fn get_current_model(&self) -> Option<String> {
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 {
Expand Down
1 change: 1 addition & 0 deletions frontend/src-tauri/src/database/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src-tauri/src/database/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[sqlx(rename = "whisperApiKey")]
#[serde(rename = "whisperApiKey")]
pub whisper_api_key: Option<String>,
Expand All @@ -129,4 +132,7 @@ pub struct TranscriptSetting {
#[sqlx(rename = "openaiApiKey")]
#[serde(rename = "openaiApiKey")]
pub openai_api_key: Option<String>,
#[sqlx(rename = "runpodApiKey")]
#[serde(rename = "runpodApiKey")]
pub runpod_api_key: Option<String>,
}
9 changes: 6 additions & 3 deletions frontend/src-tauri/src/database/repositories/setting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;

Expand Down
1 change: 1 addition & 0 deletions frontend/src-tauri/src/onboarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ pub async fn complete_onboarding<R: Runtime>(
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));
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/ParakeetModelManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ export function ParakeetModelManager({
await invoke('api_save_transcript_config', {
provider: 'parakeet',
model: modelName,
endpointUrl: null,
apiKey: null
});
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,15 @@ 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);

await invoke('api_save_transcript_config', {
provider: payload.provider,
model: payload.model,
endpointUrl: payload.endpointUrl,
apiKey: payload.apiKey,
});

Expand Down
Loading
Loading