diff --git a/.DS_Store b/.DS_Store index 5b82f78..f3ce5d5 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fafdf37 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Prisma", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/prisma/.gitignore b/prisma/.gitignore index a547bf3..be32cf8 100644 --- a/prisma/.gitignore +++ b/prisma/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? +prisma/.env +prisma/.env.example diff --git a/prisma/api.ts b/prisma/api.ts index 8e3ac9e..afeddf7 100644 --- a/prisma/api.ts +++ b/prisma/api.ts @@ -1,6 +1,6 @@ import { GoogleGenAI } from "@google/genai"; import OpenAI from "openai"; -import { ApiProvider, AppConfig, CustomModel } from './types'; +import { ApiProvider, CustomModel } from './types'; type AIProviderConfig = { provider?: ApiProvider; @@ -8,39 +8,99 @@ type AIProviderConfig = { baseUrl?: string; }; -/** - * Find custom model configuration by model name - */ export const findCustomModel = (modelName: string, customModels?: CustomModel[]): CustomModel | undefined => { return customModels?.find(m => m.name === modelName); }; +// External API base URLs for production +const PROVIDER_BASE_URLS: Record = { + openai: 'https://api.openai.com/v1', + deepseek: 'https://api.deepseek.com/v1', + anthropic: 'https://api.anthropic.com/v1', + xai: 'https://api.x.ai/v1', + mistral: 'https://api.mistral.ai/v1', + custom: '', +}; + +// Check if we're in development mode +const isDevelopment = import.meta.env?.MODE === 'development' || process.env.NODE_ENV === 'development'; + +// Store the current custom API target URL +let currentCustomApiUrl: string | null = null; + +// Setup fetch interceptor to add X-Target-URL header for custom API proxy +const originalFetch = typeof window !== 'undefined' ? window.fetch.bind(window) : null; + +if (typeof window !== 'undefined' && originalFetch) { + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + let urlString: string; + if (typeof input === 'string') { + urlString = input; + } else if (input instanceof URL) { + urlString = input.toString(); + } else { + urlString = input.url; + } + + // If this is a custom-api request and we have a target URL, add the header + if (urlString.includes('/custom-api') && currentCustomApiUrl) { + const headers = new Headers(init?.headers); + headers.set('X-Target-URL', currentCustomApiUrl); + console.log('[Fetch] Adding X-Target-URL header:', currentCustomApiUrl); + + return originalFetch(input, { + ...init, + headers, + }); + } + + return originalFetch(input, init); + }; +} + export const getAI = (config?: AIProviderConfig) => { const provider = config?.provider || 'google'; - // Support both Vite env vars (VITE_) and standard env vars for flexibility const apiKey = config?.apiKey || (import.meta.env as any).VITE_API_KEY || process.env.API_KEY; if (provider === 'openai' || provider === 'deepseek' || provider === 'custom' || provider === 'anthropic' || provider === 'xai' || provider === 'mistral') { const options: any = { apiKey: apiKey, - // WARNING: dangerouslyAllowBrowser enables client-side API calls - // This is acceptable for local development but NOT production - // In production, use a backend proxy to protect API keys dangerouslyAllowBrowser: true, }; if (config?.baseUrl) { - options.baseURL = config.baseUrl; - } else if (provider === 'deepseek') { - options.baseURL = 'https://api.deepseek.com/v1'; - } else if (provider === 'anthropic') { - options.baseURL = 'https://api.anthropic.com/v1'; - } else if (provider === 'xai') { - options.baseURL = 'https://api.x.ai/v1'; - } else if (provider === 'mistral') { - options.baseURL = 'https://api.mistral.ai/v1'; + // Custom baseUrl from Configuration UI + if (isDevelopment) { + // Store the target URL for the fetch interceptor + currentCustomApiUrl = config.baseUrl; + // Use proxy path + options.baseURL = `${window.location.origin}/custom-api`; + console.log('[API] Using custom API proxy:', { + proxyPath: options.baseURL, + targetUrl: currentCustomApiUrl, + }); + } else { + // In production, use the URL directly + options.baseURL = config.baseUrl; + } + } else { + const providerBaseUrl = PROVIDER_BASE_URLS[provider]; + if (providerBaseUrl) { + if (isDevelopment) { + // In development, use proxy to avoid CORS for known providers + options.baseURL = `${window.location.origin}/${provider}/v1`; + } else { + options.baseURL = providerBaseUrl; + } + } } + console.log('[API] OpenAI client config:', { + provider, + baseURL: options.baseURL, + hasApiKey: !!options.apiKey, + customTarget: currentCustomApiUrl, + }); return new OpenAI(options); } else { const options: any = { @@ -75,4 +135,4 @@ export const getAIProvider = (model: string): ApiProvider => { return 'custom'; } return 'google'; -}; \ No newline at end of file +}; diff --git a/prisma/components/Header.tsx b/prisma/components/Header.tsx index 80cfd12..7d8f197 100644 --- a/prisma/components/Header.tsx +++ b/prisma/components/Header.tsx @@ -49,7 +49,7 @@ const Header = ({ selectedModel, setSelectedModel, onOpenSettings, onToggleSideb className="relative bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-auto p-2.5 outline-none appearance-none cursor-pointer pl-3 pr-8 shadow-sm font-medium hover:bg-slate-50 transition-colors" > {availableModels.map(m => ( - + ))} diff --git a/prisma/components/settings/ModelSection.tsx b/prisma/components/settings/ModelSection.tsx index 0b53e05..c24ac6d 100644 --- a/prisma/components/settings/ModelSection.tsx +++ b/prisma/components/settings/ModelSection.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { Plus, Trash2, Bot, Key, Globe, ChevronDown, ChevronUp } from 'lucide-react'; import { AppConfig, ApiProvider, CustomModel } from '../../types'; +import { MODELS } from '../../config'; interface ModelSectionProps { config: AppConfig; @@ -20,9 +21,25 @@ const ModelSection = ({ config, setConfig }: ModelSectionProps) => { const handleAddModel = () => { if (!newModelName.trim()) return; + const trimmedName = newModelName.trim(); + + // Check if model name already exists in preset models + const existingPresetModel = MODELS.find(m => m.value === trimmedName); + if (existingPresetModel) { + alert(`Model name "${trimmedName}" already exists as a preset model. Please choose a different name.`); + return; + } + + // Check if model name already exists in custom models + const existingCustomModel = customModels.find(m => m.name === trimmedName); + if (existingCustomModel) { + alert(`Model name "${trimmedName}" already exists. Please choose a different name.`); + return; + } + const newModel: CustomModel = { id: `custom-${Date.now()}`, - name: newModelName.trim(), + name: trimmedName, provider: newModelProvider, apiKey: newModelApiKey || undefined, baseUrl: newModelBaseUrl || undefined diff --git a/prisma/hooks/useDeepThink.ts b/prisma/hooks/useDeepThink.ts index 9526894..d10d1c8 100644 --- a/prisma/hooks/useDeepThink.ts +++ b/prisma/hooks/useDeepThink.ts @@ -99,8 +99,8 @@ export const useDeepThink = () => { setProcessStartTime(Date.now()); setProcessEndTime(null); - const provider = getAIProvider(model); const customModelConfig = findCustomModel(model, config.customModels); + const provider = customModelConfig?.provider || getAIProvider(model); const ai = getAI({ provider, diff --git a/prisma/index.css b/prisma/index.css new file mode 100644 index 0000000..f968d47 --- /dev/null +++ b/prisma/index.css @@ -0,0 +1 @@ +/* Base styles are handled by Tailwind CSS */ diff --git a/prisma/interceptor.ts b/prisma/interceptor.ts index f748dec..bab052f 100644 --- a/prisma/interceptor.ts +++ b/prisma/interceptor.ts @@ -1,18 +1,5 @@ -/** - * Network Interceptor - * - * Intercepts global fetch calls to redirect Gemini API requests - * from the default endpoint to a user-defined custom base URL. - * - * Uses Object.defineProperty to bypass "getter-only" restrictions on window.fetch - * in certain sandboxed or strict environments. - */ - const originalFetch = window.fetch; -/** - * Robustly applies a function to the window.fetch property. - */ const applyFetch = (fn: typeof window.fetch) => { try { Object.defineProperty(window, 'fetch', { @@ -22,7 +9,6 @@ const applyFetch = (fn: typeof window.fetch) => { enumerable: true }); } catch (e) { - // Fallback for environments where defineProperty on window might fail try { (window as any).fetch = fn; } catch (err) { @@ -33,15 +19,12 @@ const applyFetch = (fn: typeof window.fetch) => { export const setInterceptorUrl = (baseUrl: string | null) => { if (!baseUrl) { - // Restore original fetch when disabled applyFetch(originalFetch); return; } - // Normalize the base URL let normalizedBase = baseUrl.trim(); try { - // Basic validation new URL(normalizedBase); } catch (e) { console.warn("[Prisma] Invalid Base URL provided:", normalizedBase); @@ -65,28 +48,22 @@ export const setInterceptorUrl = (baseUrl: string | null) => { const defaultHost = 'generativelanguage.googleapis.com'; - // Check if the request is targeting the Google Gemini API if (urlString.includes(defaultHost)) { try { const url = new URL(urlString); const proxy = new URL(normalizedBase); - // Replace protocol and host url.protocol = proxy.protocol; url.host = proxy.host; - // Prepend proxy path if it exists (e.g., /v1/proxy) if (proxy.pathname !== '/') { const cleanPath = proxy.pathname.endsWith('/') ? proxy.pathname.slice(0, -1) : proxy.pathname; - // Ensure we don't double up slashes url.pathname = cleanPath + url.pathname; } const newUrl = url.toString(); - // Handle the different types of fetch inputs if (input instanceof Request) { - // Re-create the request with the new URL and original properties const requestData: RequestInit = { method: input.method, headers: input.headers, @@ -99,9 +76,7 @@ export const setInterceptorUrl = (baseUrl: string | null) => { integrity: input.integrity, }; - // Merge with init if provided const mergedInit = { ...requestData, ...init }; - return originalFetch(new URL(newUrl), mergedInit); } diff --git a/prisma/vite.config.ts b/prisma/vite.config.ts index ee5fb8d..c48d6f4 100644 --- a/prisma/vite.config.ts +++ b/prisma/vite.config.ts @@ -1,15 +1,149 @@ import path from 'path'; import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; +import type { Connect } from 'vite'; + +// Custom middleware to handle dynamic proxy for /custom-api +function customApiProxyMiddleware(): Connect.NextHandleFunction { + return async (req, res, next) => { + if (!req.url?.startsWith('/custom-api')) { + return next(); + } + + const targetUrl = req.headers['x-target-url'] as string; + if (!targetUrl) { + res.statusCode = 400; + res.end(JSON.stringify({ error: 'Missing X-Target-URL header' })); + return; + } + + try { + const url = new URL(targetUrl); + const targetPath = req.url.replace(/^\/custom-api/, ''); + const fullUrl = `${url.origin}${targetPath}`; + + console.log('[Custom Proxy] Forwarding:', req.method, req.url, '->', fullUrl); + + // Collect request body + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = Buffer.concat(chunks); + + // Forward headers (excluding hop-by-hop headers) + const forwardHeaders: Record = {}; + const skipHeaders = ['host', 'connection', 'x-target-url', 'transfer-encoding']; + for (const [key, value] of Object.entries(req.headers)) { + if (!skipHeaders.includes(key.toLowerCase()) && value) { + forwardHeaders[key] = Array.isArray(value) ? value[0] : value; + } + } + forwardHeaders['host'] = url.host; + + // Make the request + const response = await fetch(fullUrl, { + method: req.method, + headers: forwardHeaders, + body: ['GET', 'HEAD'].includes(req.method || '') ? undefined : body, + }); + + // Forward response status and headers + res.statusCode = response.status; + response.headers.forEach((value, key) => { + if (!['transfer-encoding', 'connection'].includes(key.toLowerCase())) { + res.setHeader(key, value); + } + }); + + // Stream the response body + if (response.body) { + const reader = response.body.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } finally { + reader.releaseLock(); + } + } + res.end(); + + } catch (error: any) { + console.error('[Custom Proxy] Error:', error.message); + res.statusCode = 502; + res.end(JSON.stringify({ error: 'Proxy error', message: error.message })); + } + }; +} export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); + return { server: { port: 3000, host: '0.0.0.0', + proxy: { + // Proxy for OpenAI API + '/openai/v1': { + target: 'https://api.openai.com', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/openai\/v1/, '/v1'), + }, + // Proxy for DeepSeek API + '/deepseek/v1': { + target: 'https://api.deepseek.com', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/deepseek\/v1/, '/v1'), + }, + // Proxy for Anthropic API + '/anthropic/v1': { + target: 'https://api.anthropic.com', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/anthropic\/v1/, '/v1'), + }, + // Proxy for xAI API + '/xai/v1': { + target: 'https://api.x.ai', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/xai\/v1/, '/v1'), + }, + // Proxy for Mistral API + '/mistral/v1': { + target: 'https://api.mistral.ai', + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/mistral\/v1/, '/v1'), + }, + // Proxy for Google Gemini API + '/v1beta/models': { + target: 'https://generativelanguage.googleapis.com', + changeOrigin: true, + secure: true, + }, + '/v1/models': { + target: 'https://generativelanguage.googleapis.com', + changeOrigin: true, + secure: true, + }, + } }, - plugins: [react()], + plugins: [ + react(), + { + name: 'custom-api-proxy', + configureServer(server) { + server.middlewares.use(customApiProxyMiddleware()); + }, + }, + ], define: { 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY), 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)