From c24a66daa8987160aa6bd745e6811b48d4a3e2cc Mon Sep 17 00:00:00 2001 From: Thomas Barron Date: Thu, 13 Nov 2025 10:32:48 -0700 Subject: [PATCH] feat: openai-compatible base_url in editor settings --- .gitignore | 1 + src/app/api/editor/route.ts | 12 +++- src/app/editor/page.tsx | 66 +++++++++++++------ src/app/models/types.ts | 2 + src/app/services/ai.service.ts | 21 ++++++ .../services/redis-data-storage.service.ts | 8 ++- 6 files changed, 87 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index e780adf..77ca06b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ next-env.d.ts dump.rdb deploy.sh +opencode.json \ No newline at end of file diff --git a/src/app/api/editor/route.ts b/src/app/api/editor/route.ts index 86a9c78..8d3e85a 100644 --- a/src/app/api/editor/route.ts +++ b/src/app/api/editor/route.ts @@ -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, @@ -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( @@ -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' }, @@ -83,6 +91,7 @@ export const PUT = withAuth(async (request: NextRequest, user, redis) => { messageSliceCount, inputTokenCost, outputTokenCost, + baseUrl: baseUrl || undefined, articleGenerationPeriodMinutes, eventGenerationPeriodMinutes, editionGenerationPeriodMinutes @@ -95,6 +104,7 @@ export const PUT = withAuth(async (request: NextRequest, user, redis) => { messageSliceCount, inputTokenCost, outputTokenCost, + baseUrl: baseUrl || '', articleGenerationPeriodMinutes, eventGenerationPeriodMinutes, editionGenerationPeriodMinutes, diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 4ca852a..dd40d8d 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -11,6 +11,7 @@ interface EditorData { messageSliceCount: number; inputTokenCost: number; outputTokenCost: number; + baseUrl: string; articleGenerationPeriodMinutes: number; lastArticleGenerationTime: number | null; eventGenerationPeriodMinutes: number; @@ -52,6 +53,7 @@ export default function EditorPage() { messageSliceCount: 200, inputTokenCost: 0.050, outputTokenCost: 0.400, + baseUrl: '', articleGenerationPeriodMinutes: 15, lastArticleGenerationTime: null, eventGenerationPeriodMinutes: 30, @@ -350,27 +352,49 @@ export default function EditorPage() {

AI Model Configuration

- {/* Model Name */} -
- - 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} - /> -

- Specify the AI model to use for content generation. This setting affects all AI operations in the newsroom. -

-
+ {/* Model Name */} +
+ + 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} + /> +

+ Specify the AI model to use for content generation. This setting affects all AI operations in the newsroom. +

+
+ + {/* Base URL */} +
+ + 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} + /> +

+ Custom base URL for OpenAI API requests. Leave empty to use the default OpenAI API. Useful for custom endpoints or proxies. +

+
{/* Input Token Cost */}
diff --git a/src/app/models/types.ts b/src/app/models/types.ts index d99f9b5..7d7d8eb 100644 --- a/src/app/models/types.ts +++ b/src/app/models/types.ts @@ -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; @@ -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', diff --git a/src/app/services/ai.service.ts b/src/app/services/ai.service.ts index d65f9cb..334e8a0 100644 --- a/src/app/services/ai.service.ts +++ b/src/app/services/ai.service.ts @@ -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 { + 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 { try { const storedModelName = await this.dataStorageService.getModelName(); diff --git a/src/app/services/redis-data-storage.service.ts b/src/app/services/redis-data-storage.service.ts index 52e17ce..b3b302d 100644 --- a/src/app/services/redis-data-storage.service.ts +++ b/src/app/services/redis-data-storage.service.ts @@ -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) { @@ -77,13 +81,14 @@ export class RedisDataStorageService implements IDataStorageService { } async getEditor(): Promise { - 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), @@ -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