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