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
4 changes: 4 additions & 0 deletions app/diagnostics-filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ const ARGS_ECHO_REDACTORS = {
'set-remote-ollama-url': redactRest,
'test-remote-ollama': redactRest,
'set-cloud-api-url': redactRest,
// --api-url may embed credentials or a private org hostname (same class as
// set-cloud-api-url). The ASR key never travels via argv — it's env/
// safeStorage only — so there's nothing key-shaped to redact here.
'set-openai-asr-config': redactRest,
// device_id + a user-assigned device label (e.g. "Valentin's AirPods") —
// same PII class as set-user-name.
'set-microphone': redactRest,
Expand Down
16 changes: 16 additions & 0 deletions app/e2e-mock-ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,22 @@ function install({ ipcMain }) {
engine: process.env.STENOAI_E2E_MOCK_ENGINE || 'parakeet',
}),

// OpenAI-compatible ASR config. Shape-only for first paint; the real
// set/get round-trip + key storage is covered by cloud-asr-config.t2.
'get-openai-asr-config': async () => ({
success: true,
api_url: 'https://api.openai.com/v1',
api_key_set: false,
model: 'whisper-1',
}),
'set-openai-asr-config': async () => ({
success: true,
api_url: 'https://api.openai.com/v1',
api_key_set: false,
model: 'whisper-1',
}),
'set-openai-asr-key': async () => ({ success: true, api_key_set: true }),

