From 5715885583a44b15d50f7d57d5ce23b9b9b3c430 Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Thu, 29 Oct 2026 13:04:46 -0600 Subject: [PATCH 1/9] feat(transcription): route Nova-3 through the LiteLLM media gateway --- .../__tests__/integration/transcribe.test.ts | 8 +- apps/web/actions/videos/get-status.ts | 4 +- apps/web/app/s/[videoId]/page.tsx | 4 +- apps/web/content/docs/self-hosting.mdx | 3 +- apps/web/lib/transcribe.ts | 4 +- apps/web/lib/transcription-gateway.ts | 49 +++++++++++ apps/web/workflows/transcribe.ts | 29 ++++++- deploy/k8s/chart/templates/configmap.yaml | 3 + deploy/k8s/chart/templates/secret.yaml | 4 +- deploy/k8s/chart/templates/web.yaml | 17 ++-- deploy/k8s/chart/values.yaml | 8 +- deploy/k8s/environments/prod/values.yaml | 3 + deploy/transcription-gateway.md | 87 +++++++++++++++++++ docker-compose.coolify.env.example | 5 +- packages/env/server.ts | 5 +- 15 files changed, 208 insertions(+), 25 deletions(-) create mode 100644 apps/web/lib/transcription-gateway.ts create mode 100644 deploy/transcription-gateway.md diff --git a/apps/web/__tests__/integration/transcribe.test.ts b/apps/web/__tests__/integration/transcribe.test.ts index d00d638cfb0..ac85be158f6 100644 --- a/apps/web/__tests__/integration/transcribe.test.ts +++ b/apps/web/__tests__/integration/transcribe.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@cap/env", () => ({ serverEnv: vi.fn(() => ({ - DEEPGRAM_API_KEY: "test-deepgram-api-key", + AI_GATEWAY_TRANSCRIPTION_URL: + "http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000/media/cap/nova-3/transcribe", + AI_GATEWAY_TRANSCRIPTION_API_KEY: "test-gateway-api-key", DATABASE_URL: "mysql://test@localhost/test", })), })); @@ -78,10 +80,10 @@ describe("transcribeVideo", () => { }); describe("input validation", () => { - it("requires DEEPGRAM_API_KEY environment variable", async () => { + it("requires transcription gateway configuration", async () => { const { serverEnv } = await import("@cap/env"); vi.mocked(serverEnv).mockReturnValueOnce({ - DEEPGRAM_API_KEY: undefined, + AI_GATEWAY_TRANSCRIPTION_API_KEY: undefined, } as ReturnType); const result = await transcribeVideo( diff --git a/apps/web/actions/videos/get-status.ts b/apps/web/actions/videos/get-status.ts index 9d6b0999512..c4caaaa3eba 100644 --- a/apps/web/actions/videos/get-status.ts +++ b/apps/web/actions/videos/get-status.ts @@ -3,7 +3,6 @@ import { db } from "@cap/database"; import { users, videos, videoUploads } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; -import { serverEnv } from "@cap/env"; import { provideOptionalAuth, VideosPolicy } from "@cap/web-backend"; import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; @@ -15,6 +14,7 @@ import { } from "@/lib/desktop-segments-finalization"; import { startAiGeneration } from "@/lib/generate-ai"; import * as EffectRuntime from "@/lib/server"; +import { isTranscriptionConfigured } from "@/lib/transcription-gateway"; import { transcribeVideo } from "../../lib/transcribe"; import { isAiGenerationEnabled } from "../../utils/flags"; @@ -62,7 +62,7 @@ export async function getVideoStatus( const metadata: VideoMetadata = (video.metadata as VideoMetadata) || {}; - if (!video.transcriptionStatus && serverEnv().DEEPGRAM_API_KEY) { + if (!video.transcriptionStatus && isTranscriptionConfigured()) { const activeUpload = await db() .select({ videoId: videoUploads.videoId, diff --git a/apps/web/app/s/[videoId]/page.tsx b/apps/web/app/s/[videoId]/page.tsx index d2035484583..39a5a2506ca 100644 --- a/apps/web/app/s/[videoId]/page.tsx +++ b/apps/web/app/s/[videoId]/page.tsx @@ -58,6 +58,7 @@ import { SOCIAL_REFERRER_DOMAINS, } from "@/lib/social-crawlers"; import { transcribeVideo } from "@/lib/transcribe"; +import { isTranscriptionConfigured } from "@/lib/transcription-gateway"; import { isEditSourceKey, reconcileStaleEditUpload, @@ -549,9 +550,8 @@ async function AuthorizedContent({ organizationSettings: video.orgSettings, spaces: sharedSpaces.filter((space) => space.id !== space.organizationId), }); - const env = serverEnv(); const transcriptionGenerationAvailable = - Boolean(env.DEEPGRAM_API_KEY) && !rules.settings.disableTranscript; + isTranscriptionConfigured() && !rules.settings.disableTranscript; const aiProviderAvailable = isAiProviderConfigured(); let aiGenerationEnabled = false; diff --git a/apps/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 3f4b6370fbe..0413f36a633 100644 --- a/apps/web/content/docs/self-hosting.mdx +++ b/apps/web/content/docs/self-hosting.mdx @@ -113,7 +113,8 @@ By default, login links are printed to the server logs. To send emails: For video transcription and AI summaries: ```bash -DEEPGRAM_API_KEY=your-key # Transcription +AI_GATEWAY_TRANSCRIPTION_URL=your-gateway-endpoint # Nova-3 raw transcription +AI_GATEWAY_TRANSCRIPTION_API_KEY=your-scoped-gateway-key GROQ_API_KEY=your-key # AI summaries (preferred) OPENAI_API_KEY=your-key # AI summaries (fallback) ``` diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index 606ef72f741..f9aa2346381 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -1,10 +1,10 @@ import { db } from "@cap/database"; import { organizations, videos, videoUploads } from "@cap/database/schema"; -import { serverEnv } from "@cap/env"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { start } from "workflow/api"; import { transcribeVideoWorkflow } from "@/workflows/transcribe"; +import { isTranscriptionConfigured } from "./transcription-gateway"; type TranscribeResult = { success: boolean; @@ -17,7 +17,7 @@ export async function transcribeVideo( aiGenerationEnabled = false, _isRetry = false, ): Promise { - if (!serverEnv().DEEPGRAM_API_KEY) { + if (!isTranscriptionConfigured()) { return { success: false, message: "Missing necessary environment variables", diff --git a/apps/web/lib/transcription-gateway.ts b/apps/web/lib/transcription-gateway.ts new file mode 100644 index 00000000000..e220411383f --- /dev/null +++ b/apps/web/lib/transcription-gateway.ts @@ -0,0 +1,49 @@ +import { serverEnv } from "@cap/env"; + +const TRANSCRIPTION_PATH = "/media/cap/nova-3/transcribe"; +const PUBLIC_GATEWAY = "https://ai-gateway.take3tech.dev"; +const INTERNAL_GATEWAY = + "http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000"; + +export function getTranscriptionGatewayConfig() { + const env = serverEnv(); + const endpoint = env.AI_GATEWAY_TRANSCRIPTION_URL?.trim(); + const apiKey = env.AI_GATEWAY_TRANSCRIPTION_API_KEY?.trim(); + if (!endpoint || !apiKey) return null; + + try { + const url = new URL(endpoint); + if ( + ![PUBLIC_GATEWAY, INTERNAL_GATEWAY].includes(url.origin) || + url.pathname !== TRANSCRIPTION_PATH || + url.username || + url.password || + url.search || + url.hash + ) { + return null; + } + const accessId = env.AI_GATEWAY_CF_ACCESS_CLIENT_ID?.trim(); + const accessSecret = env.AI_GATEWAY_CF_ACCESS_CLIENT_SECRET?.trim(); + if ( + Boolean(accessId) !== Boolean(accessSecret) || + (url.origin === PUBLIC_GATEWAY && !accessId) + ) { + return null; + } + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + }; + if (accessId && accessSecret) { + headers["CF-Access-Client-Id"] = accessId; + headers["CF-Access-Client-Secret"] = accessSecret; + } + return { endpoint: url.toString(), apiKey, headers }; + } catch { + return null; + } +} + +export function isTranscriptionConfigured() { + return getTranscriptionGatewayConfig() !== null; +} diff --git a/apps/web/workflows/transcribe.ts b/apps/web/workflows/transcribe.ts index 1f985ce47a2..7d9cab2629e 100644 --- a/apps/web/workflows/transcribe.ts +++ b/apps/web/workflows/transcribe.ts @@ -7,7 +7,6 @@ import { videoUploads, } from "@cap/database/schema"; import type { VideoMetadata } from "@cap/database/types"; -import { serverEnv } from "@cap/env"; import { userIsPro } from "@cap/utils"; import { Storage } from "@cap/web-backend"; import { @@ -35,6 +34,10 @@ import { } from "@/lib/media-client"; import { runPromise } from "@/lib/server"; import { type DeepgramResult, formatToWebVTT } from "@/lib/transcribe-utils"; +import { + getTranscriptionGatewayConfig, + isTranscriptionConfigured, +} from "@/lib/transcription-gateway"; import { decodeStorageVideo } from "@/lib/video-storage"; interface TranscribeWorkflowPayload { @@ -98,8 +101,10 @@ export async function transcribeVideoWorkflow( async function validateVideo(videoId: string): Promise { "use step"; - if (!serverEnv().DEEPGRAM_API_KEY) { - throw new FatalError("Missing DEEPGRAM_API_KEY"); + if (!isTranscriptionConfigured()) { + throw new FatalError( + "Missing or invalid transcription gateway configuration", + ); } const query = await db() @@ -339,11 +344,27 @@ async function transcribeWithDeepgram( const audioBuffer = Buffer.from(await audioResponse.arrayBuffer()); - const deepgram = createClient(serverEnv().DEEPGRAM_API_KEY as string); + const gateway = getTranscriptionGatewayConfig(); + if (!gateway) + throw new FatalError( + "Missing or invalid transcription gateway configuration", + ); + const deepgram = createClient(gateway.apiKey, { + global: { + fetch: { + options: { + url: gateway.endpoint, + headers: gateway.headers, + redirect: "error", + }, + }, + }, + }); const { result, error } = await deepgram.listen.prerecorded.transcribeFile( audioBuffer, getDeepgramTranscriptionOptions(language), + gateway.endpoint, ); if (error) { diff --git a/deploy/k8s/chart/templates/configmap.yaml b/deploy/k8s/chart/templates/configmap.yaml index aaaaf8a9c4e..f2d82fac26b 100644 --- a/deploy/k8s/chart/templates/configmap.yaml +++ b/deploy/k8s/chart/templates/configmap.yaml @@ -51,6 +51,9 @@ data: AI_GATEWAY_BASE_URL: {{ required "aiGateway.baseUrl is required when aiGateway.enabled=true" .Values.aiGateway.baseUrl | quote }} AI_GATEWAY_MODEL: {{ default "openai/gpt-oss-120b" .Values.aiGateway.model | quote }} {{- end }} + {{- if .Values.transcriptionGateway.enabled }} + AI_GATEWAY_TRANSCRIPTION_URL: {{ required "transcriptionGateway.url is required when transcriptionGateway.enabled=true" .Values.transcriptionGateway.url | quote }} + {{- end }} {{- range $key, $value := .Values.web.configEnv }} {{ $key }}: {{ $value | quote }} {{- end }} diff --git a/deploy/k8s/chart/templates/secret.yaml b/deploy/k8s/chart/templates/secret.yaml index 29a39487adb..614068321e0 100644 --- a/deploy/k8s/chart/templates/secret.yaml +++ b/deploy/k8s/chart/templates/secret.yaml @@ -30,8 +30,8 @@ stringData: {{- with .Values.runtimeSecret.values.groqApiKey }} {{ $.Values.runtimeSecret.keys.groqApiKey }}: {{ . | quote }} {{- end }} - {{- with .Values.runtimeSecret.values.deepgramApiKey }} - {{ $.Values.runtimeSecret.keys.deepgramApiKey }}: {{ . | quote }} + {{- with .Values.runtimeSecret.values.transcriptionGatewayApiKey }} + {{ $.Values.runtimeSecret.keys.transcriptionGatewayApiKey }}: {{ . | quote }} {{- end }} {{- with .Values.runtimeSecret.values.replicateApiToken }} {{ $.Values.runtimeSecret.keys.replicateApiToken }}: {{ . | quote }} diff --git a/deploy/k8s/chart/templates/web.yaml b/deploy/k8s/chart/templates/web.yaml index e14e5e21e7e..9802af2e79d 100644 --- a/deploy/k8s/chart/templates/web.yaml +++ b/deploy/k8s/chart/templates/web.yaml @@ -96,11 +96,6 @@ spec: name: {{ include "cap.secretName" . }} key: {{ .Values.runtimeSecret.keys.mediaServerWebhookSecret }} {{- if .Values.providerKeys.enabled }} - - name: DEEPGRAM_API_KEY - valueFrom: - secretKeyRef: - name: {{ include "cap.secretName" . }} - key: {{ .Values.runtimeSecret.keys.deepgramApiKey }} {{- if not .Values.aiGateway.enabled }} - name: GROQ_API_KEY valueFrom: @@ -136,6 +131,18 @@ spec: name: {{ include "cap.configName" . }} key: AI_GATEWAY_MODEL {{- end }} + {{- if .Values.transcriptionGateway.enabled }} + - name: AI_GATEWAY_TRANSCRIPTION_URL + valueFrom: + configMapKeyRef: + name: {{ include "cap.configName" . }} + key: AI_GATEWAY_TRANSCRIPTION_URL + - name: AI_GATEWAY_TRANSCRIPTION_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "cap.secretName" . }} + key: {{ .Values.runtimeSecret.keys.transcriptionGatewayApiKey }} + {{- end }} {{- if .Values.workflow.enabled }} - name: WORKFLOW_TARGET_WORLD valueFrom: diff --git a/deploy/k8s/chart/values.yaml b/deploy/k8s/chart/values.yaml index 62070e394f9..81866e28b66 100644 --- a/deploy/k8s/chart/values.yaml +++ b/deploy/k8s/chart/values.yaml @@ -25,7 +25,7 @@ runtimeSecret: aiGatewayApiKey: ai-gateway-api-key openaiApiKey: openai-api-key groqApiKey: groq-api-key - deepgramApiKey: deepgram-api-key + transcriptionGatewayApiKey: transcription-gateway-api-key replicateApiToken: replicate-api-token workflowTargetWorld: workflow-target-world workflowPostgresUrl: workflow-postgres-url @@ -44,7 +44,7 @@ runtimeSecret: aiGatewayApiKey: "" openaiApiKey: "" groqApiKey: "" - deepgramApiKey: "" + transcriptionGatewayApiKey: "" replicateApiToken: "" workflowTargetWorld: "" workflowPostgresUrl: "" @@ -59,6 +59,10 @@ aiGateway: baseUrl: "" model: openai/gpt-oss-120b +transcriptionGateway: + enabled: false + url: "" + workflow: enabled: false setup: diff --git a/deploy/k8s/environments/prod/values.yaml b/deploy/k8s/environments/prod/values.yaml index c7a5d158648..535f2a00912 100644 --- a/deploy/k8s/environments/prod/values.yaml +++ b/deploy/k8s/environments/prod/values.yaml @@ -28,6 +28,9 @@ aiGateway: enabled: true baseUrl: http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000/v1 model: openai/gpt-oss-120b +transcriptionGateway: + enabled: true + url: http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000/media/cap/nova-3/transcribe workflow: enabled: true cron: diff --git a/deploy/transcription-gateway.md b/deploy/transcription-gateway.md new file mode 100644 index 00000000000..6205ac4e089 --- /dev/null +++ b/deploy/transcription-gateway.md @@ -0,0 +1,87 @@ +# Route Nova-3 transcription through LiteLLM + +Cap keeps Deepgram Nova-3 and the existing caption formatter. The only transport +change sends the SDK request through the shared gateway. + +## Runtime configuration + +| Setting | Required value | +| --- | --- | +| `AI_GATEWAY_TRANSCRIPTION_URL` | Internal `http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000/media/cap/nova-3/transcribe` or public `https://ai-gateway.take3tech.dev/media/cap/nova-3/transcribe` | +| `AI_GATEWAY_TRANSCRIPTION_API_KEY` | Dedicated LiteLLM virtual key for Cap transcription | +| `AI_GATEWAY_CF_ACCESS_CLIENT_ID`, `AI_GATEWAY_CF_ACCESS_CLIENT_SECRET` | Both required for the public endpoint; optional as a pair internally | + +The key must grant model alias `cap-deepgram-nova-3` and explicitly allow the +passthrough route `/media/cap/nova-3/transcribe`. It is separate from the key +used by Cap chat and summaries. Do not give Cap the provider's Deepgram key. + +The Helm chart reads the gateway key from `cap-runtime`, property +`transcription-gateway-api-key`. Set `transcriptionGateway.enabled=true` and +`transcriptionGateway.url` to the internal URL for Kubernetes. Production values +already select these settings. The optional chart-created secret accepts +`runtimeSecret.values.transcriptionGatewayApiKey`. + +The gateway reuses `DEEPGRAM_API_KEY` from Secrets Manager secret +`kubernetes/cap/provider-keys/production`. The upstream credential stays in the +gateway. The previous Cap pod injection of `DEEPGRAM_API_KEY` is removed. + +## Request and response contract + +The gateway must register the custom media route before this Cap image is +deployed. Generic LiteLLM passthrough and the standard transcription adapter do +not preserve the complete existing contract. + +- Accept only POST at the fixed route and forward to + `https://api.deepgram.com/v1/listen`. +- Stream the original audio bytes. Preserve repeated query parameters, including + the separate `detect_language` values. +- Require `model=nova-3`. Preserve `smart_format=true`, `utterances=true`, + `mime_type=audio/mpeg`, and the existing explicit-language or auto-detection + selection. +- Return the raw Deepgram JSON. Keep utterance groups, `punctuated_word`, + word timestamps, and any speaker fields. Do not translate it to an + OpenAI transcription response. +- Reject redirects and do not forward caller credentials to Deepgram. +- Authenticate the dedicated key with core LiteLLM auth and enforce its explicit + passthrough grant. Do not buffer the upload merely to authenticate it. +- Keep audio, transcripts, and request query values out of gateway logs. The + custom route emits only fixed route/model, status, duration, byte counts, and + unknown usage/cost. + +Cap still uses `@deepgram/sdk` 3.13.0 for the request. Its file request uses +`Content-Type: deepgram/audio+video`. The SDK appends array options as repeated +query fields and returns the raw response. The existing formatter still splits +captions on punctuation, pauses longer than 0.5 seconds, or eight words. +No new speaker, punctuation, language, or transcription model setting is added. + +## Capacity and timeout + +The existing Cap path loads the complete extracted MP3 into memory before the +SDK call. It has no explicit audio-byte or recording-duration limit and adds no +new one here. Local FFmpeg extraction produces 128 kbps MP3. The workflow queue +library defaults to ten workers unless its environment overrides concurrency. +These are source defaults, not a measurement of the deployed system. + +The custom gateway transport allows 3600 seconds for the Cap request and streams +both directions. Capacity planning must include concurrent large uploads; the +gateway must not impose an undocumented small body limit or buffer every upload +in RAM. Existing public Cap ingress permits 10 GiB uploads, but that setting is +not the transcription provider's audio limit. + +## Deployment gates + +Do not merge or deploy the Cap migration before the following checks pass: + +1. Deploy the reviewed gateway callback and provision the dedicated key. +2. Verify unauthorized keys cannot use the route or choose a different model. +3. Use approved synthetic audio to verify the gateway preserves raw bytes, + repeated language parameters, raw utterances, punctuation, and timestamps. +4. Confirm gateway logs contain no audio or transcript data. +5. Provision `cap-runtime/transcription-gateway-api-key`. +6. Publish a matching Cap image and apply its chart configuration together. +7. Verify the authorized recording, transcription, captions, language selection, + status, and retry flow after deployment. + +If gateway configuration is absent or invalid, Cap treats transcription as +unavailable. It never falls back to a direct Deepgram call. Existing recordings, +captions, storage, Replicate code, Supermemory, chat, and summaries are unchanged. diff --git a/docker-compose.coolify.env.example b/docker-compose.coolify.env.example index 144db76dc0e..9a456711824 100644 --- a/docker-compose.coolify.env.example +++ b/docker-compose.coolify.env.example @@ -61,7 +61,10 @@ RESEND_FROM_DOMAIN= # =================== # For video transcription -DEEPGRAM_API_KEY= +AI_GATEWAY_TRANSCRIPTION_URL= +AI_GATEWAY_TRANSCRIPTION_API_KEY= +AI_GATEWAY_CF_ACCESS_CLIENT_ID= +AI_GATEWAY_CF_ACCESS_CLIENT_SECRET= # For AI summaries (Groq is preferred, OpenAI as fallback) GROQ_API_KEY= diff --git a/packages/env/server.ts b/packages/env/server.ts index 1b3602d2374..d513f5142db 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -121,7 +121,10 @@ function createServerEnv() { .string() .optional() .describe("LiteLLM public model name for AI summaries and chat"), - DEEPGRAM_API_KEY: z.string().optional().describe("Audio transcription"), + AI_GATEWAY_TRANSCRIPTION_URL: z.string().optional(), + AI_GATEWAY_TRANSCRIPTION_API_KEY: z.string().optional(), + AI_GATEWAY_CF_ACCESS_CLIENT_ID: z.string().optional(), + AI_GATEWAY_CF_ACCESS_CLIENT_SECRET: z.string().optional(), ANTHROPIC_API_KEY: z.string().optional().describe("AI chat"), OPENAI_API_KEY: z.string().optional().describe("AI summaries"), GROQ_API_KEY: z.string().optional().describe("AI summaries"), From 978d8e20caf1770fb43d409ba8601b155407c51a Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Thu, 29 Oct 2026 13:07:06 -0600 Subject: [PATCH 2/9] fix(transcription): align gateway runtime secret key --- deploy/k8s/chart/values.yaml | 2 +- deploy/transcription-gateway.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/k8s/chart/values.yaml b/deploy/k8s/chart/values.yaml index 81866e28b66..d9acc75eb0f 100644 --- a/deploy/k8s/chart/values.yaml +++ b/deploy/k8s/chart/values.yaml @@ -25,7 +25,7 @@ runtimeSecret: aiGatewayApiKey: ai-gateway-api-key openaiApiKey: openai-api-key groqApiKey: groq-api-key - transcriptionGatewayApiKey: transcription-gateway-api-key + transcriptionGatewayApiKey: ai-gateway-transcription-api-key replicateApiToken: replicate-api-token workflowTargetWorld: workflow-target-world workflowPostgresUrl: workflow-postgres-url diff --git a/deploy/transcription-gateway.md b/deploy/transcription-gateway.md index 6205ac4e089..06645df9bb3 100644 --- a/deploy/transcription-gateway.md +++ b/deploy/transcription-gateway.md @@ -16,7 +16,7 @@ passthrough route `/media/cap/nova-3/transcribe`. It is separate from the key used by Cap chat and summaries. Do not give Cap the provider's Deepgram key. The Helm chart reads the gateway key from `cap-runtime`, property -`transcription-gateway-api-key`. Set `transcriptionGateway.enabled=true` and +`ai-gateway-transcription-api-key`. Set `transcriptionGateway.enabled=true` and `transcriptionGateway.url` to the internal URL for Kubernetes. Production values already select these settings. The optional chart-created secret accepts `runtimeSecret.values.transcriptionGatewayApiKey`. @@ -77,7 +77,7 @@ Do not merge or deploy the Cap migration before the following checks pass: 3. Use approved synthetic audio to verify the gateway preserves raw bytes, repeated language parameters, raw utterances, punctuation, and timestamps. 4. Confirm gateway logs contain no audio or transcript data. -5. Provision `cap-runtime/transcription-gateway-api-key`. +5. Provision `cap-runtime/ai-gateway-transcription-api-key`. 6. Publish a matching Cap image and apply its chart configuration together. 7. Verify the authorized recording, transcription, captions, language selection, status, and retry flow after deployment. From 7c8c91317f7e5ce111fcc9ea696ccbd303a85b39 Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Mon, 2 Nov 2026 10:17:39 -0700 Subject: [PATCH 3/9] fix(transcription): preserve the gateway port in SDK requests --- apps/web/content/docs/self-hosting.mdx | 9 ++++++++- apps/web/workflows/transcribe.ts | 5 +++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 0413f36a633..f6900facbad 100644 --- a/apps/web/content/docs/self-hosting.mdx +++ b/apps/web/content/docs/self-hosting.mdx @@ -113,12 +113,19 @@ By default, login links are printed to the server logs. To send emails: For video transcription and AI summaries: ```bash -AI_GATEWAY_TRANSCRIPTION_URL=your-gateway-endpoint # Nova-3 raw transcription +AI_GATEWAY_TRANSCRIPTION_URL=https://ai-gateway.take3tech.dev/media/cap/nova-3/transcribe AI_GATEWAY_TRANSCRIPTION_API_KEY=your-scoped-gateway-key +AI_GATEWAY_CF_ACCESS_CLIENT_ID=your-access-client-id +AI_GATEWAY_CF_ACCESS_CLIENT_SECRET=your-access-client-secret GROQ_API_KEY=your-key # AI summaries (preferred) OPENAI_API_KEY=your-key # AI summaries (fallback) ``` +The public gateway requires both Cloudflare Access credentials. The only +alternative is the internal cluster URL +`http://ai-gateway-litellm.ai-gateway.svc.cluster.local:4000/media/cap/nova-3/transcribe`. +Keep this exact path and omit query strings and fragments. + ### Google OAuth ```bash diff --git a/apps/web/workflows/transcribe.ts b/apps/web/workflows/transcribe.ts index 7d9cab2629e..909415fa7b0 100644 --- a/apps/web/workflows/transcribe.ts +++ b/apps/web/workflows/transcribe.ts @@ -349,11 +349,12 @@ async function transcribeWithDeepgram( throw new FatalError( "Missing or invalid transcription gateway configuration", ); + const gatewayUrl = new URL(gateway.endpoint); const deepgram = createClient(gateway.apiKey, { global: { fetch: { options: { - url: gateway.endpoint, + url: gatewayUrl.origin, headers: gateway.headers, redirect: "error", }, @@ -364,7 +365,7 @@ async function transcribeWithDeepgram( const { result, error } = await deepgram.listen.prerecorded.transcribeFile( audioBuffer, getDeepgramTranscriptionOptions(language), - gateway.endpoint, + gatewayUrl.pathname, ); if (error) { From e238eca518830ea0e3b884152b1e2fa95d387aca Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Mon, 2 Nov 2026 10:33:48 -0700 Subject: [PATCH 4/9] fix(transcription): forward gateway settings in Compose --- docker-compose.coolify.yml | 4 ++++ docker-compose.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 3f68cd3244b..8b6767e8f65 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -25,6 +25,10 @@ services: S3_PATH_STYLE: "true" RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_FROM_DOMAIN: ${RESEND_FROM_DOMAIN:-} + AI_GATEWAY_TRANSCRIPTION_URL: ${AI_GATEWAY_TRANSCRIPTION_URL:-} + AI_GATEWAY_TRANSCRIPTION_API_KEY: ${AI_GATEWAY_TRANSCRIPTION_API_KEY:-} + AI_GATEWAY_CF_ACCESS_CLIENT_ID: ${AI_GATEWAY_CF_ACCESS_CLIENT_ID:-} + AI_GATEWAY_CF_ACCESS_CLIENT_SECRET: ${AI_GATEWAY_CF_ACCESS_CLIENT_SECRET:-} MEDIA_SERVER_URL: http://media-server:3456 MEDIA_SERVER_WEBHOOK_SECRET: ${MEDIA_SERVER_WEBHOOK_SECRET} MEDIA_SERVER_WEBHOOK_URL: http://cap-web:3000 diff --git a/docker-compose.yml b/docker-compose.yml index f6172bdd820..752ee89519f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,10 @@ services: S3_PATH_STYLE: "true" RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_FROM_DOMAIN: ${RESEND_FROM_DOMAIN:-} + AI_GATEWAY_TRANSCRIPTION_URL: ${AI_GATEWAY_TRANSCRIPTION_URL:-} + AI_GATEWAY_TRANSCRIPTION_API_KEY: ${AI_GATEWAY_TRANSCRIPTION_API_KEY:-} + AI_GATEWAY_CF_ACCESS_CLIENT_ID: ${AI_GATEWAY_CF_ACCESS_CLIENT_ID:-} + AI_GATEWAY_CF_ACCESS_CLIENT_SECRET: ${AI_GATEWAY_CF_ACCESS_CLIENT_SECRET:-} MEDIA_SERVER_URL: http://media-server:3456 MEDIA_SERVER_WEBHOOK_URL: http://cap-web:3000 ports: From 79ef4fd17a634f6817f30b796098f7271ce1cc0e Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Mon, 2 Nov 2026 10:41:34 -0700 Subject: [PATCH 5/9] fix(transcription): resolve gateway helper in workflow builds --- apps/web/lib/transcribe.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/transcribe.ts b/apps/web/lib/transcribe.ts index f9aa2346381..e2a0cfe0c8b 100644 --- a/apps/web/lib/transcribe.ts +++ b/apps/web/lib/transcribe.ts @@ -3,8 +3,8 @@ import { organizations, videos, videoUploads } from "@cap/database/schema"; import type { Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { start } from "workflow/api"; +import { isTranscriptionConfigured } from "@/lib/transcription-gateway"; import { transcribeVideoWorkflow } from "@/workflows/transcribe"; -import { isTranscriptionConfigured } from "./transcription-gateway"; type TranscribeResult = { success: boolean; From fe072c3d4984d319bd7c48884e89ee0ce4bf0788 Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Mon, 2 Nov 2026 11:26:49 -0700 Subject: [PATCH 6/9] fix(deploy): publish production Cap web images from native builds --- .github/workflows/docker-build-web.yml | 57 +++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-build-web.yml b/.github/workflows/docker-build-web.yml index a227a179b55..f29648b8de5 100644 --- a/.github/workflows/docker-build-web.yml +++ b/.github/workflows/docker-build-web.yml @@ -8,6 +8,11 @@ on: required: false default: "latest" type: string + publish_ecr: + description: "Build for Cap production and publish the web image to ECR" + required: false + default: false + type: boolean jobs: build: @@ -55,6 +60,11 @@ jobs: context: . file: apps/web/Dockerfile platforms: linux/${{ matrix.platform }} + build-args: | + NEXT_PUBLIC_DOCKER_BUILD=true + NEXT_PUBLIC_WEB_URL=${{ inputs.publish_ecr && 'https://cap.take3tech.dev' || 'http://localhost:3000' }} + TAKE3_DESKTOP_RELEASE_REPOSITORY=${{ inputs.publish_ecr && github.repository || 'CapSoftware/Cap' }} + TAKE3_DESKTOP_RELEASE_TAG_PREFIX=take3-desktop- push: true outputs: type=image,name=ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web,push-by-digest=true cache-from: type=gha,scope=buildx-${{ matrix.platform }} @@ -105,6 +115,51 @@ jobs: echo "REPOSITORY_OWNER=${GITHUB_REPOSITORY_OWNER@L}" >>${GITHUB_ENV} - name: Create Image Manifest + env: + IMAGE_TAG: ${{ inputs.tag || 'latest' }} run: | - docker buildx imagetools create -t ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web:${{ inputs.tag || 'latest' }} \ + docker buildx imagetools create -t "ghcr.io/${REPOSITORY_OWNER}/cap-web:${IMAGE_TAG}" \ $(find /tmp/digests -type f -not -path "*/\.*" -exec basename {} \; | xargs -I {} echo "ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web@sha256:{}") + + publish-ecr: + name: Publish Production Web Image + if: ${{ inputs.publish_ecr }} + needs: merge + runs-on: ubuntu-24.04 + environment: production + permissions: + contents: read + packages: read + id-token: write + steps: + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::159207264407:role/cap-github-actions-ecr-publisher + aws-region: us-east-2 + + - name: Log in to Amazon ECR + id: ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Copy production image + env: + IMAGE_TAG: ${{ inputs.tag || 'latest' }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + ECR_REGISTRY: ${{ steps.ecr.outputs.registry }} + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -y skopeo + skopeo copy --all --preserve-digests \ + --src-authfile "$HOME/.docker/config.json" \ + --dest-authfile "$HOME/.docker/config.json" \ + "docker://ghcr.io/${REPOSITORY_OWNER,,}/cap-web:${IMAGE_TAG}" \ + "docker://${ECR_REGISTRY}/cap-web:sha-${GITHUB_SHA}" From e98297f782f9b6e09e46cf7f535dcdf98ac87dea Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Mon, 2 Nov 2026 11:30:16 -0700 Subject: [PATCH 7/9] fix(deploy): align build environment with the production URL --- .github/workflows/docker-build-web.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-build-web.yml b/.github/workflows/docker-build-web.yml index f29648b8de5..617170f772d 100644 --- a/.github/workflows/docker-build-web.yml +++ b/.github/workflows/docker-build-web.yml @@ -29,6 +29,9 @@ jobs: permissions: contents: read packages: write + env: + PUBLIC_WEB_URL: ${{ inputs.publish_ecr && 'https://cap.take3tech.dev' || 'http://localhost:3000' }} + RELEASE_REPOSITORY: ${{ inputs.publish_ecr && github.repository || 'CapSoftware/Cap' }} steps: - name: Checkout Repository @@ -39,7 +42,7 @@ jobs: - name: Create .env file run: | - echo "WEB_URL=http://localhost:3000" > .env + echo "WEB_URL=${PUBLIC_WEB_URL}" > .env echo "NEXT_PUBLIC_DOCKER_BUILD=true" >> .env - name: Login to GitHub Container Registry @@ -62,8 +65,8 @@ jobs: platforms: linux/${{ matrix.platform }} build-args: | NEXT_PUBLIC_DOCKER_BUILD=true - NEXT_PUBLIC_WEB_URL=${{ inputs.publish_ecr && 'https://cap.take3tech.dev' || 'http://localhost:3000' }} - TAKE3_DESKTOP_RELEASE_REPOSITORY=${{ inputs.publish_ecr && github.repository || 'CapSoftware/Cap' }} + NEXT_PUBLIC_WEB_URL=${{ env.PUBLIC_WEB_URL }} + TAKE3_DESKTOP_RELEASE_REPOSITORY=${{ env.RELEASE_REPOSITORY }} TAKE3_DESKTOP_RELEASE_TAG_PREFIX=take3-desktop- push: true outputs: type=image,name=ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web,push-by-digest=true From 8460532cd3862ac4f3e263ed82b6b5ff8158af63 Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Mon, 2 Nov 2026 11:40:58 -0700 Subject: [PATCH 8/9] fix(deploy): copy Cap production images from a run-specific reference --- .github/workflows/docker-build-web.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-build-web.yml b/.github/workflows/docker-build-web.yml index 617170f772d..0fdcd881e44 100644 --- a/.github/workflows/docker-build-web.yml +++ b/.github/workflows/docker-build-web.yml @@ -91,6 +91,8 @@ jobs: name: Create Multi-Architecture Manifest needs: build runs-on: ubuntu-latest + outputs: + production_image: ${{ steps.manifest.outputs.production_image }} permissions: contents: read packages: write @@ -118,11 +120,16 @@ jobs: echo "REPOSITORY_OWNER=${GITHUB_REPOSITORY_OWNER@L}" >>${GITHUB_ENV} - name: Create Image Manifest + id: manifest env: IMAGE_TAG: ${{ inputs.tag || 'latest' }} run: | - docker buildx imagetools create -t "ghcr.io/${REPOSITORY_OWNER}/cap-web:${IMAGE_TAG}" \ + production_image="ghcr.io/${REPOSITORY_OWNER}/cap-web:run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + docker buildx imagetools create \ + -t "ghcr.io/${REPOSITORY_OWNER}/cap-web:${IMAGE_TAG}" \ + -t "$production_image" \ $(find /tmp/digests -type f -not -path "*/\.*" -exec basename {} \; | xargs -I {} echo "ghcr.io/${{ env.REPOSITORY_OWNER }}/cap-web@sha256:{}") + echo "production_image=$production_image" >> "$GITHUB_OUTPUT" publish-ecr: name: Publish Production Web Image @@ -154,8 +161,7 @@ jobs: - name: Copy production image env: - IMAGE_TAG: ${{ inputs.tag || 'latest' }} - REPOSITORY_OWNER: ${{ github.repository_owner }} + SOURCE_IMAGE: ${{ needs.merge.outputs.production_image }} ECR_REGISTRY: ${{ steps.ecr.outputs.registry }} run: | set -euo pipefail @@ -164,5 +170,5 @@ jobs: skopeo copy --all --preserve-digests \ --src-authfile "$HOME/.docker/config.json" \ --dest-authfile "$HOME/.docker/config.json" \ - "docker://ghcr.io/${REPOSITORY_OWNER,,}/cap-web:${IMAGE_TAG}" \ + "docker://${SOURCE_IMAGE}" \ "docker://${ECR_REGISTRY}/cap-web:sha-${GITHUB_SHA}" From c658fb25d582cd99664c2968b3d4566c0d8d8942 Mon Sep 17 00:00:00 2001 From: Brad Butner Date: Mon, 2 Nov 2026 11:46:31 -0700 Subject: [PATCH 9/9] chore(deploy): pin the verified LiteLLM Cap web image --- deploy/k8s/environments/prod/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/k8s/environments/prod/values.yaml b/deploy/k8s/environments/prod/values.yaml index 535f2a00912..e2c8c642727 100644 --- a/deploy/k8s/environments/prod/values.yaml +++ b/deploy/k8s/environments/prod/values.yaml @@ -1,7 +1,7 @@ web: image: repository: 159207264407.dkr.ecr.us-east-2.amazonaws.com/cap-web - tag: sha-4c248ce6c4b6f789924cd735c6e666325c94139a + tag: sha-e98297f782f9b6e09e46cf7f535dcdf98ac87dea pullPolicy: IfNotPresent publicUrl: https://cap.take3tech.dev nextauthUrl: https://cap.take3tech.dev