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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ next-env.d.ts

dump.rdb
deploy.sh
opencode.json
12 changes: 11 additions & 1 deletion src/app/api/editor/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const GET = withRedis(async (_request: NextRequest, redis) => {
messageSliceCount: editor?.messageSliceCount || 200,
inputTokenCost: editor?.inputTokenCost || 0.050,
outputTokenCost: editor?.outputTokenCost || 0.400,
baseUrl: editor?.baseUrl || '',
articleGenerationPeriodMinutes: editor?.articleGenerationPeriodMinutes || 15,
lastArticleGenerationTime: editor?.lastArticleGenerationTime || null,
eventGenerationPeriodMinutes: editor?.eventGenerationPeriodMinutes || 30,
Expand All @@ -25,7 +26,7 @@ export const GET = withRedis(async (_request: NextRequest, redis) => {
// PUT /api/editor - Update editor data
export const PUT = withAuth(async (request: NextRequest, user, redis) => {
const body = await request.json();
const { bio, prompt, modelName, messageSliceCount, inputTokenCost, outputTokenCost, articleGenerationPeriodMinutes, eventGenerationPeriodMinutes, editionGenerationPeriodMinutes } = body;
const { bio, prompt, modelName, messageSliceCount, inputTokenCost, outputTokenCost, baseUrl, articleGenerationPeriodMinutes, eventGenerationPeriodMinutes, editionGenerationPeriodMinutes } = body;

if (typeof bio !== 'string' || typeof prompt !== 'string' || typeof modelName !== 'string') {
return NextResponse.json(
Expand All @@ -34,6 +35,13 @@ export const PUT = withAuth(async (request: NextRequest, user, redis) => {
);
}

if (baseUrl !== undefined && typeof baseUrl !== 'string') {
return NextResponse.json(
{ error: 'baseUrl must be a string if provided' },
{ status: 400 }
);
}

if (typeof messageSliceCount !== 'number' || messageSliceCount < 1 || messageSliceCount > 1000) {
return NextResponse.json(
{ error: 'messageSliceCount must be a number between 1 and 1000' },
Expand Down Expand Up @@ -83,6 +91,7 @@ export const PUT = withAuth(async (request: NextRequest, user, redis) => {
messageSliceCount,
inputTokenCost,
outputTokenCost,
baseUrl: baseUrl || undefined,
articleGenerationPeriodMinutes,
eventGenerationPeriodMinutes,
editionGenerationPeriodMinutes
Expand All @@ -95,6 +104,7 @@ export const PUT = withAuth(async (request: NextRequest, user, redis) => {
messageSliceCount,
inputTokenCost,
outputTokenCost,
baseUrl: baseUrl || '',
articleGenerationPeriodMinutes,
eventGenerationPeriodMinutes,
editionGenerationPeriodMinutes,
Expand Down
66 changes: 45 additions & 21 deletions src/app/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface EditorData {
messageSliceCount: number;
inputTokenCost: number;
outputTokenCost: number;
baseUrl: string;
articleGenerationPeriodMinutes: number;
lastArticleGenerationTime: number | null;
eventGenerationPeriodMinutes: number;
Expand Down Expand Up @@ -52,6 +53,7 @@ export default function EditorPage() {
messageSliceCount: 200,
inputTokenCost: 0.050,
outputTokenCost: 0.400,
baseUrl: '',
articleGenerationPeriodMinutes: 15,
lastArticleGenerationTime: null,
eventGenerationPeriodMinutes: 30,
Expand Down Expand Up @@ -350,27 +352,49 @@ export default function EditorPage() {
<h2 className="text-2xl font-semibold text-white/90">AI Model Configuration</h2>
</div>
<div className="backdrop-blur-sm bg-white/5 border border-white/10 rounded-xl p-6 space-y-6">
{/* Model Name */}
<div>
<label className="block text-sm font-medium text-white/80 mb-2">
Model Name
</label>
<input
type="text"
value={editorData.modelName}
onChange={(e) => setEditorData({ ...editorData, modelName: e.target.value })}
placeholder="Enter AI model name (e.g., gpt-5-nano)"
className={`w-full p-4 backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg text-white/90 placeholder-white/50 ${
isAdmin
? 'focus:ring-2 focus:ring-white/50 focus:border-white/30'
: 'bg-white/5 cursor-not-allowed opacity-60'
}`}
readOnly={!isAdmin}
/>
<p className="text-sm text-white/70 mt-2">
Specify the AI model to use for content generation. This setting affects all AI operations in the newsroom.
</p>
</div>
{/* Model Name */}
<div>
<label className="block text-sm font-medium text-white/80 mb-2">
Model Name
</label>
<input
type="text"
value={editorData.modelName}
onChange={(e) => setEditorData({ ...editorData, modelName: e.target.value })}
placeholder="Enter AI model name (e.g., gpt-5-nano)"
className={`w-full p-4 backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg text-white/90 placeholder-white/50 ${
isAdmin
? 'focus:ring-2 focus:ring-white/50 focus:border-white/30'
: 'bg-white/5 cursor-not-allowed opacity-60'
}`}
readOnly={!isAdmin}
/>
<p className="text-sm text-white/70 mt-2">
Specify the AI model to use for content generation. This setting affects all AI operations in the newsroom.
</p>
</div>

{/* Base URL */}
<div>
<label className="block text-sm font-medium text-white/80 mb-2">
OpenAI API Base URL
</label>
<input
type="url"
value={editorData.baseUrl}
onChange={(e) => setEditorData({ ...editorData, baseUrl: e.target.value })}
placeholder="https://api.openai.com/v1 (leave empty for default)"
className={`w-full p-4 backdrop-blur-sm bg-white/10 border border-white/20 rounded-lg text-white/90 placeholder-white/50 ${
isAdmin
? 'focus:ring-2 focus:ring-white/50 focus:border-white/30'
: 'bg-white/5 cursor-not-allowed opacity-60'
}`}
readOnly={!isAdmin}
/>
<p className="text-sm text-white/70 mt-2">
Custom base URL for OpenAI API requests. Leave empty to use the default OpenAI API. Useful for custom endpoints or proxies.
</p>
</div>

{/* Input Token Cost */}
<div>
Expand Down
2 changes: 2 additions & 0 deletions src/app/models/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface Editor {
messageSliceCount: number;
inputTokenCost: number;
outputTokenCost: number;
baseUrl?: string; // Optional base URL for OpenAI API requests
articleGenerationPeriodMinutes: number;
lastArticleGenerationTime?: number; // milliseconds since epoch, optional for backward compatibility
eventGenerationPeriodMinutes: number;
Expand Down Expand Up @@ -114,6 +115,7 @@ EDITOR_PROMPT: 'editor:prompt',
EDITOR_MESSAGE_SLICE_COUNT: 'editor:message_slice_count',
INPUT_TOKEN_COST: 'editor:input_token_cost',
OUTPUT_TOKEN_COST: 'editor:output_token_cost',
BASE_URL: 'editor:base_url',
ARTICLE_GENERATION_PERIOD_MINUTES: 'article_generation:period_minutes',
LAST_ARTICLE_GENERATION_TIME: 'article_generation:last_time',
EVENT_GENERATION_PERIOD_MINUTES: 'event_generation:period_minutes',
Expand Down
21 changes: 21 additions & 0 deletions src/app/services/ai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,35 @@ export class AIService {
throw new Error('OPENAI_API_KEY environment variable is required');
}

// Initialize OpenAI client synchronously first, then update with baseUrl if available
this.openai = new OpenAI({
apiKey: apiKey,
});

// Initialize OpenAI client with configurable base URL asynchronously
this.initializeOpenAIClient(apiKey);

// Initialize modelName from Redis with default
this.initializeModelName();
}

private async initializeOpenAIClient(apiKey: string): Promise<void> {
try {
const editor = await this.dataStorageService.getEditor();
const baseUrl = editor?.baseUrl;

if (baseUrl) {
// Re-initialize with baseUrl if available
this.openai = new OpenAI({
apiKey: apiKey,
baseURL: baseUrl,
});
}
} catch (error) {
console.warn('Failed to fetch baseUrl from Redis, keeping default OpenAI API:', error);
}
}

private async initializeModelName(): Promise<void> {
try {
const storedModelName = await this.dataStorageService.getModelName();
Expand Down
8 changes: 7 additions & 1 deletion src/app/services/redis-data-storage.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ export class RedisDataStorageService implements IDataStorageService {
multi.set(REDIS_KEYS.INPUT_TOKEN_COST, editor.inputTokenCost.toString());
console.log('Redis Write: SET', REDIS_KEYS.OUTPUT_TOKEN_COST, editor.outputTokenCost.toString());
multi.set(REDIS_KEYS.OUTPUT_TOKEN_COST, editor.outputTokenCost.toString());
if (editor.baseUrl !== undefined) {
console.log('Redis Write: SET', REDIS_KEYS.BASE_URL, editor.baseUrl);
multi.set(REDIS_KEYS.BASE_URL, editor.baseUrl);
}
console.log('Redis Write: SET', REDIS_KEYS.ARTICLE_GENERATION_PERIOD_MINUTES, editor.articleGenerationPeriodMinutes.toString());
multi.set(REDIS_KEYS.ARTICLE_GENERATION_PERIOD_MINUTES, editor.articleGenerationPeriodMinutes.toString());
if (editor.lastArticleGenerationTime !== undefined) {
Expand All @@ -77,13 +81,14 @@ export class RedisDataStorageService implements IDataStorageService {
}

async getEditor(): Promise<Editor | null> {
const [bio, prompt, modelName, messageSliceCountStr, inputTokenCostStr, outputTokenCostStr, articleGenerationPeriodMinutesStr, lastArticleGenerationTimeStr, eventGenerationPeriodMinutesStr, lastEventGenerationTimeStr, editionGenerationPeriodMinutesStr, lastEditionGenerationTimeStr] = await Promise.all([
const [bio, prompt, modelName, messageSliceCountStr, inputTokenCostStr, outputTokenCostStr, baseUrl, articleGenerationPeriodMinutesStr, lastArticleGenerationTimeStr, eventGenerationPeriodMinutesStr, lastEventGenerationTimeStr, editionGenerationPeriodMinutesStr, lastEditionGenerationTimeStr] = await Promise.all([
this.client.get(REDIS_KEYS.EDITOR_BIO),
this.client.get(REDIS_KEYS.EDITOR_PROMPT),
this.client.get(REDIS_KEYS.MODEL_NAME),
this.client.get(REDIS_KEYS.EDITOR_MESSAGE_SLICE_COUNT),
this.client.get(REDIS_KEYS.INPUT_TOKEN_COST),
this.client.get(REDIS_KEYS.OUTPUT_TOKEN_COST),
this.client.get(REDIS_KEYS.BASE_URL),
this.client.get(REDIS_KEYS.ARTICLE_GENERATION_PERIOD_MINUTES),
this.client.get(REDIS_KEYS.LAST_ARTICLE_GENERATION_TIME),
this.client.get(REDIS_KEYS.EVENT_GENERATION_PERIOD_MINUTES),
Expand All @@ -101,6 +106,7 @@ export class RedisDataStorageService implements IDataStorageService {
messageSliceCount: messageSliceCountStr ? parseInt(messageSliceCountStr) : 200, // Default fallback
inputTokenCost: inputTokenCostStr ? parseFloat(inputTokenCostStr) : 0.050, // Default to $0.050 per 1M tokens
outputTokenCost: outputTokenCostStr ? parseFloat(outputTokenCostStr) : 0.400, // Default to $0.400 per 1M tokens
baseUrl: baseUrl || undefined, // Optional field
articleGenerationPeriodMinutes: articleGenerationPeriodMinutesStr ? parseInt(articleGenerationPeriodMinutesStr) : 15, // Default fallback
lastArticleGenerationTime: lastArticleGenerationTimeStr ? parseInt(lastArticleGenerationTimeStr) : undefined, // Optional field
eventGenerationPeriodMinutes: eventGenerationPeriodMinutesStr ? parseInt(eventGenerationPeriodMinutesStr) : 30, // Default fallback
Expand Down