// Default not-installed keeps most T1 specs on their routes; the pill-dock
// T1 sets STENOAI_E2E_MOCK_PARAKEET_INSTALLED=1 so App.tsx's first-run
// setup gate doesn't redirect it to /setup before it can hit Record.
Expand Down
122 changes: 115 additions & 7 deletions app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -3709,7 +3709,7 @@ function spawnLiveTranscribe(sessionName) {
liveTranscribeSessionName = null;
liveTranscribeStdoutBuf = '';
}
const aiEnv = getAiEnv();
const aiEnv = { ...getAiEnv(), ...getTranscriptionEnv() };
const env = Object.keys(aiEnv).length > 0
? { ...require('process').env, ...aiEnv }
: undefined;
Expand Down Expand Up @@ -3983,7 +3983,8 @@ function loadTranscriptionEngine() {
if (!fs.existsSync(cfgPath)) return 'parakeet';
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const engine = cfg.transcription_engine;
return engine === 'whisper' ? 'whisper' : 'parakeet';
if (engine === 'whisper' || engine === 'openai-asr') return engine;
return 'parakeet';
} catch (_) {
return 'parakeet';
}
Expand All @@ -4000,12 +4001,21 @@ function loadTranscriptionContext() {
return { engine: 'parakeet', model: 'parakeet', language: 'auto' };
}
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
const engine = cfg.transcription_engine === 'whisper' ? 'whisper' : 'parakeet';
return {
engine,
const rawEngine = cfg.transcription_engine;
const engine = (rawEngine === 'whisper' || rawEngine === 'openai-asr') ? rawEngine : 'parakeet';
let model;
if (engine === 'whisper') {
model = sanitizeModelForAnalytics(cfg.whisper_model);
} else if (engine === 'openai-asr') {
model = sanitizeModelForAnalytics(cfg.openai_asr_model) || 'whisper-1';
} else {
// Parakeet has no separate user-selectable model today (single bundled
// default) -- report the engine name rather than guess a variant id.
model: engine === 'whisper' ? sanitizeModelForAnalytics(cfg.whisper_model) : 'parakeet',
model = 'parakeet';
}
return {
engine,
model,
language: cfg.language || 'auto',
};
} catch (_) {
Expand Down Expand Up @@ -4364,7 +4374,9 @@ async function processNextInQueue() {
let transcriptionEndedAtMs = null;

try {
const queueAiEnv = getAiEnv();
// process-streaming does BOTH transcription (needs the ASR key) and
// summarization (needs the AI env), so merge both.
const queueAiEnv = { ...getAiEnv(), ...getTranscriptionEnv() };
const queueEnv = Object.keys(queueAiEnv).length > 0 ? { ...require('process').env, ...queueAiEnv } : undefined;
const processArgs = ['process-streaming', currentProcessingJob.audioFile, '--name', currentProcessingJob.sessionName];
if (currentProcessingJob.notesFile && fs.existsSync(currentProcessingJob.notesFile)) {
Expand Down Expand Up @@ -6511,6 +6523,49 @@ ipcMain.handle('set-transcription-engine', async (event, engine) => {
} catch (e) { return { success: false, error: e.message }; }
});

// OpenAI-compatible ASR: the NON-SECRET config (url/model) shells to the CLI
// like set-cloud-api-url does. api_key_set is always overridden with the
// safeStorage truth (hasOpenAiAsrKey) — the CLI only sees the env-var key,
// which isn't injected on these calls, so its own api_key_set is unreliable.
ipcMain.handle('get-openai-asr-config', async () => {
try {
const result = await runPythonScript('simple_recorder.py', ['get-openai-asr-config'], true);
const jsonData = JSON.parse(result.trim());
jsonData.api_key_set = hasOpenAiAsrKey();
return jsonData;
} catch (e) { return { success: false, error: e.message }; }
});

ipcMain.handle('set-openai-asr-config', async (_event, cfg) => {
try {
const args = ['set-openai-asr-config'];
if (cfg && cfg.api_url !== undefined) { args.push('--api-url', cfg.api_url); }
if (cfg && cfg.model !== undefined) { args.push('--model', cfg.model); }
const result = await runPythonScript('simple_recorder.py', args, true);
const jsonData = JSON.parse(result.trim());
jsonData.api_key_set = hasOpenAiAsrKey();
return jsonData;
} catch (e) { return { success: false, error: e.message }; }
});

// The SECRET key: stored encrypted via safeStorage (never argv, never
// config.json), mirroring set-cloud-api-key. Passing an empty string clears it
// (deletes the file).
ipcMain.handle('set-openai-asr-key', async (_event, key) => {
try {
if (!key) {
try {
if (fs.existsSync(getOpenAiAsrKeyPath())) fs.unlinkSync(getOpenAiAsrKeyPath());
} catch (e) {
return { success: false, error: e.message };
}
return { success: true, api_key_set: false };
}
const saved = saveOpenAiAsrKey(key);
return { success: saved, api_key_set: saved && hasOpenAiAsrKey() };
} catch (e) { return { success: false, error: e.message }; }
});

ipcMain.handle('list-parakeet-models', async () => {
try {
const result = await runPythonScript('simple_recorder.py', ['list-parakeet-models'], true);
Expand Down Expand Up @@ -7432,6 +7487,48 @@ function hasCloudApiKey() {
return fs.existsSync(getCloudKeyPath());
}

// --- OpenAI-compatible ASR (transcription) API key -------------------------
// Mirrors the cloud summariser key exactly: encrypted-at-rest via safeStorage,
// stored under getUserDataDir() (honours STENOAI_USER_DATA_DIR test isolation),
// never written to config.json, and injected into the TRANSCRIPTION subprocess
// env as STENOAI_OAI_API_KEY. This is the security-critical difference from the
// upstream PR, which persisted the key in plaintext config.json.
function getOpenAiAsrKeyPath() {
return path.join(getUserDataDir(), '.openai-asr-api-key');
}

function saveOpenAiAsrKey(key) {
try {
const keyDir = path.dirname(getOpenAiAsrKeyPath());
if (!fs.existsSync(keyDir)) {
fs.mkdirSync(keyDir, { recursive: true });
}
const encrypted = safeStorage.encryptString(key);
fs.writeFileSync(getOpenAiAsrKeyPath(), encrypted);
return true;
} catch (error) {
console.error('Failed to save OpenAI ASR API key:', error.message);
return false;
}
}

function loadOpenAiAsrKey() {
try {
const keyPath = getOpenAiAsrKeyPath();
migrateLegacyCredentialFile(keyPath, '.openai-asr-api-key');
if (!fs.existsSync(keyPath)) return null;
const encrypted = fs.readFileSync(keyPath);
return safeStorage.decryptString(encrypted);
} catch (error) {
console.error('Failed to load OpenAI ASR API key:', error.message);
return null;
}
}

function hasOpenAiAsrKey() {
return fs.existsSync(getOpenAiAsrKeyPath());
}

// Build the env additions a Python AI-driven subprocess needs. Merges
// the encrypted-on-disk cloud key (decrypted only here, never written
// to the env if absent) AND the org adapter URL+JWT when a session
Expand All @@ -7450,6 +7547,17 @@ function getAiEnv() {
return env;
}

// Env additions a transcription subprocess needs. The OpenAI-compatible ASR
// key (decrypted from safeStorage only here) is surfaced as STENOAI_OAI_API_KEY
// so the Python transcriber's get_openai_asr_api_key() can read it. Empty when
// no key is set / the engine isn't openai-asr — the Python side no-ops on it.
function getTranscriptionEnv() {
const env = {};
const oaiKey = loadOpenAiAsrKey();
if (oaiKey) env.STENOAI_OAI_API_KEY = oaiKey;
return env;
}

// Read the Python-side ai_provider config so we can react to it on sign-in
// / sign-out events. Returns 'local' on any error so an unreadable config
// can't accidentally keep us in 'adapter' mode after logout.
Expand Down
9 changes: 9 additions & 0 deletions app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,15 @@ const stenoai = {
set: (engine) => invoke('set-transcription-engine', engine),
},

openaiAsr: {
getConfig: () => invoke('get-openai-asr-config'),
// cfg may include any subset of { api_url, model } — the key is NOT set
// here; use setKey (safeStorage-backed) for the credential.
setConfig: (cfg) => invoke('set-openai-asr-config', cfg),
// Pass an empty string to clear the stored key.
setKey: (key) => invoke('set-openai-asr-key', key),
},

settings: {
getNotifications: () => invoke('get-notifications'),
setNotifications: (v) => invoke('set-notifications', v),
Expand Down
48 changes: 48 additions & 0 deletions app/renderer/src/hooks/useModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const transcriptionEngineKeys = {
current: () => [...transcriptionEngineKeys.all, 'current'] as const,
};

export const openaiAsrKeys = {
all: ['openaiAsrConfig'] as const,
config: () => [...openaiAsrKeys.all, 'config'] as const,
};

function parseSizeGb(size?: string): number | undefined {
if (!size) return undefined;
const match = size.match(/^([\d.]+)\s*(GB|MB|KB|B)?$/i);
Expand Down Expand Up @@ -604,3 +609,46 @@ export function useSetActiveTranscription() {
},
});
}

// ---------------------------------------------------------------------------
// OpenAI-compatible ASR config
//
// The non-secret endpoint config (api_url, model) round-trips through the
// backend config.json. `api_key_set` reflects the encrypted-on-disk key held
// by the main process (safeStorage) — the key value itself is never returned.
// ---------------------------------------------------------------------------

/** Query for the current OpenAI ASR endpoint config (url, api_key_set, model). */
export function useOpenAiAsrConfig() {
return useQuery({
queryKey: openaiAsrKeys.config(),
queryFn: async () => unwrap(await ipc().openaiAsr.getConfig()),
});
}

/**
* Mutation to save the non-secret OpenAI ASR config (url and/or model).
* Pass only the fields you want to change; others are left untouched. The
* API key is set separately via useSetOpenAiAsrKey (never through here).
*/
export function useSetOpenAiAsrConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (cfg: { api_url?: string; model?: string }) =>
unwrap(await ipc().openaiAsr.setConfig(cfg)),
onSuccess: () => qc.invalidateQueries({ queryKey: openaiAsrKeys.all }),
});
}

/**
* Mutation to set (or clear, with an empty string) the OpenAI ASR API key.
* The key is stored encrypted by the main process; only `api_key_set` is
* ever surfaced back.
*/
export function useSetOpenAiAsrKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => unwrap(await ipc().openaiAsr.setKey(key)),
onSuccess: () => qc.invalidateQueries({ queryKey: openaiAsrKeys.all }),
});
}
25 changes: 24 additions & 1 deletion app/renderer/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,13 +478,27 @@ export type ParakeetStatusResponse = Result<{
installed: boolean;
}>;

