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

This commit is contained in:
ksuilen 2025-06-17 11:54:02 +02:00
parent 5d3fb1d7de
commit 99d1506011
9 changed files with 608 additions and 4 deletions

View File

@ -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<HeaderManagerProps> = ({ provider, settings, onUpdateSettings }) => {
const [isAddingHeader, setIsAddingHeader] = useState(false);
const [editingHeaderKey, setEditingHeaderKey] = useState<string | null>(null);
const [headerForm, setHeaderForm] = useState<HeaderFormData>({
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Custom Headers</h4>
<IconButton
onClick={() => setIsAddingHeader(true)}
title="Add Header"
className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
disabled={isAddingHeader || editingHeaderKey !== null}
>
<div className="i-ph:plus w-4 h-4" />
</IconButton>
</div>
{/* Header Form */}
<AnimatePresence>
{(isAddingHeader || editingHeaderKey !== null) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border border-bolt-elements-borderColor rounded-lg p-4 bg-bolt-elements-background-depth-3"
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-bolt-elements-textSecondary mb-1">Header Name</label>
<input
type="text"
value={headerForm.name}
onChange={(e) => 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',
)}
/>
</div>
<div>
<label className="block text-xs font-medium text-bolt-elements-textSecondary mb-1">
Header Value
</label>
<input
type="text"
value={headerForm.value}
onChange={(e) => 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',
)}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<IconButton
onClick={handleCancelEdit}
title="Cancel"
className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<div className="i-ph:x w-4 h-4" />
</IconButton>
<IconButton
onClick={editingHeaderKey !== null ? handleUpdateHeader : handleAddHeader}
title={editingHeaderKey !== null ? 'Update Header' : 'Add Header'}
className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
disabled={!headerForm.name.trim() || !headerForm.value.trim()}
>
<div className="i-ph:check w-4 h-4" />
</IconButton>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Headers List */}
<div className="space-y-2">
<AnimatePresence>
{headerEntries.map(([key, value]) => (
<motion.div
key={key}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className={classNames(
'flex items-center justify-between p-3 rounded-lg',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'hover:bg-bolt-elements-background-depth-3 transition-colors',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-bolt-elements-textPrimary truncate">{key}</span>
<span className="text-xs px-2 py-0.5 rounded bg-blue-500/10 text-blue-500">{value}</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">Custom header for {provider}</p>
</div>
<div className="flex items-center gap-1">
<IconButton
onClick={() => handleEditHeader(key)}
title="Edit Header"
className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500"
disabled={isAddingHeader || editingHeaderKey !== null}
>
<div className="i-ph:pencil-simple w-4 h-4" />
</IconButton>
<IconButton
onClick={() => handleDeleteHeader(key)}
title="Delete Header"
className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
disabled={isAddingHeader || editingHeaderKey !== null}
>
<div className="i-ph:trash w-4 h-4" />
</IconButton>
</div>
</motion.div>
))}
</AnimatePresence>
{headerEntries.length === 0 && !isAddingHeader && (
<div className="text-center py-8 text-bolt-elements-textSecondary">
<div className="i-ph:list-dashes w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No custom headers configured</p>
<p className="text-xs mt-1">Click the + button to add a header</p>
{provider === 'Portkey' && <p className="text-xs mt-2 text-purple-500">Default: x-portkey-debug: false</p>}
</div>
)}
</div>
</div>
);
};

View File

@ -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<ModelManagerProps> = ({ provider, settings, onUpdateSettings }) => {
const [isAddingModel, setIsAddingModel] = useState(false);
const [editingModelIndex, setEditingModelIndex] = useState<number | null>(null);
const [modelForm, setModelForm] = useState<ModelFormData>({
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">Custom Models</h4>
<IconButton
onClick={() => setIsAddingModel(true)}
title="Add Model"
className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
disabled={isAddingModel || editingModelIndex !== null}
>
<div className="i-ph:plus w-4 h-4" />
</IconButton>
</div>
{/* Model Form */}
<AnimatePresence>
{(isAddingModel || editingModelIndex !== null) && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border border-bolt-elements-borderColor rounded-lg p-4 bg-bolt-elements-background-depth-3"
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-bolt-elements-textSecondary mb-1">Model Name</label>
<input
type="text"
value={modelForm.name}
onChange={(e) => 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',
)}
/>
</div>
<div>
<label className="block text-xs font-medium text-bolt-elements-textSecondary mb-1">
Display Label
</label>
<input
type="text"
value={modelForm.label}
onChange={(e) => 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',
)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-bolt-elements-textSecondary mb-1">Max Tokens</label>
<input
type="number"
value={modelForm.maxTokenAllowed}
onChange={(e) => 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',
)}
/>
</div>
<div className="flex items-center justify-end gap-2">
<IconButton
onClick={handleCancelEdit}
title="Cancel"
className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
>
<div className="i-ph:x w-4 h-4" />
</IconButton>
<IconButton
onClick={editingModelIndex !== null ? handleUpdateModel : handleAddModel}
title={editingModelIndex !== null ? 'Update Model' : 'Add Model'}
className="bg-green-500/10 hover:bg-green-500/20 text-green-500"
disabled={!modelForm.name.trim() || !modelForm.label.trim()}
>
<div className="i-ph:check w-4 h-4" />
</IconButton>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Model List */}
<div className="space-y-2">
<AnimatePresence>
{customModels.map((model, index) => (
<motion.div
key={`${model.name}-${index}`}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
className={classNames(
'flex items-center justify-between p-3 rounded-lg',
'bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor',
'hover:bg-bolt-elements-background-depth-3 transition-colors',
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-bolt-elements-textPrimary truncate">{model.label}</span>
<span className="text-xs px-2 py-0.5 rounded bg-purple-500/10 text-purple-500">{model.name}</span>
</div>
<p className="text-xs text-bolt-elements-textSecondary mt-0.5">
Max tokens: {model.maxTokenAllowed.toLocaleString()}
</p>
</div>
<div className="flex items-center gap-1">
<IconButton
onClick={() => handleEditModel(index)}
title="Edit Model"
className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500"
disabled={isAddingModel || editingModelIndex !== null}
>
<div className="i-ph:pencil-simple w-4 h-4" />
</IconButton>
<IconButton
onClick={() => handleDeleteModel(index)}
title="Delete Model"
className="bg-red-500/10 hover:bg-red-500/20 text-red-500"
disabled={isAddingModel || editingModelIndex !== null}
>
<div className="i-ph:trash w-4 h-4" />
</IconButton>
</div>
</motion.div>
))}
</AnimatePresence>
{customModels.length === 0 && !isAddingModel && (
<div className="text-center py-8 text-bolt-elements-textSecondary">
<div className="i-ph:list-dashes w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No custom models configured</p>
<p className="text-xs mt-1">Click the + button to add a model</p>
</div>
)}
</div>
</div>
);
};

View File

@ -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<ProviderName, IconType> = {
@ -48,12 +52,14 @@ const PROVIDER_ICONS: Record<ProviderName, IconType> = {
Perplexity: SiPerplexity,
Together: BsCloud,
XAI: BsRobot,
Portkey: BsCloud,
};
// Update PROVIDER_DESCRIPTIONS to use the same type
const PROVIDER_DESCRIPTIONS: Partial<Record<ProviderName, string>> = {
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<string | null>(null);
const [filteredProviders, setFilteredProviders] = useState<IProviderConfig[]>([]);
const [categoryEnabled, setCategoryEnabled] = useState<boolean>(false);
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
// Load API keys from cookies on mount
useEffect(() => {
const savedApiKeys = getApiKeysFromCookies();
setApiKeys(savedApiKeys);
}, []);
// Load and filter providers
useEffect(() => {
@ -283,6 +296,38 @@ const CloudProvidersTab = () => {
)}
</motion.div>
)}
{/* Special Portkey configuration */}
{provider.settings.enabled && provider.name === 'Portkey' && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-4 space-y-4 border-t border-bolt-elements-borderColor pt-4"
>
{/* API Key Manager */}
<APIKeyManager
provider={provider as ProviderInfo}
apiKey={apiKeys[provider.name] || ''}
setApiKey={(key) => setApiKeys((prev) => ({ ...prev, [provider.name]: key }))}
/>
{/* Model Manager */}
<ModelManager
provider={provider.name}
settings={provider.settings}
onUpdateSettings={(newSettings) => settings.updateProviderSettings(provider.name, newSettings)}
/>
{/* Header Manager */}
<HeaderManager
provider={provider.name}
settings={provider.settings}
onUpdateSettings={(newSettings) => settings.updateProviderSettings(provider.name, newSettings)}
/>
</motion.div>
)}
</div>
</div>

View File

@ -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<string, string>,
) {
const openai = createOpenAI({
baseURL,
apiKey,
headers: customHeaders,
});
return openai(model);

View File

@ -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<string, string>,
settings?: IProviderSetting,
_serverEnv: Record<string, string> = {},
): Promise<ModelInfo[]> {
// Return user-configured custom models
return settings?.customModels || [];
}
getModelInstance(options: {
model: string;
serverEnv: Env;
apiKeys?: Record<string, string>;
providerSettings?: Record<string, IProviderSetting>;
}): 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);
}
}

View File

@ -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,
};

View File

@ -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<string, IProviderConfig>;

View File

@ -17,6 +17,8 @@ export type ProviderInfo = {
export interface IProviderSetting {
enabled?: boolean;
baseUrl?: string;
customModels?: ModelInfo[];
customHeaders?: Record<string, string>;
}
export type IProviderConfig = ProviderInfo & {

View File

@ -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;
}