From 5a4ce2083b40be76f677852db5d6bb047a8d1dc8 Mon Sep 17 00:00:00 2001 From: Timo Goetzken Date: Thu, 24 Sep 2026 15:58:26 +0200 Subject: [PATCH 1/2] feat(transcription): allow an OpenAI-compatible endpoint for the openai provider The openai transcription provider hardcoded api.openai.com, whisper-1 and OPENAI_API_KEY, so self-hosters could not route voice clips through LiteLLM, Groq or a self-hosted OpenAI-compatible gateway, while chat routes already accept a custom base URL. TRANSCRIPTION_OPENAI_BASE_URL, TRANSCRIPTION_OPENAI_MODEL and TRANSCRIPTION_OPENAI_API_KEY override the endpoint, model and key. Unset, behaviour is unchanged. A keyless custom endpoint is called without an Authorization header; api.openai.com still requires a key. --- .env.example | 6 ++ apps/api/src/lib/env.ts | 5 + apps/api/src/lib/transcription.ts | 21 ++-- .../transcription-openai-compatible.test.ts | 102 ++++++++++++++++++ 4 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 apps/api/test/transcription-openai-compatible.test.ts diff --git a/.env.example b/.env.example index 3d3452bd..b00ece3a 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,12 @@ OPENROUTER_API_KEY= # managed alternatives. Org-level config in Settings → AI takes precedence. # TRANSCRIPTION_PROVIDER=local # WHISPER_URL=http://localhost:9000 +# The 'openai' provider also accepts any OpenAI-compatible endpoint (LiteLLM, +# Groq, a self-hosted gateway). Base URL includes /v1; the key falls back to +# OPENAI_API_KEY and may stay empty for a keyless self-hosted endpoint. +# TRANSCRIPTION_OPENAI_BASE_URL=https://api.groq.com/openai/v1 +# TRANSCRIPTION_OPENAI_MODEL=whisper-large-v3-turbo +# TRANSCRIPTION_OPENAI_API_KEY= DEEPGRAM_API_KEY= # ── 4. URLs & ports ─────────────────────────────────── diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index e523ad45..d6850505 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -100,6 +100,11 @@ export const env = { // OpenAI Whisper or Deepgram override via env or per-org config. TRANSCRIPTION_PROVIDER: (process.env.TRANSCRIPTION_PROVIDER || 'local') as 'local' | 'openai' | 'deepgram', WHISPER_URL: process.env.WHISPER_URL || 'http://localhost:9000', // local whisper container + // 'openai' provider: point it at any OpenAI-compatible endpoint (LiteLLM, + // Groq, a self-hosted gateway). Empty = api.openai.com, whisper-1, OPENAI_API_KEY. + TRANSCRIPTION_OPENAI_BASE_URL: process.env.TRANSCRIPTION_OPENAI_BASE_URL || '', + TRANSCRIPTION_OPENAI_MODEL: process.env.TRANSCRIPTION_OPENAI_MODEL || '', + TRANSCRIPTION_OPENAI_API_KEY: process.env.TRANSCRIPTION_OPENAI_API_KEY || '', DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY || '', // Phase 10 — Prometheus scraper bearer token. Unset = /api/metrics returns 503. METRICS_SCRAPE_TOKEN: process.env.METRICS_SCRAPE_TOKEN || '', diff --git a/apps/api/src/lib/transcription.ts b/apps/api/src/lib/transcription.ts index b185e230..b8a69260 100644 --- a/apps/api/src/lib/transcription.ts +++ b/apps/api/src/lib/transcription.ts @@ -59,21 +59,28 @@ async function transcribeLocal(audioPath: string): Promise }; } -// ─── OpenAI Whisper API ─── +// ─── OpenAI Whisper API (or any OpenAI-compatible transcription endpoint) ─── +const OPENAI_TRANSCRIPTION_BASE_URL = 'https://api.openai.com/v1'; + async function transcribeOpenAI(audioPath: string): Promise { - const apiKey = env.OPENAI_API_KEY; - if (!apiKey) throw new Error('OPENAI_API_KEY not set for transcription'); + const baseUrl = (env.TRANSCRIPTION_OPENAI_BASE_URL || OPENAI_TRANSCRIPTION_BASE_URL).replace(/\/+$/, ''); + const model = env.TRANSCRIPTION_OPENAI_MODEL || 'whisper-1'; + const apiKey = env.TRANSCRIPTION_OPENAI_API_KEY || env.OPENAI_API_KEY; + // OpenAI itself always needs a key; a self-hosted compatible endpoint may not. + if (!apiKey && baseUrl === OPENAI_TRANSCRIPTION_BASE_URL) { + throw new Error('OPENAI_API_KEY not set for transcription'); + } const fileBuffer = await readFile(audioPath); const formData = new FormData(); formData.append('file', new Blob([fileBuffer]), 'audio.webm'); - formData.append('model', 'whisper-1'); + formData.append('model', model); formData.append('response_format', 'verbose_json'); formData.append('timestamp_granularities[]', 'segment'); - const res = await fetch('https://api.openai.com/v1/audio/transcriptions', { + const res = await fetch(`${baseUrl}/audio/transcriptions`, { method: 'POST', - headers: { Authorization: `Bearer ${apiKey}` }, + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, body: formData, }); @@ -97,7 +104,7 @@ async function transcribeOpenAI(audioPath: string): Promise text: s.text.trim(), })), language: data.language, - model: 'whisper-1', + model, duration_s: data.duration || 0, }; } diff --git a/apps/api/test/transcription-openai-compatible.test.ts b/apps/api/test/transcription-openai-compatible.test.ts new file mode 100644 index 00000000..11e2cfb1 --- /dev/null +++ b/apps/api/test/transcription-openai-compatible.test.ts @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { env } from '../src/lib/env.js'; +import { transcribe } from '../src/lib/transcription.js'; + +const TRANSCRIPTION_ENV = [ + 'TRANSCRIPTION_PROVIDER', + 'TRANSCRIPTION_OPENAI_BASE_URL', + 'TRANSCRIPTION_OPENAI_MODEL', + 'TRANSCRIPTION_OPENAI_API_KEY', + 'OPENAI_API_KEY', +] as const; + +async function withClip(t: import('node:test').TestContext, overrides: Partial>) { + const saved = Object.fromEntries(TRANSCRIPTION_ENV.map((key) => [key, env[key]])); + t.after(() => Object.assign(env, saved)); + Object.assign(env, { + TRANSCRIPTION_PROVIDER: 'openai', + TRANSCRIPTION_OPENAI_BASE_URL: '', + TRANSCRIPTION_OPENAI_MODEL: '', + TRANSCRIPTION_OPENAI_API_KEY: '', + OPENAI_API_KEY: '', + ...overrides, + }); + const dir = await mkdtemp(join(tmpdir(), 'deft-transcription-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const clip = join(dir, 'clip.webm'); + await writeFile(clip, Buffer.from('fake audio')); + return clip; +} + +function captureFetch(t: import('node:test').TestContext) { + const calls: { url: string; headers: Headers; form: FormData }[] = []; + t.mock.method(globalThis, 'fetch', async (url: unknown, init: RequestInit) => { + calls.push({ url: String(url), headers: new Headers(init.headers), form: init.body as FormData }); + return Response.json({ + text: ' hello ', + language: 'english', + duration: 1.5, + segments: [{ start: 0.2, end: 1.4, text: ' hello ' }], + }); + }); + return calls; +} + +test('openai transcription keeps api.openai.com, whisper-1 and OPENAI_API_KEY by default', async (t) => { + const clip = await withClip(t, { OPENAI_API_KEY: 'sk-openai' }); + const calls = captureFetch(t); + + const result = await transcribe(clip); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://api.openai.com/v1/audio/transcriptions'); + assert.equal(calls[0].headers.get('authorization'), 'Bearer sk-openai'); + assert.equal(calls[0].form.get('model'), 'whisper-1'); + assert.equal(calls[0].form.get('response_format'), 'verbose_json'); + assert.deepEqual(result, { + text: ' hello ', + segments: [{ start: 0.2, end: 1.4, text: 'hello' }], + language: 'english', + model: 'whisper-1', + duration_s: 1.5, + }); +}); + +test('openai transcription targets a configured OpenAI-compatible endpoint, model and key', async (t) => { + const clip = await withClip(t, { + OPENAI_API_KEY: 'sk-openai', + TRANSCRIPTION_OPENAI_BASE_URL: 'https://llm.example.test/v1/', + TRANSCRIPTION_OPENAI_MODEL: 'whisper-large-v3-turbo', + TRANSCRIPTION_OPENAI_API_KEY: 'sk-proxy', + }); + const calls = captureFetch(t); + + const result = await transcribe(clip); + + assert.equal(calls[0].url, 'https://llm.example.test/v1/audio/transcriptions'); + assert.equal(calls[0].headers.get('authorization'), 'Bearer sk-proxy'); + assert.equal(calls[0].form.get('model'), 'whisper-large-v3-turbo'); + assert.equal(result.model, 'whisper-large-v3-turbo'); +}); + +test('a keyless self-hosted endpoint is called without an Authorization header', async (t) => { + const clip = await withClip(t, { TRANSCRIPTION_OPENAI_BASE_URL: 'http://whisper.internal:8000/v1' }); + const calls = captureFetch(t); + + await transcribe(clip); + + assert.equal(calls[0].url, 'http://whisper.internal:8000/v1/audio/transcriptions'); + assert.equal(calls[0].headers.has('authorization'), false); +}); + +test('api.openai.com without any key still fails before sending audio', async (t) => { + const clip = await withClip(t, {}); + const calls = captureFetch(t); + + await assert.rejects(transcribe(clip), /OPENAI_API_KEY not set for transcription/); + assert.equal(calls.length, 0); +}); From 7692f7acd46316067df768ccdc3c0d3c41e32703 Mon Sep 17 00:00:00 2001 From: Timo Goetzken Date: Thu, 24 Sep 2026 15:59:29 +0200 Subject: [PATCH 2/2] docs(self-hosting): document transcription environment variables --- docs/self-hosting.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 7f20a11a..154a3116 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -521,6 +521,11 @@ can supply its own AI independently of Deft's provider configuration. | `OPENAI_API_KEY` | No | Optional AI provider/embedding/transcription fallback | none | | `OPENROUTER_API_KEY` | No | Optional AI provider fallback | none | | `OLLAMA_URL` | No | Optional local Ollama endpoint; set only when running | none | +| `TRANSCRIPTION_PROVIDER` | No | Voice-clip transcription: `local`, `openai`, or `deepgram`; an org-level choice in Settings → AI wins | `local` | +| `WHISPER_URL` | No | Whisper service for the `local` provider | `http://localhost:9000` | +| `TRANSCRIPTION_OPENAI_BASE_URL` | No | OpenAI-compatible base URL (including `/v1`) for the `openai` provider, such as LiteLLM, Groq, or a self-hosted gateway | `https://api.openai.com/v1` | +| `TRANSCRIPTION_OPENAI_MODEL` | No | Transcription model sent to that endpoint | `whisper-1` | +| `TRANSCRIPTION_OPENAI_API_KEY` | No | Key for that endpoint; empty falls back to `OPENAI_API_KEY`, and a keyless custom endpoint is called without one | `OPENAI_API_KEY` | | `R2_ENDPOINT` / `R2_ACCESS_KEY` / `R2_SECRET_KEY` / `R2_BUCKET` | No | Cloudflare R2 uploads | local uploads volume | | `METRICS_SCRAPE_TOKEN` | No | Bearer token for `/api/metrics` and `/health/queue`; unset disables detailed telemetry | none |