export type TranscriptionEngine = 'parakeet' | 'whisper';
export type TranscriptionEngine = 'parakeet' | 'whisper' | 'openai-asr';

export type GetTranscriptionEngineResponse = Result<{
engine: TranscriptionEngine;
valid_engines: TranscriptionEngine[];
}>;

export type GetOpenAiAsrConfigResponse = Result<{
api_url: string;
api_key_set: boolean;
model: string;
}>;

export type SetOpenAiAsrConfigResponse = Result<{
api_url: string;
api_key_set: boolean;
model: string;
}>;

export type SetOpenAiAsrKeyResponse = Result<{ api_key_set: boolean }>;

export type GetNotificationsResponse = Result<{ notifications_enabled: boolean }>;
export type GetTelemetryResponse = Result<{
telemetry_enabled: boolean;
Expand Down Expand Up @@ -957,6 +971,15 @@ export interface StenoaiBridge {
set: RequestFn<[engine: TranscriptionEngine], Result<{ engine: TranscriptionEngine }>>;
};

openaiAsr: {
getConfig: RequestFn<[], GetOpenAiAsrConfigResponse>;
setConfig: RequestFn<
[cfg: { api_url?: string; model?: string }],
SetOpenAiAsrConfigResponse
>;
setKey: RequestFn<[key: string], SetOpenAiAsrKeyResponse>;
};

settings: {
getNotifications: RequestFn<[], GetNotificationsResponse>;
setNotifications: RequestFn<[v: boolean], Result<Record<string, never>>>;
Expand Down
Loading
Loading