Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
66 changes: 49 additions & 17 deletions server/src/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,31 +85,63 @@ router.post('/api/proxy/github/*', async (req, res) => {
}
});

function normalizeReasoningEffort(value: unknown): string | null {
if (typeof value !== 'string') return null;
return value === 'minimal' ? 'low' : value;
}

// POST /api/proxy/ai
// Accepts either { configId, body } (lookup from DB) or { config, body } (inline config for one-time requests)
router.post('/api/proxy/ai', async (req, res) => {
try {
const db = getDb();
const { configId, body: requestBody } = req.body as { configId: string; body: Record<string, unknown> };

if (!configId) {
res.status(400).json({ error: 'configId required', code: 'CONFIG_ID_REQUIRED' });
return;
}
const { configId, config: inlineConfig, body: requestBody } = req.body as {
configId?: string;
config?: { apiType?: string; baseUrl: string; apiKey: string; model: string; reasoningEffort?: string };
body: Record<string, unknown>;
};

const aiConfig = db.prepare('SELECT * FROM ai_configs WHERE id = ?').get(configId) as Record<string, unknown> | undefined;
if (!aiConfig) {
res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' });
let apiKey: string;
let apiType: string;
let baseUrl: string;
let model: string;
let reasoningEffort: string | null;

if (inlineConfig && !configId) {
// Inline config path (for form tests without a saved config ID)
apiKey = inlineConfig.apiKey;
apiType = inlineConfig.apiType || 'openai';
baseUrl = inlineConfig.baseUrl;
model = inlineConfig.model;
reasoningEffort = normalizeReasoningEffort(inlineConfig.reasoningEffort);
if (!baseUrl || !apiKey || !model) {
res.status(400).json({ error: 'baseUrl, apiKey, and model are required', code: 'INVALID_REQUEST' });
return;
}
// Warn if API key is transmitted over non-HTTPS connection
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== 'https:') {
console.warn(`[Proxy] ⚠️ AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`);
}
} catch { /* invalid URL, will be caught by validateUrl later */ }
} else if (configId) {
// DB lookup path (for saved configs)
const aiConfig = db.prepare('SELECT * FROM ai_configs WHERE id = ?').get(configId) as Record<string, unknown> | undefined;
if (!aiConfig) {
res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' });
return;
}
apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey);
apiType = (aiConfig.api_type as string) || 'openai';
baseUrl = aiConfig.base_url as string;
model = aiConfig.model as string;
reasoningEffort = normalizeReasoningEffort(aiConfig.reasoning_effort);
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
res.status(400).json({ error: 'configId or config required', code: 'CONFIG_ID_REQUIRED' });
return;
}

const apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey);
const apiType = (aiConfig.api_type as string) || 'openai';
const baseUrl = aiConfig.base_url as string;
const model = aiConfig.model as string;
const reasoningEffort = aiConfig.reasoning_effort === 'minimal'
? 'low'
: aiConfig.reasoning_effort as string | null | undefined;

let targetUrl: string;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Expand Down
24 changes: 18 additions & 6 deletions src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,12 @@ export class AIService {
};

let data: Record<string, unknown>;
if (backend.isAvailable && this.config.id) {
data = await backend.proxyAIRequest(this.config.id, requestBody) as Record<string, unknown>;
if (backend.isAvailable) {
if (this.config.id) {
data = await backend.proxyAIRequest(this.config.id, requestBody) as Record<string, unknown>;
} else {
data = await backend.proxyAIRequestWithConfig(this.config, requestBody) as Record<string, unknown>;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
const url = buildFinalApiUrl(this.config.baseUrl, apiType);
const response = await fetch(url, {
Expand Down Expand Up @@ -211,8 +215,12 @@ export class AIService {
};

let data: unknown;
if (backend.isAvailable && this.config.id) {
data = await backend.proxyAIRequest(this.config.id, requestBody);
if (backend.isAvailable) {
if (this.config.id) {
data = await backend.proxyAIRequest(this.config.id, requestBody);
} else {
data = await backend.proxyAIRequestWithConfig(this.config, requestBody);
}
} else {
const url = buildApiUrl(this.config.baseUrl, 'v1/messages');
const response = await fetch(url, {
Expand Down Expand Up @@ -267,8 +275,12 @@ ${options.user}` : options.user;
};

let data: unknown;
if (backend.isAvailable && this.config.id) {
data = await backend.proxyAIRequest(this.config.id, requestBody);
if (backend.isAvailable) {
if (this.config.id) {
data = await backend.proxyAIRequest(this.config.id, requestBody);
} else {
data = await backend.proxyAIRequestWithConfig(this.config, requestBody);
}
} else {
const path = `v1beta/models/${encodeURIComponent(model)}:generateContent`;
const urlObj = new URL(buildApiUrl(this.config.baseUrl, path));
Expand Down
12 changes: 12 additions & 0 deletions src/services/backendAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ class BackendAdapter {
return res.json();
}

async proxyAIRequestWithConfig(aiConfig: { apiType?: string; baseUrl: string; apiKey: string; model: string; reasoningEffort?: string }, body: object): Promise<unknown> {
if (!this._backendUrl) throw new Error('Backend not available');

const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/ai`, {
method: 'POST',
headers: this.getAuthHeaders(),
body: JSON.stringify({ config: aiConfig, body })
}, 120000);
if (!res.ok) await this.throwTranslatedError(res, 'AI proxy error');
return res.json();
}

// === WebDAV Proxy ===

async proxyWebDAV(configId: string, method: string, path: string, body?: string, headers?: Record<string, string>): Promise<Response> {
Expand Down
Loading