From 99d1506011693d9feec0912a5104ab34cfeae5c2 Mon Sep 17 00:00:00 2001 From: ksuilen Date: Tue, 17 Jun 2025 11:54:02 +0200 Subject: [PATCH] Add Portkey provider with custom headers and models - Add Portkey provider for AI gateway functionality - Support for custom headers via UI (default: x-portkey-debug: false) - Support for custom models configuration via UI - Integrate HeaderManager and ModelManager components - Update CloudProvidersTab to support Portkey configuration - Add environment variables for PORTKEY_API_KEY and PORTKEY_API_BASE_URL - Fix linting issues and import paths --- .../tabs/providers/HeaderManager.tsx | 236 ++++++++++++++++ .../@settings/tabs/providers/ModelManager.tsx | 255 ++++++++++++++++++ .../providers/cloud/CloudProvidersTab.tsx | 49 +++- app/lib/modules/llm/base-provider.ts | 8 +- app/lib/modules/llm/providers/portkey.ts | 56 ++++ app/lib/modules/llm/registry.ts | 2 + app/lib/stores/settings.ts | 2 +- app/types/model.ts | 2 + worker-configuration.d.ts | 2 + 9 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 app/components/@settings/tabs/providers/HeaderManager.tsx create mode 100644 app/components/@settings/tabs/providers/ModelManager.tsx create mode 100644 app/lib/modules/llm/providers/portkey.ts diff --git a/app/components/@settings/tabs/providers/HeaderManager.tsx b/app/components/@settings/tabs/providers/HeaderManager.tsx new file mode 100644 index 00000000..0663f63b --- /dev/null +++ b/app/components/@settings/tabs/providers/HeaderManager.tsx @@ -0,0 +1,236 @@ +import React, { useState } from 'react'; +import { IconButton } from '~/components/ui/IconButton'; +import type { IProviderSetting } from '~/types/model'; +import { classNames } from '~/utils/classNames'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface HeaderManagerProps { + provider: string; + settings: IProviderSetting; + onUpdateSettings: (settings: IProviderSetting) => void; +} + +interface HeaderFormData { + name: string; + value: string; +} + +export const HeaderManager: React.FC = ({ provider, settings, onUpdateSettings }) => { + const [isAddingHeader, setIsAddingHeader] = useState(false); + const [editingHeaderKey, setEditingHeaderKey] = useState(null); + const [headerForm, setHeaderForm] = useState({ + name: '', + value: '', + }); + + const customHeaders = settings.customHeaders || {}; + const headerEntries = Object.entries(customHeaders); + + const handleAddHeader = () => { + if (!headerForm.name.trim() || !headerForm.value.trim()) { + return; + } + + const updatedHeaders = { + ...customHeaders, + [headerForm.name.trim()]: headerForm.value.trim(), + }; + + const updatedSettings = { + ...settings, + customHeaders: updatedHeaders, + }; + + onUpdateSettings(updatedSettings); + setHeaderForm({ name: '', value: '' }); + setIsAddingHeader(false); + }; + + const handleEditHeader = (key: string) => { + setHeaderForm({ + name: key, + value: customHeaders[key], + }); + setEditingHeaderKey(key); + }; + + const handleUpdateHeader = () => { + if (editingHeaderKey === null || !headerForm.name.trim() || !headerForm.value.trim()) { + return; + } + + const updatedHeaders = { ...customHeaders }; + + // Remove old key if name changed + if (editingHeaderKey !== headerForm.name.trim()) { + delete updatedHeaders[editingHeaderKey]; + } + + // Add/update with new values + updatedHeaders[headerForm.name.trim()] = headerForm.value.trim(); + + const updatedSettings = { + ...settings, + customHeaders: updatedHeaders, + }; + + onUpdateSettings(updatedSettings); + setHeaderForm({ name: '', value: '' }); + setEditingHeaderKey(null); + }; + + const handleDeleteHeader = (key: string) => { + const updatedHeaders = { ...customHeaders }; + delete updatedHeaders[key]; + + const updatedSettings = { + ...settings, + customHeaders: updatedHeaders, + }; + onUpdateSettings(updatedSettings); + }; + + const handleCancelEdit = () => { + setHeaderForm({ name: '', value: '' }); + setIsAddingHeader(false); + setEditingHeaderKey(null); + }; + + return ( +
+
+

Custom Headers

+ setIsAddingHeader(true)} + title="Add Header" + className="bg-green-500/10 hover:bg-green-500/20 text-green-500" + disabled={isAddingHeader || editingHeaderKey !== null} + > +
+ +
+ + {/* Header Form */} + + {(isAddingHeader || editingHeaderKey !== null) && ( + +
+
+
+ + setHeaderForm({ ...headerForm, name: e.target.value })} + placeholder="e.g., x-api-version" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + )} + /> +
+
+ + setHeaderForm({ ...headerForm, value: e.target.value })} + placeholder="e.g., v1.0" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + )} + /> +
+
+
+ +
+ + +
+ +
+
+ + )} + + + {/* Headers List */} +
+ + {headerEntries.map(([key, value]) => ( + +
+
+ {key} + {value} +
+

Custom header for {provider}

+
+
+ handleEditHeader(key)} + title="Edit Header" + className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500" + disabled={isAddingHeader || editingHeaderKey !== null} + > +
+ + handleDeleteHeader(key)} + title="Delete Header" + className="bg-red-500/10 hover:bg-red-500/20 text-red-500" + disabled={isAddingHeader || editingHeaderKey !== null} + > +
+ +
+ + ))} + + + {headerEntries.length === 0 && !isAddingHeader && ( +
+
+

No custom headers configured

+

Click the + button to add a header

+ {provider === 'Portkey' &&

Default: x-portkey-debug: false

} +
+ )} +
+
+ ); +}; diff --git a/app/components/@settings/tabs/providers/ModelManager.tsx b/app/components/@settings/tabs/providers/ModelManager.tsx new file mode 100644 index 00000000..632ec7d0 --- /dev/null +++ b/app/components/@settings/tabs/providers/ModelManager.tsx @@ -0,0 +1,255 @@ +import React, { useState } from 'react'; +import { IconButton } from '~/components/ui/IconButton'; +import type { ModelInfo } from '~/lib/modules/llm/types'; +import type { IProviderSetting } from '~/types/model'; +import { classNames } from '~/utils/classNames'; +import { motion, AnimatePresence } from 'framer-motion'; + +interface ModelManagerProps { + provider: string; + settings: IProviderSetting; + onUpdateSettings: (settings: IProviderSetting) => void; +} + +interface ModelFormData { + name: string; + label: string; + maxTokenAllowed: number; +} + +export const ModelManager: React.FC = ({ provider, settings, onUpdateSettings }) => { + const [isAddingModel, setIsAddingModel] = useState(false); + const [editingModelIndex, setEditingModelIndex] = useState(null); + const [modelForm, setModelForm] = useState({ + name: '', + label: '', + maxTokenAllowed: 8000, + }); + + const customModels = settings.customModels || []; + + const handleAddModel = () => { + if (!modelForm.name.trim() || !modelForm.label.trim()) { + return; + } + + const newModel: ModelInfo = { + name: modelForm.name.trim(), + label: modelForm.label.trim(), + provider, + maxTokenAllowed: modelForm.maxTokenAllowed, + }; + + const updatedSettings = { + ...settings, + customModels: [...customModels, newModel], + }; + + onUpdateSettings(updatedSettings); + setModelForm({ name: '', label: '', maxTokenAllowed: 8000 }); + setIsAddingModel(false); + }; + + const handleEditModel = (index: number) => { + const model = customModels[index]; + setModelForm({ + name: model.name, + label: model.label, + maxTokenAllowed: model.maxTokenAllowed, + }); + setEditingModelIndex(index); + }; + + const handleUpdateModel = () => { + if (editingModelIndex === null || !modelForm.name.trim() || !modelForm.label.trim()) { + return; + } + + const updatedModels = [...customModels]; + updatedModels[editingModelIndex] = { + name: modelForm.name.trim(), + label: modelForm.label.trim(), + provider, + maxTokenAllowed: modelForm.maxTokenAllowed, + }; + + const updatedSettings = { + ...settings, + customModels: updatedModels, + }; + + onUpdateSettings(updatedSettings); + setModelForm({ name: '', label: '', maxTokenAllowed: 8000 }); + setEditingModelIndex(null); + }; + + const handleDeleteModel = (index: number) => { + const updatedModels = customModels.filter((_, i) => i !== index); + const updatedSettings = { + ...settings, + customModels: updatedModels, + }; + onUpdateSettings(updatedSettings); + }; + + const handleCancelEdit = () => { + setModelForm({ name: '', label: '', maxTokenAllowed: 8000 }); + setIsAddingModel(false); + setEditingModelIndex(null); + }; + + return ( +
+
+

Custom Models

+ setIsAddingModel(true)} + title="Add Model" + className="bg-green-500/10 hover:bg-green-500/20 text-green-500" + disabled={isAddingModel || editingModelIndex !== null} + > +
+ +
+ + {/* Model Form */} + + {(isAddingModel || editingModelIndex !== null) && ( + +
+
+
+ + setModelForm({ ...modelForm, name: e.target.value })} + placeholder="e.g., gpt-4o" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + )} + /> +
+
+ + setModelForm({ ...modelForm, label: e.target.value })} + placeholder="e.g., GPT-4o (via Portkey)" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + )} + /> +
+
+
+ + setModelForm({ ...modelForm, maxTokenAllowed: parseInt(e.target.value) || 8000 })} + min="1000" + max="2000000" + className={classNames( + 'w-full px-3 py-2 rounded-lg text-sm', + 'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor', + 'text-bolt-elements-textPrimary', + 'focus:outline-none focus:ring-2 focus:ring-purple-500/30', + )} + /> +
+
+ +
+ + +
+ +
+
+ + )} + + + {/* Model List */} +
+ + {customModels.map((model, index) => ( + +
+
+ {model.label} + {model.name} +
+

+ Max tokens: {model.maxTokenAllowed.toLocaleString()} +

+
+
+ handleEditModel(index)} + title="Edit Model" + className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500" + disabled={isAddingModel || editingModelIndex !== null} + > +
+ + handleDeleteModel(index)} + title="Delete Model" + className="bg-red-500/10 hover:bg-red-500/20 text-red-500" + disabled={isAddingModel || editingModelIndex !== null} + > +
+ +
+ + ))} + + + {customModels.length === 0 && !isAddingModel && ( +
+
+

No custom models configured

+

Click the + button to add a model

+
+ )} +
+
+ ); +}; diff --git a/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx b/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx index 9f85b766..99242f29 100644 --- a/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx +++ b/app/components/@settings/tabs/providers/cloud/CloudProvidersTab.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { Switch } from '~/components/ui/Switch'; import { useSettings } from '~/lib/hooks/useSettings'; import { URL_CONFIGURABLE_PROVIDERS } from '~/lib/stores/settings'; -import type { IProviderConfig } from '~/types/model'; +import type { IProviderConfig, ProviderInfo } from '~/types/model'; import { logStore } from '~/lib/stores/logs'; import { motion } from 'framer-motion'; import { classNames } from '~/utils/classNames'; @@ -14,6 +14,9 @@ import { TbBrain, TbCloudComputing } from 'react-icons/tb'; import { BiCodeBlock, BiChip } from 'react-icons/bi'; import { FaCloud, FaBrain } from 'react-icons/fa'; import type { IconType } from 'react-icons'; +import { APIKeyManager, getApiKeysFromCookies } from '~/components/chat/APIKeyManager'; +import { ModelManager } from '~/components/@settings/tabs/providers/ModelManager'; +import { HeaderManager } from '~/components/@settings/tabs/providers/HeaderManager'; // Add type for provider names to ensure type safety type ProviderName = @@ -30,7 +33,8 @@ type ProviderName = | 'OpenRouter' | 'Perplexity' | 'Together' - | 'XAI'; + | 'XAI' + | 'Portkey'; // Update the PROVIDER_ICONS type to use the ProviderName type const PROVIDER_ICONS: Record = { @@ -48,12 +52,14 @@ const PROVIDER_ICONS: Record = { Perplexity: SiPerplexity, Together: BsCloud, XAI: BsRobot, + Portkey: BsCloud, }; // Update PROVIDER_DESCRIPTIONS to use the same type const PROVIDER_DESCRIPTIONS: Partial> = { Anthropic: 'Access Claude and other Anthropic models', OpenAI: 'Use GPT-4, GPT-3.5, and other OpenAI models', + Portkey: 'AI gateway with custom model configuration', }; const CloudProvidersTab = () => { @@ -61,6 +67,13 @@ const CloudProvidersTab = () => { const [editingProvider, setEditingProvider] = useState(null); const [filteredProviders, setFilteredProviders] = useState([]); const [categoryEnabled, setCategoryEnabled] = useState(false); + const [apiKeys, setApiKeys] = useState>({}); + + // Load API keys from cookies on mount + useEffect(() => { + const savedApiKeys = getApiKeysFromCookies(); + setApiKeys(savedApiKeys); + }, []); // Load and filter providers useEffect(() => { @@ -283,6 +296,38 @@ const CloudProvidersTab = () => { )} )} + + {/* Special Portkey configuration */} + {provider.settings.enabled && provider.name === 'Portkey' && ( + + {/* API Key Manager */} + setApiKeys((prev) => ({ ...prev, [provider.name]: key }))} + /> + + {/* Model Manager */} + settings.updateProviderSettings(provider.name, newSettings)} + /> + + {/* Header Manager */} + settings.updateProviderSettings(provider.name, newSettings)} + /> + + )}
diff --git a/app/lib/modules/llm/base-provider.ts b/app/lib/modules/llm/base-provider.ts index 9cb23403..08ee17af 100644 --- a/app/lib/modules/llm/base-provider.ts +++ b/app/lib/modules/llm/base-provider.ts @@ -119,10 +119,16 @@ export abstract class BaseProvider implements ProviderInfo { type OptionalApiKey = string | undefined; -export function getOpenAILikeModel(baseURL: string, apiKey: OptionalApiKey, model: string) { +export function getOpenAILikeModel( + baseURL: string, + apiKey: OptionalApiKey, + model: string, + customHeaders?: Record, +) { const openai = createOpenAI({ baseURL, apiKey, + headers: customHeaders, }); return openai(model); diff --git a/app/lib/modules/llm/providers/portkey.ts b/app/lib/modules/llm/providers/portkey.ts new file mode 100644 index 00000000..d21221ce --- /dev/null +++ b/app/lib/modules/llm/providers/portkey.ts @@ -0,0 +1,56 @@ +import { BaseProvider, getOpenAILikeModel } from '~/lib/modules/llm/base-provider'; +import type { ModelInfo } from '~/lib/modules/llm/types'; +import type { IProviderSetting } from '~/types/model'; +import type { LanguageModelV1 } from 'ai'; + +export default class PortkeyProvider extends BaseProvider { + name = 'Portkey'; + getApiKeyLink = 'https://portkey.ai/docs/api-reference/authentication'; + + config = { + baseUrlKey: 'PORTKEY_API_BASE_URL', + apiTokenKey: 'PORTKEY_API_KEY', + }; + + // No static models - all models are user-configurable + staticModels: ModelInfo[] = []; + + // Get custom models from user settings + async getDynamicModels( + apiKeys?: Record, + settings?: IProviderSetting, + _serverEnv: Record = {}, + ): Promise { + // Return user-configured custom models + return settings?.customModels || []; + } + + getModelInstance(options: { + model: string; + serverEnv: Env; + apiKeys?: Record; + providerSettings?: Record; + }): LanguageModelV1 { + const { model, serverEnv, apiKeys, providerSettings } = options; + + const { baseUrl, apiKey } = this.getProviderBaseUrlAndKey({ + apiKeys, + providerSettings: providerSettings?.[this.name], + serverEnv: serverEnv as any, + defaultBaseUrlKey: 'PORTKEY_API_BASE_URL', + defaultApiTokenKey: 'PORTKEY_API_KEY', + }); + + if (!baseUrl || !apiKey) { + throw new Error(`Missing configuration for ${this.name} provider`); + } + + // Get custom headers from settings, with Portkey defaults + const customHeaders = { + 'x-portkey-debug': 'false', // Default Portkey header + ...providerSettings?.[this.name]?.customHeaders, // User-defined headers override defaults + }; + + return getOpenAILikeModel(baseUrl, apiKey, model, customHeaders); + } +} diff --git a/app/lib/modules/llm/registry.ts b/app/lib/modules/llm/registry.ts index 6edba6d8..9842ca86 100644 --- a/app/lib/modules/llm/registry.ts +++ b/app/lib/modules/llm/registry.ts @@ -16,6 +16,7 @@ import XAIProvider from './providers/xai'; import HyperbolicProvider from './providers/hyperbolic'; import AmazonBedrockProvider from './providers/amazon-bedrock'; import GithubProvider from './providers/github'; +import PortkeyProvider from './providers/portkey'; export { AnthropicProvider, @@ -36,4 +37,5 @@ export { LMStudioProvider, AmazonBedrockProvider, GithubProvider, + PortkeyProvider, }; diff --git a/app/lib/stores/settings.ts b/app/lib/stores/settings.ts index 917f6e0a..ae1358ad 100644 --- a/app/lib/stores/settings.ts +++ b/app/lib/stores/settings.ts @@ -29,7 +29,7 @@ export interface Shortcuts { toggleTerminal: Shortcut; } -export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike']; +export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike', 'Portkey']; export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama']; export type ProviderSetting = Record; diff --git a/app/types/model.ts b/app/types/model.ts index d16b10ae..8fb99ac8 100644 --- a/app/types/model.ts +++ b/app/types/model.ts @@ -17,6 +17,8 @@ export type ProviderInfo = { export interface IProviderSetting { enabled?: boolean; baseUrl?: string; + customModels?: ModelInfo[]; + customHeaders?: Record; } export type IProviderConfig = ProviderInfo & { diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index b2ae1ce7..2437adaf 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -18,4 +18,6 @@ interface Env { XAI_API_KEY: string; PERPLEXITY_API_KEY: string; AWS_BEDROCK_CONFIG: string; + PORTKEY_API_KEY: string; + PORTKEY_API_BASE_URL: string; }