Skip to content
Open
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: 3 additions & 3 deletions apps/web/__tests__/unit/generate-ai-title.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ vi.mock("@cap/web-backend", () => ({
Storage: {},
}));

vi.mock("@/lib/groq-client", () => ({
GROQ_MODEL: "test-model",
getGroqClient: vi.fn(() => null),
vi.mock("@/lib/ai-provider", () => ({
getAiClient: vi.fn(() => null),
getAiModel: vi.fn(() => "test-model"),
}));

vi.mock("@/lib/server", () => ({
Expand Down
6 changes: 3 additions & 3 deletions apps/web/actions/videos/get-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
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";
import { Effect, Exit } from "effect";
import { isAiConfigured, isSttConfigured } from "@/lib/ai-provider";
import {
isRetryableDesktopSegmentsFinalizationError,
queueDesktopSegmentsFinalization,
Expand Down Expand Up @@ -60,7 +60,7 @@ export async function getVideoStatus(

const metadata: VideoMetadata = (video.metadata as VideoMetadata) || {};

if (!video.transcriptionStatus && serverEnv().DEEPGRAM_API_KEY) {
if (!video.transcriptionStatus && isSttConfigured()) {
const activeUpload = await db()
.select({
videoId: videoUploads.videoId,
Expand Down Expand Up @@ -151,7 +151,7 @@ export async function getVideoStatus(
video.transcriptionStatus === "COMPLETE" &&
!metadata.aiGenerationStatus &&
!metadata.summary &&
(serverEnv().GROQ_API_KEY || serverEnv().OPENAI_API_KEY);
isAiConfigured();

if (shouldTriggerAiGeneration) {
try {
Expand Down
14 changes: 7 additions & 7 deletions apps/web/actions/videos/translate-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Storage } from "@cap/web-backend";
import type { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { Effect, Option } from "effect";
import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client";
import { getAiClient, getAiModel } from "@/lib/ai-provider";
import { runPromise } from "@/lib/server";
import { decodeStorageVideo } from "@/lib/video-storage";
import {
Expand Down Expand Up @@ -38,8 +38,8 @@ export async function translateTranscript(
};
}

const groq = getGroqClient();
if (!groq) {
const ai = getAiClient();
if (!ai) {
return {
success: false,
message: "Translation service not configured",
Expand Down Expand Up @@ -94,7 +94,7 @@ export async function translateTranscript(
const translatedVtt = await translateVttContent(
originalVtt.value,
targetLanguage,
groq,
ai,
);

if (!translatedVtt) {
Expand Down Expand Up @@ -124,7 +124,7 @@ export async function translateTranscript(
async function translateVttContent(
vttContent: string,
targetLanguage: LanguageCode,
groq: NonNullable<ReturnType<typeof getGroqClient>>,
ai: NonNullable<ReturnType<typeof getAiClient>>,
): Promise<string | null> {
const targetLanguageName = SUPPORTED_LANGUAGES[targetLanguage];

Expand All @@ -144,8 +144,8 @@ VTT content to translate:
${vttContent}`;

try {
const response = await groq.chat.completions.create({
model: GROQ_MODEL,
const response = await ai.chat.completions.create({
model: getAiModel(),
messages: [{ role: "user", content: prompt }],
temperature: 0.3,
max_tokens: 8000,
Expand Down
124 changes: 124 additions & 0 deletions apps/web/lib/ai-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import "server-only";

import { serverEnv } from "@cap/env";
import OpenAI from "openai";

const GROQ_DEFAULT_BASE_URL = "https://api.groq.com/openai/v1";
const GROQ_DEFAULT_MODEL = "openai/gpt-oss-120b";
const OPENAI_DEFAULT_MODEL = "gpt-4o-mini";
const OPENAI_WHISPER_DEFAULT_MODEL = "whisper-1";

const AI_TIMEOUT_MS = 120_000;
const STT_TIMEOUT_MS = 300_000;
const MAX_RETRIES = 2;

export function isAiConfigured(): boolean {
const env = serverEnv();
return Boolean(env.AI_BASE_URL || env.GROQ_API_KEY || env.OPENAI_API_KEY);
}

export function isSttConfigured(): boolean {
const env = serverEnv();
return Boolean(env.STT_BASE_URL || env.DEEPGRAM_API_KEY);
}

export function getAiClient(): OpenAI | null {
const env = serverEnv();

if (env.AI_BASE_URL) {
if (!env.AI_API_KEY) {
throw new Error(
"AI_API_KEY is required when AI_BASE_URL is set. Use any non-empty string for local providers that ignore auth.",
);
}
if (!env.AI_MODEL) {
throw new Error("AI_MODEL is required when AI_BASE_URL is set.");
}
return new OpenAI({
baseURL: env.AI_BASE_URL,
Comment thread
kovashikawa marked this conversation as resolved.
apiKey: env.AI_API_KEY,
timeout: AI_TIMEOUT_MS,
maxRetries: MAX_RETRIES,
});
}

if (env.GROQ_API_KEY) {
return new OpenAI({
baseURL: GROQ_DEFAULT_BASE_URL,
apiKey: env.GROQ_API_KEY,
timeout: AI_TIMEOUT_MS,
maxRetries: MAX_RETRIES,
});
}

if (env.OPENAI_API_KEY) {
return new OpenAI({
apiKey: env.OPENAI_API_KEY,
timeout: AI_TIMEOUT_MS,
maxRetries: MAX_RETRIES,
});
}

return null;
}

export function getAiFallbackClient(): {
client: OpenAI;
model: string;
} | null {
const env = serverEnv();
if (env.AI_BASE_URL) return null;
if (!env.GROQ_API_KEY || !env.OPENAI_API_KEY) return null;
return {
client: new OpenAI({
apiKey: env.OPENAI_API_KEY,
timeout: AI_TIMEOUT_MS,
maxRetries: MAX_RETRIES,
}),
model: OPENAI_DEFAULT_MODEL,
};
}

export function getAiModel(): string {
const env = serverEnv();
if (env.AI_BASE_URL) {
if (!env.AI_MODEL) {
throw new Error("AI_MODEL is required when AI_BASE_URL is set.");
}
return env.AI_MODEL;
}
if (env.GROQ_API_KEY) return GROQ_DEFAULT_MODEL;
return OPENAI_DEFAULT_MODEL;
}

export function getSttClient(): OpenAI | null {
const env = serverEnv();
if (!env.STT_BASE_URL) return null;

if (!env.STT_API_KEY) {
throw new Error(
"STT_API_KEY is required when STT_BASE_URL is set. Use any non-empty string for local providers that ignore auth.",
);
}
if (!env.STT_MODEL) {
throw new Error("STT_MODEL is required when STT_BASE_URL is set.");
}

return new OpenAI({
baseURL: env.STT_BASE_URL,
apiKey: env.STT_API_KEY,
timeout: STT_TIMEOUT_MS,
maxRetries: MAX_RETRIES,
});
}

export function getSttModel(): string {
const env = serverEnv();
if (env.STT_BASE_URL) {
if (!env.STT_MODEL) {
throw new Error("STT_MODEL is required when STT_BASE_URL is set.");
}
return env.STT_MODEL;
}
return OPENAI_WHISPER_DEFAULT_MODEL;
}
7 changes: 4 additions & 3 deletions apps/web/lib/generate-ai.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { db } from "@cap/database";
import { videos } from "@cap/database/schema";
import type { VideoMetadata } from "@cap/database/types";
import { serverEnv } from "@cap/env";
import type { Video } from "@cap/web-domain";
import { eq } from "drizzle-orm";
import { start } from "workflow/api";
import { isAiConfigured } from "@/lib/ai-provider";
import { generateAiWorkflow } from "@/workflows/generate-ai";

type GenerateAiResult = {
Expand All @@ -16,10 +16,11 @@ export async function startAiGeneration(
videoId: Video.VideoId,
userId: string,
): Promise<GenerateAiResult> {
if (!serverEnv().GROQ_API_KEY && !serverEnv().OPENAI_API_KEY) {
if (!isAiConfigured()) {
return {
success: false,
message: "Missing AI API keys (Groq or OpenAI)",
message:
"No AI provider configured (set AI_BASE_URL, GROQ_API_KEY, or OPENAI_API_KEY)",
};
}

Expand Down
20 changes: 0 additions & 20 deletions apps/web/lib/groq-client.ts

This file was deleted.

14 changes: 8 additions & 6 deletions apps/web/lib/messenger/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import "server-only";

import type { MessengerMessageRole } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
import { GROQ_MODEL, getGroqClient } from "@/lib/groq-client";
import { getAiClient, getAiModel } from "@/lib/ai-provider";
import { CAP_REFERENCE_GUIDE, MESSENGER_AGENT_PROMPT } from "./constants";
import { getKnowledgeTag, searchSupermemory } from "./supermemory";

Expand Down Expand Up @@ -165,18 +165,18 @@ const callOpenAi = async ({
return parseOpenAiContent(payload);
};

const callGroq = async ({
const callAiProvider = async ({
systemPrompt,
history,
}: {
systemPrompt: string;
history: ConversationMessage[];
}) => {
const client = getGroqClient();
const client = getAiClient();
if (!client) return null;

const completion = await client.chat.completions.create({
model: GROQ_MODEL,
model: getAiModel(),
temperature: 0.65,
max_tokens: 500,
messages: [
Expand Down Expand Up @@ -227,8 +227,10 @@ export const generateMessengerAgentReply = async ({
);
if (fromOpenAi) return fromOpenAi;

const fromGroq = await callGroq({ systemPrompt, history }).catch(() => null);
if (fromGroq) return fromGroq;
const fromAi = await callAiProvider({ systemPrompt, history }).catch(
() => null,
);
if (fromAi) return fromAi;

return "Oh no, I'm so sorry about this! I'm having a little technical hiccup on my end. Someone from the team will jump in here shortly to help you out though!";
};
7 changes: 4 additions & 3 deletions apps/web/lib/transcribe.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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 { isSttConfigured } from "@/lib/ai-provider";
import { transcribeVideoWorkflow } from "@/workflows/transcribe";

type TranscribeResult = {
Expand All @@ -17,10 +17,11 @@ export async function transcribeVideo(
aiGenerationEnabled = false,
_isRetry = false,
): Promise<TranscribeResult> {
if (!serverEnv().DEEPGRAM_API_KEY) {
if (!isSttConfigured()) {
return {
success: false,
message: "Missing necessary environment variables",
message:
"No transcription provider configured (set DEEPGRAM_API_KEY or STT_BASE_URL)",
};
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@
"framer-motion": "^11.13.1",
"geist": "^1.3.1",
"gif.js": "0.2.0",
"groq-sdk": "^0.29.0",
"hls.js": "^1.5.3",
"hono": "^4.7.1",
"js-cookie": "^3.0.5",
Expand All @@ -114,6 +113,7 @@
"next": "16.2.1",
"next-auth": "^4.24.5",
"next-mdx-remote": "^6.0.0",
"openai": "^4.86.0",
"posthog-js": "^1.215.3",
"posthog-node": "^4.11.2",
"prettier": "^3.7.4",
Expand Down
Loading