Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save brandonbryant12/14d79c506ceae35d53c1304b532a0848 to your computer and use it in GitHub Desktop.
Save brandonbryant12/14d79c506ceae35d53c1304b532a0848 to your computer and use it in GitHub Desktop.
// server/lib/azure-openai.ts
import dotenv from 'dotenv';
import OpenAI from "openai";
import { encode, decode } from "gpt-3-encoder";
// Load environment variables from .env file
dotenv.config();
interface TokenCache {
token: string;
expiresAt: number;
}
let tokenCache: TokenCache | null = null;
export function getAzureOpenAIClient() {
// Create the custom fetch handler for authentication if needed
const customFetch = async (
url: RequestInfo | URL,
init?: RequestInit
) => {
// Get the authentication token if needed
// This can be replaced with your actual auth token retrieval logic
const token = await getAuthToken();
// Add the token to the Authorization header if using AAD auth
// Otherwise, the API key is used directly in the client config
const headers = {
...init?.headers,
"Authorization": `Bearer ${token}`
};
// Return the fetch with added headers
return fetch(url, {
...init,
headers
});
};
// Create the Azure OpenAI client
const client = new OpenAI({
apiKey: process.env.AZURE_OPENAI_API_KEY,
baseURL: `${process.env.AZURE_OPENAI_ENDPOINT}/openai/deployments/${process.env.AZURE_OPENAI_DEPLOYMENT}`,
defaultQuery: { "api-version": process.env.AZURE_OPENAI_API_VERSION || "2024-02-01" },
fetch: customFetch, // Only include if using AAD token authentication
maxRetries: 3,
timeout: 60000, // 60 second timeout
});
return client;
}
async function getAuthToken(): Promise<string> {
const now = Date.now();
// Check if we have a valid cached token
if (tokenCache && now < tokenCache.expiresAt) {
return tokenCache.token;
}
// If no valid token in cache, fetch a new one
// This is where you'd make the actual token fetch request
// For now, we're using a dummy implementation
const newToken = "dummy-auth-token";
// Cache the token with a TTL of 55 minutes (in milliseconds)
const ttlMs = 55 * 60 * 1000;
tokenCache = {
token: newToken,
expiresAt: now + ttlMs
};
return newToken;
}
// Utility functions for content management
export function countTokens(text: string): number {
return encode(text).length;
}
export function chunkContent(text: string, maxTokens: number = 2000): string[] {
const tokens = encode(text);
const chunks: string[] = [];
let currentChunk: number[] = [];
for (let i = 0; i < tokens.length; i++) {
currentChunk.push(tokens[i]);
if (currentChunk.length >= maxTokens) {
// Find the last period to make clean breaks
const text = decodeTokens(currentChunk);
const lastPeriod = text.lastIndexOf('.');
if (lastPeriod !== -1) {
chunks.push(text.substring(0, lastPeriod + 1));
// Keep the remainder for next chunk
const remainder = text.substring(lastPeriod + 1);
currentChunk = encode(remainder);
} else {
chunks.push(text);
currentChunk = [];
}
}
}
if (currentChunk.length > 0) {
chunks.push(decodeTokens(currentChunk));
}
return chunks;
}
function decodeTokens(tokens: number[]): string {
return decode(tokens);
}
// You could also add a timeout wrapper for API calls
export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
);
return Promise.race([promise, timeout]);
}
// Error handling for OpenAI API calls
export function handleOpenAIError(error: any): never {
console.error("OpenAI API Error:", error);
if (error?.message?.includes('Request timed out')) {
throw new Error(
"Request to Azure OpenAI API timed out. Please try again."
);
}
if (error?.status === 429 ||
error?.error?.type === "insufficient_quota" ||
error?.error?.code === "billing_hard_limit_reached") {
throw new Error(
"Azure OpenAI API quota exceeded. Please try again later or contact support."
);
}
if (error?.code === "ECONNRESET" || error?.code === "ETIMEDOUT") {
throw new Error(
"Connection error while processing content. Please try again."
);
}
throw error;
}
// server/lib/openai.ts
import dotenv from 'dotenv';
// Load environment variables from .env file
dotenv.config();
import OpenAI from "openai";
import { z } from "zod";
import axios from "axios";
import { transcriptSchema } from "@shared/schema";
import { encode, decode } from "gpt-3-encoder";
import { getAzureOpenAIClient } from './getOpenAIClient';
const openai = getAzureOpenAIClient();
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Request timed out')), timeoutMs)
);
return Promise.race([promise, timeout]);
}
export type VoiceType = "ash" | "sage";
export async function generateSpeech(text: string, voice: VoiceType) {
const maxRetries = 3;
let retryCount = 0;
let lastError;
while (retryCount < maxRetries) {
try {
const voiceMapping = {
ash: "onyx" as const, // Using a more stable voice
sage: "nova" as const, // Using a more stable voice
};
const response = await openai.audio.speech.create({
model: "tts-1",
voice: voiceMapping[voice],
input: text,
response_format: "mp3",
});
const buffer = Buffer.from(await response.arrayBuffer());
return buffer;
} catch (error: any) {
lastError = error;
retryCount++;
// If it's a final retry, or if it's a non-retryable error, throw immediately
if (retryCount === maxRetries ||
(error.status && error.status !== 408 && error.status !== 500 && error.status !== 503)) {
break;
}
// Exponential backoff
await new Promise(resolve => setTimeout(resolve, Math.pow(2, retryCount) * 1000));
}
}
handleOpenAIError(lastError);
}
class OpenAIQuotaError extends Error {
constructor(message: string) {
super(message);
this.name = "OpenAIQuotaError";
}
}
function handleOpenAIError(error: any): never {
if (!process.env.AZURE_OPENAI_API_KEY) {
throw new Error(
"Azure OpenAI API key is not configured. Please check your environment variables.",
);
}
console.error("OpenAI API Error:", error);
if (error?.message?.includes('Request timed out')) {
throw new Error(
"Request to Azure OpenAI API timed out. Please try again.",
);
}
if (error?.status === 429 ||
error?.error?.type === "insufficient_quota" ||
error?.error?.code === "billing_hard_limit_reached") {
throw new OpenAIQuotaError(
"Azure OpenAI API quota exceeded. Please try again later or contact support.",
);
}
if (error?.code === "ECONNRESET" || error?.code === "ETIMEDOUT") {
throw new Error(
"Connection error while processing content. Please try again.",
);
}
throw error;
}
// Add new utility functions for content chunking
function countTokens(text: string): number {
return encode(text).length;
}
function chunkContent(text: string, maxTokens: number = 2000): string[] {
const tokens = encode(text);
const chunks: string[] = [];
let currentChunk: number[] = [];
// Add overlap to maintain context between chunks
const overlap = 100;
for (let i = 0; i < tokens.length; i++) {
currentChunk.push(tokens[i]);
if (currentChunk.length >= maxTokens) {
// Find the last period to make clean breaks
const text = decodeTokens(currentChunk);
const lastPeriod = text.lastIndexOf('.');
if (lastPeriod !== -1) {
chunks.push(text.substring(0, lastPeriod + 1));
// Keep the remainder for next chunk, including overlap
const remainder = text.substring(lastPeriod + 1);
currentChunk = encode(remainder);
} else {
chunks.push(text);
currentChunk = [];
}
}
}
if (currentChunk.length > 0) {
chunks.push(decodeTokens(currentChunk));
}
return chunks;
}
// Fix: Changed name from 'decode' to 'decodeTokens' to avoid conflict with imported function
function decodeTokens(tokens: number[]): string {
return decode(tokens);
}
// The newest OpenAI model is "gpt-4o" which was released May 13, 2024
const CHAT_MODEL = "gpt-4o";
export async function generateSummary(text: string, customPrompt?: string) {
try {
const totalTokens = countTokens(text);
if (totalTokens > 800000) {
throw new Error(`Content is too large (${totalTokens} tokens). Maximum allowed is 800000 tokens.`);
}
// If content is large, chunk it and process sequentially
if (totalTokens > 2000) {
console.log(`Content is large (${totalTokens} tokens), processing in chunks...`);
const chunks = chunkContent(text);
const summaries = await Promise.all(
chunks.map(chunk => withTimeout(processChunk(chunk, customPrompt), 30000))
);
// Combine summaries
return combineSummaries(summaries);
}
// For small content, process normally
return await withTimeout(processChunk(text, customPrompt), 30000);
} catch (error) {
console.error("Summary generation error:", error);
handleOpenAIError(error);
}
}
async function processChunk(text: string, customPrompt?: string) {
const systemPrompt = customPrompt
? `You are an expert content summarizer. ${customPrompt}. Provide response in JSON format with a non-empty title field and topics array.`
: `You are an expert content summarizer. Provide a concise summary of this section in JSON format with these fields:
- title: A clear, descriptive title for this section (REQUIRED, must not be empty)
- summary: A 2-3 sentence summary of the main points
- duration: Estimated reading time in minutes
- topics: Array of 2-4 main topics covered (REQUIRED, must be non-empty array of strings)
Each topic should be a clear, actionable item starting with a verb (e.g., "Understand market trends", "Review financial statements").
Return the response as a JSON object.`;
const response = await openai.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT || CHAT_MODEL,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: text,
},
],
response_format: { type: "json_object" },
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error("Failed to generate summary: Empty response from OpenAI");
}
console.log("Raw OpenAI response for summary:", content);
const parsedContent = JSON.parse(content);
console.log("Parsed topics before validation:", parsedContent.topics);
// Validate the required title field
if (!parsedContent.title || typeof parsedContent.title !== 'string' || parsedContent.title.trim() === '') {
parsedContent.title = "Content Summary";
console.log("Warning: Missing or invalid title, using default title");
}
// Process topics array, providing defaults if needed
if (!Array.isArray(parsedContent.topics)) {
console.log("Warning: Topics is not an array, creating default topics");
parsedContent.topics = ["Understand the main concepts", "Review key points", "Apply the knowledge"];
} else {
// Filter out invalid topics
const validTopics = parsedContent.topics
.filter((topic: any) => typeof topic === 'string' && topic.trim() !== '')
.map((topic: string) => topic.trim());
// If no valid topics remain, provide defaults instead of throwing an error
if (validTopics.length === 0) {
console.log("Warning: No valid topics found, using default topics");
parsedContent.topics = ["Understand the main concepts", "Review key points", "Apply the knowledge"];
} else {
parsedContent.topics = validTopics;
}
}
console.log("Topics after validation:", parsedContent.topics);
// Ensure summary is valid
if (!parsedContent.summary || typeof parsedContent.summary !== 'string' || parsedContent.summary.trim() === '') {
parsedContent.summary = "A summary of the content highlighting key points and concepts.";
console.log("Warning: Missing or invalid summary, using default summary");
}
// Ensure duration is valid
if (typeof parsedContent.duration !== 'number' || isNaN(parsedContent.duration)) {
parsedContent.duration = Math.ceil(text.length / 1500); // Rough estimate: ~1500 chars per minute
console.log("Warning: Missing or invalid duration, using estimated duration");
}
return parsedContent;
}
function combineSummaries(summaries: any[]): any {
return {
title: summaries[0].title,
summary: summaries.map(s => s.summary).join(' '),
duration: summaries.reduce((acc, s) => acc + s.duration, 0),
topics: Array.from(new Set(summaries.flatMap(s => s.topics))),
};
}
export async function generateDialogue(text: string, customPrompt?: string) {
try {
const totalTokens = countTokens(text);
if (totalTokens > 800000) {
throw new Error(`Content is too large (${totalTokens} tokens). Maximum allowed is 800000 tokens.`);
}
// For large content, process in chunks
if (totalTokens > 2000) {
console.log(`Content is large (${totalTokens} tokens), processing dialogue in chunks...`);
const chunks = chunkContent(text);
const dialogues = await Promise.all(
chunks.map(chunk => withTimeout(processDialogueChunk(chunk, customPrompt), 30000))
);
// Combine dialogue segments
return combineDialogues(dialogues);
}
// For small content, process normally
return await withTimeout(processDialogueChunk(text, customPrompt), 30000);
} catch (error) {
console.error("Dialogue generation error:", error);
handleOpenAIError(error);
}
}
async function processDialogueChunk(text: string, customPrompt?: string) {
const baseSystemPrompt = `You are a master podcast scriptwriter specializing in creating highly engaging, natural conversations. ${customPrompt || "Transform this section of content into an emotionally rich dialogue segment"}
Return the response in this JSON format:
{
"segments": [
{
"speaker": "string", // either "Ash" or "Sage"
"text": "string", // MUST include emotional markers
"timestamp": number // increment by 5-10 seconds per segment
}
]
}`;
const response = await openai.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT || CHAT_MODEL,
messages: [
{
role: "system",
content: baseSystemPrompt,
},
{
role: "user",
content: text,
},
],
response_format: { type: "json_object" },
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error("Failed to generate dialogue: Empty response from OpenAI");
}
return transcriptSchema.parse(JSON.parse(content));
}
function combineDialogues(dialogues: any[]): any {
let timestamp = 0;
const combinedSegments = dialogues.flatMap(dialogue => {
const segments = dialogue.segments.map((segment: any) => ({
...segment,
timestamp: timestamp + segment.timestamp,
}));
timestamp = segments[segments.length - 1].timestamp;
return segments;
});
return {
segments: combinedSegments,
};
}
export async function translateDialogue(
transcript: {
segments: Array<{ speaker: string; text: string; timestamp: number }>;
},
targetLanguage: string,
) {
try {
const response = await openai.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT || CHAT_MODEL,
messages: [
{
role: "system",
content: `You are an expert translator. Translate the given podcast transcript to ${targetLanguage} while maintaining the natural conversational flow, emotional markers, and personality traits. Keep the same speakers and timing, only translate the text.
Return the response in this JSON format:
{
"segments": [
{
"speaker": "string", // keep original speaker names
"text": "string", // translated text including emotional markers
"timestamp": number // keep original timestamps
}
]
}`,
},
{
role: "user",
content: JSON.stringify(transcript),
},
],
response_format: { type: "json_object" },
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error(
"Failed to translate dialogue: Empty response from OpenAI",
);
}
return transcriptSchema.parse(JSON.parse(content));
} catch (error) {
handleOpenAIError(error);
}
}
export async function generateQuizQuestions(transcript: {
segments: Array<{ speaker: string; text: string; timestamp: number }>;
}) {
try {
const response = await openai.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT || CHAT_MODEL,
messages: [
{
role: "system",
content: `You are an expert educator specializing in creating engaging quiz questions. Generate thought-provoking questions based on the podcast transcript.
Guidelines for questions:
1. Create questions that test understanding, not just recall
2. Include interesting wrong options that seem plausible
3. Provide helpful explanations for the correct answers
4. Reference specific timestamps from the content
5. Mix different types of questions:
- Main concept understanding
- Detail comprehension
- Application of ideas
- Critical thinking
Return the response in this JSON format:
{
"questions": [
{
"question": string, // The actual question
"options": string[], // Array of 4 possible answers
"correctAnswer": number, // Index of correct answer (0-3)
"explanation": string, // Detailed explanation of why this is correct
"timestamp": number // Timestamp in podcast where this is discussed
}
]
}
Generate 5 questions that cover different aspects of the content.`,
},
{
role: "user",
content: JSON.stringify(transcript),
},
],
response_format: { type: "json_object" },
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error(
"Failed to generate quiz questions: Empty response from OpenAI",
);
}
return JSON.parse(content);
} catch (error) {
console.error("Quiz generation error:", error);
throw new Error(
`Failed to generate quiz: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
export async function analyzeFinancialStatement(content: string) {
try {
const response = await openai.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT || CHAT_MODEL,
messages: [
{
role: "system",
content: `You are a financial expert. Analyze this financial statement and extract key financial data.
Format the response as a JSON object with these fields:
{
"summary": "Brief, clear overview of financial status",
"insights": ["Key insight 1", "Key insight 2", ...],
"categories": {
"cash": number,
"stocks": number,
"bonds": number,
"other": number
},
"monthlyTotal": number,
"portfolio": {
"totalValue": number,
"returns": {
"monthly": number,
"quarterly": number,
"yearly": number
},
"assetAllocation": {
"stocks": number,
"bonds": number,
"cash": number,
"other": number
},
"topHoldings": [
{
"name": string,
"value": number,
"percentage": number,
"type": string
}
],
"riskMetrics": {
"volatility": number,
"sharpeRatio": number,
"beta": number
}
}
}
Important:
1. All number values must be provided and not null
2. Use 0 as default if a value cannot be determined
3. Returns should be decimal percentages (e.g., 0.05 for 5%)
4. Ensure all required fields are present with valid values`,
},
{
role: "user",
content: content,
},
],
response_format: { type: "json_object" },
});
const result = response.choices[0].message.content;
if (!result) {
throw new Error(
"Failed to analyze statement: Empty response from OpenAI",
);
}
const parsed = JSON.parse(result);
// Add default holdings if not present
if (!parsed.portfolio.topHoldings) {
parsed.portfolio.topHoldings = [];
}
// Process holdings and get tickers
const processedHoldings = await Promise.all(
parsed.portfolio.topHoldings.map(async (holding: any) => {
const ticker = await getStockTickerFromName(holding.name);
return {
...holding,
symbol: ticker || undefined,
};
}),
);
return {
...parsed,
portfolio: {
...parsed.portfolio,
topHoldings: processedHoldings,
},
};
} catch (error) {
console.error("Financial analysis error:", error);
// Return a safe default structure
return {
summary: "Unable to analyze statement",
insights: ["Analysis failed, please try again"],
categories: {
cash: 0,
stocks: 0,
bonds: 0,
other: 0,
},
monthlyTotal: 0,
portfolio: {
totalValue: 0,
returns: {
monthly: 0,
quarterly: 0,
yearly: 0,
},
assetAllocation: {
stocks: 0,
bonds: 0,
cash: 0,
other: 0,
},
topHoldings: [],
riskMetrics: {
volatility: 0,
sharpeRatio: 0,
beta: 0,
},
},
};
}
}
export async function generatePortfolioRadioShow(portfolioAnalysis: any) {
try {
const response = await openai.chat.completions.create({
model: process.env.AZURE_OPENAI_DEPLOYMENT || CHAT_MODEL,
messages: [
{
role: "system",
content: `Create an engaging podcast discussion about this portfolio between two hosts: Ash (the enthusiastic host) and Sage (the expert analyst).
Format as JSON with this structure:
{
"title": "Portfolio Analysis Podcast",
"summary": "Brief overview of what will be discussed",
"sections": [
{
"title": "Portfolio Overview",
"metrics": {
"totalValue": number,
"monthlyReturn": number,
"yearlyReturn": number
}
},
{
"title": "Market Context",
"marketConditions": {
"trend": "bullish|bearish|neutral",
"keyFactors": string[],
"potentialImpact": string
}
},
{
"title": "Risk Analysis",
"metrics": {
"volatility": number,
"sharpeRatio": number,
"beta": number
}
}
],
"transcript": {
"segments": [
{
"speaker": "Ash|Sage",
"text": string,
"timestamp": number,
"section": string
}
]
},
"showMetadata": {
"duration": number,
"marketMood": "bullish|bearish|neutral",
"keyPoints": string[],
"portfolioHighlights": {
"strongestCategory": string,
"improvement": string,
"mainRisk": string
}
}
}
Make it engaging and conversational:
1. Ash should be enthusiastic and ask insightful questions
2. Sage should provide expert analysis and clear explanations
3. Include emotional markers like [excited], [thoughtful], etc.
4. Keep total duration around 3-5 minutes
5. Naturally discuss key metrics and insights
6. Include specific market context and its impact on the portfolio
7. Break down complex metrics into understandable terms`,
},
{
role: "user",
content: JSON.stringify(portfolioAnalysis),
},
],
response_format: { type: "json_object" },
});
const content = response.choices[0].message.content;
if (!content) {
throw new Error(
"Failed to generate radio show script: Empty response from OpenAI",
);
}
return JSON.parse(content);
} catch (error) {
handleOpenAIError(error);
}
}
async function getStockTickerFromName(
companyName: string,
): Promise<string | null> {
try {
// Clean the company name
const cleanedName = companyName
.replace(/\bINC\b|\bCORP\b|\bLTD\b|\bPLC\b|\bETF\b/gi, "")
.replace(/[^a-zA-Z0-9\s]/g, "")
.trim();
// Try to fetch symbol from Alpha Vantage
const response = await axios.get(
`https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=${encodeURIComponent(cleanedName)}&apikey=${process.env.ALPHA_VANTAGE_API_KEY}`,
);
if (response.data.bestMatches && response.data.bestMatches.length > 0) {
return response.data.bestMatches[0]["1. symbol"];
}
return null;
} catch (error) {
console.error("Error finding ticker:", error);
return null;
}
}
export default openai;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment