Declarative providers (#5084)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2025-10-15 09:48:14 -04:00
committed by GitHub
parent 925a042cb1
commit 9251da4314
28 changed files with 1219 additions and 802 deletions
+206 -37
View File
@@ -382,7 +382,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCustomProviderRequest"
"$ref": "#/components/schemas/UpdateCustomProviderRequest"
}
}
},
@@ -409,6 +409,84 @@
}
},
"/config/custom-providers/{id}": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_custom_provider",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Custom provider retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoadedProvider"
}
}
}
},
"404": {
"description": "Provider not found"
},
"500": {
"description": "Internal server error"
}
}
},
"put": {
"tags": [
"super::routes::config_management"
],
"operationId": "update_custom_provider",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCustomProviderRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Custom provider updated successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Provider not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"super::routes::config_management"
@@ -2203,40 +2281,6 @@
"$ref": "#/components/schemas/Message"
}
},
"CreateCustomProviderRequest": {
"type": "object",
"required": [
"provider_type",
"display_name",
"api_url",
"api_key",
"models"
],
"properties": {
"api_key": {
"type": "string"
},
"api_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"provider_type": {
"type": "string"
},
"supports_streaming": {
"type": "boolean",
"nullable": true
}
}
},
"CreateRecipeRequest": {
"type": "object",
"required": [
@@ -2296,6 +2340,61 @@
}
}
},
"DeclarativeProviderConfig": {
"type": "object",
"required": [
"name",
"engine",
"display_name",
"api_key_env",
"base_url",
"models"
],
"properties": {
"api_key_env": {
"type": "string"
},
"base_url": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
},
"display_name": {
"type": "string"
},
"engine": {
"$ref": "#/components/schemas/ProviderEngine"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"nullable": true
},
"models": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ModelInfo"
}
},
"name": {
"type": "string"
},
"supports_streaming": {
"type": "boolean",
"nullable": true
},
"timeout_seconds": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
}
}
},
"DecodeRecipeRequest": {
"type": "object",
"required": [
@@ -2979,6 +3078,21 @@
}
}
},
"LoadedProvider": {
"type": "object",
"required": [
"config",
"is_editable"
],
"properties": {
"config": {
"$ref": "#/components/schemas/DeclarativeProviderConfig"
},
"is_editable": {
"type": "boolean"
}
}
},
"Message": {
"type": "object",
"description": "A message to or from an LLM",
@@ -3347,7 +3461,8 @@
"required": [
"name",
"metadata",
"is_configured"
"is_configured",
"provider_type"
],
"properties": {
"is_configured": {
@@ -3358,9 +3473,20 @@
},
"name": {
"type": "string"
},
"provider_type": {
"$ref": "#/components/schemas/ProviderType"
}
}
},
"ProviderEngine": {
"type": "string",
"enum": [
"openai",
"ollama",
"anthropic"
]
},
"ProviderMetadata": {
"type": "object",
"description": "Metadata about a provider's configuration requirements and capabilities",
@@ -3398,7 +3524,7 @@
"items": {
"$ref": "#/components/schemas/ModelInfo"
},
"description": "A list of currently known models with their capabilities\nTODO: eventually query the apis directly"
"description": "A list of currently known models with their capabilities"
},
"model_doc_link": {
"type": "string",
@@ -3410,6 +3536,15 @@
}
}
},
"ProviderType": {
"type": "string",
"enum": [
"Preferred",
"Builtin",
"Declarative",
"Custom"
]
},
"ProvidersResponse": {
"type": "object",
"required": [
@@ -4449,6 +4584,40 @@
}
}
},
"UpdateCustomProviderRequest": {
"type": "object",
"required": [
"engine",
"display_name",
"api_url",
"api_key",
"models"
],
"properties": {
"api_key": {
"type": "string"
},
"api_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"engine": {
"type": "string"
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"supports_streaming": {
"type": "boolean",
"nullable": true
}
}
},
"UpdateProviderRequest": {
"type": "object",
"required": [
+19 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from './client';
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, ImportSessionData, ImportSessionResponses, ImportSessionErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, ExportSessionData, ExportSessionResponses, ExportSessionErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen';
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetCustomProviderData, GetCustomProviderResponses, GetCustomProviderErrors, UpdateCustomProviderData, UpdateCustomProviderResponses, UpdateCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, ImportSessionData, ImportSessionResponses, ImportSessionErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, ExportSessionData, ExportSessionResponses, ExportSessionErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -134,6 +134,24 @@ export const removeCustomProvider = <ThrowOnError extends boolean = false>(optio
});
};
export const getCustomProvider = <ThrowOnError extends boolean = false>(options: Options<GetCustomProviderData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetCustomProviderResponses, GetCustomProviderErrors, ThrowOnError>({
url: '/config/custom-providers/{id}',
...options
});
};
export const updateCustomProvider = <ThrowOnError extends boolean = false>(options: Options<UpdateCustomProviderData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).put<UpdateCustomProviderResponses, UpdateCustomProviderErrors, ThrowOnError>({
url: '/config/custom-providers/{id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const getExtensions = <ThrowOnError extends boolean = false>(options?: Options<GetExtensionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetExtensionsResponses, GetExtensionsErrors, ThrowOnError>({
url: '/config/extensions',
+93 -11
View File
@@ -110,15 +110,6 @@ export type ContextManageResponse = {
export type Conversation = Array<Message>;
export type CreateCustomProviderRequest = {
api_key: string;
api_url: string;
display_name: string;
models: Array<string>;
provider_type: string;
supports_streaming?: boolean | null;
};
export type CreateRecipeRequest = {
author?: AuthorRequest | null;
session_id: string;
@@ -136,6 +127,21 @@ export type CreateScheduleRequest = {
recipe_source: string;
};
export type DeclarativeProviderConfig = {
api_key_env: string;
base_url: string;
description?: string | null;
display_name: string;
engine: ProviderEngine;
headers?: {
[key: string]: string;
} | null;
models: Array<ModelInfo>;
name: string;
supports_streaming?: boolean | null;
timeout_seconds?: number | null;
};
export type DecodeRecipeRequest = {
deeplink: string;
};
@@ -367,6 +373,11 @@ export type ListSchedulesResponse = {
jobs: Array<ScheduledJob>;
};
export type LoadedProvider = {
config: DeclarativeProviderConfig;
is_editable: boolean;
};
/**
* A message to or from an LLM
*/
@@ -473,8 +484,11 @@ export type ProviderDetails = {
is_configured: boolean;
metadata: ProviderMetadata;
name: string;
provider_type: ProviderType;
};
export type ProviderEngine = 'openai' | 'ollama' | 'anthropic';
/**
* Metadata about a provider's configuration requirements and capabilities
*/
@@ -497,7 +511,6 @@ export type ProviderMetadata = {
display_name: string;
/**
* A list of currently known models with their capabilities
* TODO: eventually query the apis directly
*/
known_models: Array<ModelInfo>;
/**
@@ -510,6 +523,8 @@ export type ProviderMetadata = {
name: string;
};
export type ProviderType = 'Preferred' | 'Builtin' | 'Declarative' | 'Custom';
export type ProvidersResponse = {
providers: Array<ProviderDetails>;
};
@@ -852,6 +867,15 @@ export type ToolResponse = {
};
};
export type UpdateCustomProviderRequest = {
api_key: string;
api_url: string;
display_name: string;
engine: string;
models: Array<string>;
supports_streaming?: boolean | null;
};
export type UpdateProviderRequest = {
model?: string | null;
provider: string;
@@ -1183,7 +1207,7 @@ export type BackupConfigResponses = {
export type BackupConfigResponse = BackupConfigResponses[keyof BackupConfigResponses];
export type CreateCustomProviderData = {
body: CreateCustomProviderRequest;
body: UpdateCustomProviderRequest;
path?: never;
query?: never;
url: '/config/custom-providers';
@@ -1238,6 +1262,64 @@ export type RemoveCustomProviderResponses = {
export type RemoveCustomProviderResponse = RemoveCustomProviderResponses[keyof RemoveCustomProviderResponses];
export type GetCustomProviderData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/config/custom-providers/{id}';
};
export type GetCustomProviderErrors = {
/**
* Provider not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type GetCustomProviderResponses = {
/**
* Custom provider retrieved successfully
*/
200: LoadedProvider;
};
export type GetCustomProviderResponse = GetCustomProviderResponses[keyof GetCustomProviderResponses];
export type UpdateCustomProviderData = {
body: UpdateCustomProviderRequest;
path: {
id: string;
};
query?: never;
url: '/config/custom-providers/{id}';
};
export type UpdateCustomProviderErrors = {
/**
* Provider not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type UpdateCustomProviderResponses = {
/**
* Custom provider updated successfully
*/
200: string;
};
export type UpdateCustomProviderResponse = UpdateCustomProviderResponses[keyof UpdateCustomProviderResponses];
export type GetExtensionsData = {
body?: never;
path?: never;
@@ -18,6 +18,7 @@ import { useModelAndProvider } from '../../../ModelAndProviderContext';
import type { View } from '../../../../utils/navigationUtils';
import Model, { getProviderMetadata } from '../modelInterface';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
import { ProviderType } from '../../../../api';
type SwitchModelModalProps = {
sessionId: string | null;
@@ -165,8 +166,9 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
const results = await Promise.all(modelPromises);
// Process results and build grouped options
const groupedOptions: { options: { value: string; label: string; provider: string }[] }[] =
[];
const groupedOptions: {
options: { value: string; label: string; provider: string; providerType: ProviderType }[];
}[] = [];
const errors: string[] = [];
results.forEach(({ provider: p, models, error }) => {
@@ -178,13 +180,19 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
options: p.metadata.known_models.map(({ name }) => ({
value: name,
label: name,
providerType: p.provider_type,
provider: p.name,
})),
});
}
} else if (models && models.length > 0) {
groupedOptions.push({
options: models.map((m) => ({ value: m, label: m, provider: p.name })),
options: models.map((m) => ({
value: m,
label: m,
provider: p.name,
providerType: p.provider_type,
})),
});
}
});
@@ -196,12 +204,14 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
// Add the "Custom model" option to each provider group
groupedOptions.forEach((group) => {
const providerName = group.options[0]?.provider;
if (providerName && !providerName.startsWith('custom_')) {
const option = group.options[0];
const providerName = option?.provider;
if (providerName && option?.providerType !== 'Custom') {
group.options.push({
value: 'custom',
label: 'Use custom model',
provider: providerName,
providerType: option?.providerType,
});
}
});
@@ -3,7 +3,11 @@ import { ProviderCard } from './subcomponents/ProviderCard';
import CardContainer from './subcomponents/CardContainer';
import { ProviderModalProvider, useProviderModal } from './modal/ProviderModalProvider';
import ProviderConfigurationModal from './modal/ProviderConfiguationModal';
import { ProviderDetails, CreateCustomProviderRequest } from '../../../api';
import {
DeclarativeProviderConfig,
ProviderDetails,
UpdateCustomProviderRequest,
} from '../../../api';
import { Plus } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
@@ -43,7 +47,6 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
);
});
// Memoize the ProviderCards component
const ProviderCards = memo(function ProviderCards({
providers,
isOnboarding,
@@ -57,28 +60,69 @@ const ProviderCards = memo(function ProviderCards({
}) {
const { openModal } = useProviderModal();
const [showCustomProviderModal, setShowCustomProviderModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<{
id: string;
config: DeclarativeProviderConfig;
isEditable: boolean;
} | null>(null);
// Memoize these functions so they don't get recreated on every render
const configureProviderViaModal = useCallback(
(provider: ProviderDetails) => {
openModal(provider, {
onSubmit: () => {
// Only refresh if the function is provided
if (refreshProviders) {
refreshProviders();
}
},
onDelete: (_values: unknown) => {
if (refreshProviders) {
refreshProviders();
}
},
formProps: {},
});
async (provider: ProviderDetails) => {
if (provider.provider_type === 'Custom' || provider.provider_type === 'Declarative') {
const { getCustomProvider } = await import('../../../api');
const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });
if (result.data) {
setEditingProvider({
id: provider.name,
config: result.data.config,
isEditable: result.data.is_editable,
});
setShowCustomProviderModal(true);
}
} else {
openModal(provider, {
onSubmit: () => {
if (refreshProviders) {
refreshProviders();
}
},
onDelete: (_values: unknown) => {
if (refreshProviders) {
refreshProviders();
}
},
formProps: {},
});
}
},
[openModal, refreshProviders]
);
const handleUpdateCustomProvider = useCallback(
async (data: UpdateCustomProviderRequest) => {
if (!editingProvider) return;
const { updateCustomProvider } = await import('../../../api');
await updateCustomProvider({
path: { id: editingProvider.id },
body: data,
throwOnError: true,
});
setShowCustomProviderModal(false);
setEditingProvider(null);
if (refreshProviders) {
refreshProviders();
}
},
[editingProvider, refreshProviders]
);
const handleCloseModal = useCallback(() => {
setShowCustomProviderModal(false);
setEditingProvider(null);
}, []);
const deleteProviderConfigViaModal = useCallback(
(provider: ProviderDetails) => {
openModal(provider, {
@@ -95,22 +139,17 @@ const ProviderCards = memo(function ProviderCards({
);
const handleCreateCustomProvider = useCallback(
async (data: CreateCustomProviderRequest) => {
try {
const { createCustomProvider } = await import('../../../api');
await createCustomProvider({ body: data });
setShowCustomProviderModal(false);
if (refreshProviders) {
refreshProviders();
}
} catch (error) {
console.error('Failed to create custom provider:', error);
async (data: UpdateCustomProviderRequest) => {
const { createCustomProvider } = await import('../../../api');
await createCustomProvider({ body: data, throwOnError: true });
setShowCustomProviderModal(false);
if (refreshProviders) {
refreshProviders();
}
},
[refreshProviders]
);
// Use useMemo to memoize the cards array
const providerCards = useMemo(() => {
// providers needs to be an array
const providersArray = Array.isArray(providers) ? providers : [];
@@ -138,21 +177,33 @@ const ProviderCards = memo(function ProviderCards({
onProviderLaunch,
]);
const initialData = editingProvider && {
engine: editingProvider.config.engine.toLowerCase() + '_compatible',
display_name: editingProvider.config.display_name,
api_url: editingProvider.config.base_url,
api_key: '',
models: editingProvider.config.models.map((m) => m.name),
supports_streaming: editingProvider.config.supports_streaming ?? true,
};
const editable = editingProvider ? editingProvider.isEditable : true;
const title = (editingProvider ? (editable ? 'Edit' : 'Configure') : 'Add') + ' Provider';
return (
<>
{providerCards}
<Dialog open={showCustomProviderModal} onOpenChange={setShowCustomProviderModal}>
<Dialog open={showCustomProviderModal} onOpenChange={handleCloseModal}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Custom Provider</DialogTitle>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<CustomProviderForm
onSubmit={handleCreateCustomProvider}
onCancel={() => setShowCustomProviderModal(false)}
initialData={initialData}
isEditable={editable}
onSubmit={editingProvider ? handleUpdateCustomProvider : handleCreateCustomProvider}
onCancel={handleCloseModal}
/>
</DialogContent>
</Dialog>
</Dialog>{' '}
</>
);
});
@@ -162,7 +162,7 @@ export default function ProviderConfigurationModal() {
}
try {
const isCustomProvider = currentProvider.name.startsWith('custom_');
const isCustomProvider = currentProvider.provider_type === 'Custom';
if (isCustomProvider) {
await removeCustomProvider({
@@ -1,24 +1,25 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Input } from '../../../../../ui/input';
import { Select } from '../../../../../ui/Select';
import { Button } from '../../../../../ui/button';
import { SecureStorageNotice } from '../SecureStorageNotice';
import { Checkbox } from '@radix-ui/themes';
import { UpdateCustomProviderRequest } from '../../../../../../api';
interface CustomProviderFormProps {
onSubmit: (data: {
provider_type: string;
display_name: string;
api_url: string;
api_key: string;
models: string[];
supports_streaming: boolean;
}) => void;
onSubmit: (data: UpdateCustomProviderRequest) => void;
onCancel: () => void;
initialData: UpdateCustomProviderRequest | null;
isEditable?: boolean;
}
export default function CustomProviderForm({ onSubmit, onCancel }: CustomProviderFormProps) {
const [providerType, setProviderType] = useState('openai_compatible');
export default function CustomProviderForm({
onSubmit,
onCancel,
initialData,
isEditable,
}: CustomProviderFormProps) {
const [engine, setEngine] = useState('openai_compatible');
const [displayName, setDisplayName] = useState('');
const [apiUrl, setApiUrl] = useState('');
const [apiKey, setApiKey] = useState('');
@@ -27,6 +28,22 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
const [supportsStreaming, setSupportsStreaming] = useState(true);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (initialData) {
const engineMap: Record<string, string> = {
openai: 'openai_compatible',
anthropic: 'anthropic_compatible',
ollama: 'ollama_compatible',
};
setEngine(engineMap[initialData.engine.toLowerCase()] || 'openai_compatible');
setDisplayName(initialData.display_name);
setApiUrl(initialData.api_url);
setModels(initialData.models.join(', '));
setSupportsStreaming(initialData.supports_streaming ?? true);
}
}, [initialData]);
const handleLocalModels = (checked: boolean) => {
setIsLocalModel(checked);
if (checked) {
@@ -42,7 +59,7 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
const errors: Record<string, string> = {};
if (!displayName) errors.displayName = 'Display name is required';
if (!apiUrl) errors.apiUrl = 'API URL is required';
if (!isLocalModel && !apiKey) errors.apiKey = 'API key is required';
if (!isLocalModel && !apiKey && !initialData) errors.apiKey = 'API key is required';
if (!models) errors.models = 'At least one model is required';
if (Object.keys(errors).length > 0) {
@@ -56,7 +73,7 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
.filter((m) => m);
onSubmit({
provider_type: providerType,
engine,
display_name: displayName,
api_url: apiUrl,
api_key: apiKey,
@@ -67,92 +84,94 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
return (
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
<div>
<label
htmlFor="provider-select"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Provider Type
<span className="text-red-500 ml-1">*</span>
</label>
<Select
id="provider-select"
aria-invalid={!!validationErrors.providerType}
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
options={[
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
]}
value={{
value: providerType,
label:
providerType === 'openai_compatible'
? 'OpenAI Compatible'
: providerType === 'anthropic_compatible'
? 'Anthropic Compatible'
: 'Ollama Compatible',
}}
onChange={(option: unknown) => {
const selectedOption = option as { value: string; label: string } | null;
if (selectedOption) setProviderType(selectedOption.value);
}}
isSearchable={false}
/>
{validationErrors.providerType && (
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
{validationErrors.providerType}
</p>
)}
</div>
<div>
<label
htmlFor="display-name"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Display Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Provider Name"
aria-invalid={!!validationErrors.displayName}
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
className={validationErrors.displayName ? 'border-red-500' : ''}
/>
{validationErrors.displayName && (
<p id="display-name-error" className="text-red-500 text-sm mt-1">
{validationErrors.displayName}
</p>
)}
</div>
<div>
<label
htmlFor="api-url"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API URL
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="api-url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/v1/messages"
aria-invalid={!!validationErrors.apiUrl}
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
className={validationErrors.apiUrl ? 'border-red-500' : ''}
/>
{validationErrors.apiUrl && (
<p id="api-url-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiUrl}
</p>
)}
</div>
{isEditable && (
<>
<div>
<label
htmlFor="provider-select"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Provider Type
<span className="text-red-500 ml-1">*</span>
</label>
<Select
id="provider-select"
aria-invalid={!!validationErrors.providerType}
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
options={[
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
]}
value={{
value: engine,
label:
engine === 'openai_compatible'
? 'OpenAI Compatible'
: engine === 'anthropic_compatible'
? 'Anthropic Compatible'
: 'Ollama Compatible',
}}
onChange={(option: unknown) => {
const selectedOption = option as { value: string; label: string } | null;
if (selectedOption) setEngine(selectedOption.value);
}}
isSearchable={false}
/>
{validationErrors.providerType && (
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
{validationErrors.providerType}
</p>
)}
</div>
<div>
<label
htmlFor="display-name"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Display Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Provider Name"
aria-invalid={!!validationErrors.displayName}
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
className={validationErrors.displayName ? 'border-red-500' : ''}
/>
{validationErrors.displayName && (
<p id="display-name-error" className="text-red-500 text-sm mt-1">
{validationErrors.displayName}
</p>
)}
</div>
<div>
<label
htmlFor="api-url"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API URL
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="api-url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/v1/messages"
aria-invalid={!!validationErrors.apiUrl}
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
className={validationErrors.apiUrl ? 'border-red-500' : ''}
/>
{validationErrors.apiUrl && (
<p id="api-url-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiUrl}
</p>
)}
</div>
</>
)}
<div>
<label
@@ -160,14 +179,14 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API Key
{!isLocalModel && <span className="text-red-500 ml-1">*</span>}
{!isLocalModel && !initialData && <span className="text-red-500 ml-1">*</span>}
</label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Your API key"
placeholder={initialData ? 'Leave blank to keep existing key' : 'Your API key'}
aria-invalid={!!validationErrors.apiKey}
aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined}
className={validationErrors.apiKey ? 'border-red-500' : ''}
@@ -179,62 +198,64 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
</p>
)}
<div className="flex items-center space-x-2 mt-2">
<Checkbox id="local-model" checked={isLocalModel} onCheckedChange={handleLocalModels} />
<label
htmlFor="local-model"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
This is a local model (no auth required)
</label>
</div>
</div>
<div>
<label
htmlFor="available-models"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Available Models (comma-separated)
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="available-models"
value={models}
onChange={(e) => setModels(e.target.value)}
placeholder="model-a, model-b, model-c"
aria-invalid={!!validationErrors.models}
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
className={validationErrors.models ? 'border-red-500' : ''}
/>
{validationErrors.models && (
<p id="available-models-error" className="text-red-500 text-sm mt-1">
{validationErrors.models}
</p>
{!initialData && (
<div className="flex items-center space-x-2 mt-2">
<Checkbox id="local-model" checked={isLocalModel} onCheckedChange={handleLocalModels} />
<label
htmlFor="local-model"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
This is a local model (no auth required)
</label>
</div>
)}
</div>
<div className="flex items-center space-x-2 mb-10">
<Checkbox
id="supports-streaming"
checked={supportsStreaming}
onCheckedChange={(checked) => setSupportsStreaming(checked as boolean)}
/>
<label
htmlFor="supports-streaming"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
Provider supports streaming responses
</label>
</div>
{isEditable && (
<>
<div>
<label
htmlFor="available-models"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Available Models (comma-separated)
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="available-models"
value={models}
onChange={(e) => setModels(e.target.value)}
placeholder="model-a, model-b, model-c"
aria-invalid={!!validationErrors.models}
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
className={validationErrors.models ? 'border-red-500' : ''}
/>
{validationErrors.models && (
<p id="available-models-error" className="text-red-500 text-sm mt-1">
{validationErrors.models}
</p>
)}
</div>
<div className="flex items-center space-x-2 mb-10">
<Checkbox
id="supports-streaming"
checked={supportsStreaming}
onCheckedChange={(checked) => setSupportsStreaming(checked as boolean)}
/>
<label
htmlFor="supports-streaming"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
Provider supports streaming responses
</label>
</div>
</>
)}
<SecureStorageNotice />
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Create Provider</Button>
<Button type="submit">{initialData ? 'Update Provider' : 'Create Provider'}</Button>
</div>
</form>
);