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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '',
Expand Down
21 changes: 14 additions & 7 deletions apps/api/src/lib/transcription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,21 +59,28 @@ async function transcribeLocal(audioPath: string): Promise<TranscriptionResult>
};
}

// ─── 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<TranscriptionResult> {
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,
});

Expand All @@ -97,7 +104,7 @@ async function transcribeOpenAI(audioPath: string): Promise<TranscriptionResult>
text: s.text.trim(),
})),
language: data.language,
model: 'whisper-1',
model,
duration_s: data.duration || 0,
};
}
Expand Down
102 changes: 102 additions & 0 deletions apps/api/test/transcription-openai-compatible.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<(typeof TRANSCRIPTION_ENV)[number], string>>) {
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);
});
5 changes: 5 additions & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
Loading