diff --git a/ui/src/components/models/model-form.tsx b/ui/src/components/models/model-form.tsx index 66d4997..c764a1a 100644 --- a/ui/src/components/models/model-form.tsx +++ b/ui/src/components/models/model-form.tsx @@ -1,9 +1,17 @@ import { useForm } from '@tanstack/react-form'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import type { Model } from '@/lib/api/types'; // ── Types ───────────────────────────────────────────────────────────────────── @@ -18,6 +26,25 @@ export interface ModelFormProps { extraActions?: React.ReactNode; } +type ProviderId = 'anthropic' | 'bedrock' | 'deepseek' | 'gemini' | 'openai'; +type ProviderSelection = ProviderId | ''; + +type ProviderConfigValues = Record; + +interface ProviderConfigFieldSchema { + type: 'string'; + titleKey: string; + descriptionKey?: string; + placeholder?: string; + placeholderKey?: string; + inputType?: React.HTMLInputTypeAttribute; +} + +interface ProviderConfigSchema { + required: string[]; + properties: Record; +} + // ── Constants ───────────────────────────────────────────────────────────────── const RATE_LIMIT_FIELDS = [ @@ -48,6 +75,135 @@ const RATE_LIMIT_FIELDS = [ }, ]; +const PROVIDER_OPTIONS: Array<{ value: ProviderId; labelKey: string }> = [ + { value: 'openai', labelKey: 'models.form.providers.openai' }, + { value: 'anthropic', labelKey: 'models.form.providers.anthropic' }, + { value: 'gemini', labelKey: 'models.form.providers.gemini' }, + { value: 'deepseek', labelKey: 'models.form.providers.deepseek' }, + { value: 'bedrock', labelKey: 'models.form.providers.bedrock' }, +]; + +const OPENAI_COMPATIBLE_CONFIG_SCHEMA: ProviderConfigSchema = { + required: ['api_key'], + properties: { + api_key: { + type: 'string', + titleKey: 'models.form.apiKeyLabel', + placeholder: 'sk-…', + inputType: 'password', + }, + api_base: { + type: 'string', + titleKey: 'models.form.apiBase', + descriptionKey: 'models.form.apiBaseHint', + placeholderKey: 'models.form.apiBasePlaceholder', + inputType: 'url', + }, + }, +}; + +const BEDROCK_CONFIG_SCHEMA: ProviderConfigSchema = { + required: ['region', 'access_key_id', 'secret_access_key'], + properties: { + region: { + type: 'string', + titleKey: 'models.form.regionLabel', + descriptionKey: 'models.form.regionHint', + placeholder: 'us-east-1', + }, + access_key_id: { + type: 'string', + titleKey: 'models.form.accessKeyIdLabel', + placeholder: 'AKIA...', + }, + secret_access_key: { + type: 'string', + titleKey: 'models.form.secretAccessKeyLabel', + inputType: 'password', + placeholderKey: 'models.form.secretAccessKeyPlaceholder', + }, + session_token: { + type: 'string', + titleKey: 'models.form.sessionTokenLabel', + descriptionKey: 'models.form.sessionTokenHint', + inputType: 'password', + placeholderKey: 'models.form.sessionTokenPlaceholder', + }, + endpoint: { + type: 'string', + titleKey: 'models.form.endpointLabel', + descriptionKey: 'models.form.endpointHint', + inputType: 'url', + placeholder: 'https://bedrock-runtime.us-east-1.amazonaws.com', + }, + }, +}; + +const PROVIDER_CONFIG_SCHEMAS: Record = { + anthropic: OPENAI_COMPATIBLE_CONFIG_SCHEMA, + bedrock: BEDROCK_CONFIG_SCHEMA, + deepseek: OPENAI_COMPATIBLE_CONFIG_SCHEMA, + gemini: OPENAI_COMPATIBLE_CONFIG_SCHEMA, + openai: OPENAI_COMPATIBLE_CONFIG_SCHEMA, +}; + +function isProviderId(value: string): value is ProviderId { + return PROVIDER_OPTIONS.some((option) => option.value === value); +} + +function splitModelIdentifier(model: string | undefined): { + provider: ProviderSelection; + providerModel: string; +} { + if (!model) { + return { provider: '', providerModel: '' }; + } + + const separatorIndex = model.indexOf('/'); + if (separatorIndex === -1) { + return { provider: '', providerModel: model }; + } + + const provider = model.slice(0, separatorIndex).toLowerCase(); + const providerModel = model.slice(separatorIndex + 1); + if (!isProviderId(provider) || !providerModel) { + return { provider: '', providerModel: model }; + } + + return { provider, providerModel }; +} + +function normalizeProviderConfigValues( + provider: ProviderId, + source: Model['provider_config'] | ProviderConfigValues | undefined, +): ProviderConfigValues { + const schema = PROVIDER_CONFIG_SCHEMAS[provider]; + const objectSource = + source && typeof source === 'object' + ? (source as Record) + : undefined; + + return Object.fromEntries( + Object.keys(schema.properties).map((fieldName) => { + const rawValue = objectSource?.[fieldName]; + return [fieldName, typeof rawValue === 'string' ? rawValue : '']; + }), + ); +} + +function serializeProviderConfig( + provider: ProviderId, + values: ProviderConfigValues, +): Model['provider_config'] { + const schema = PROVIDER_CONFIG_SCHEMAS[provider]; + + return Object.fromEntries( + Object.keys(schema.properties) + .map((fieldName) => [fieldName, values[fieldName]?.trim() ?? '']) + .filter(([, value]) => value.length > 0), + ); +} + function parseOptionalNonNegativeInteger(raw: string): number | undefined { const trimmed = raw.trim(); if (!trimmed) { @@ -74,12 +230,31 @@ export function ModelForm({ extraActions, }: ModelFormProps) { const { t } = useTranslation(); + const initialModel = splitModelIdentifier(initial?.model); + const initialProviderConfigValues = initialModel.provider + ? normalizeProviderConfigValues( + initialModel.provider, + initial?.provider_config, + ) + : {}; + const [provider, setProvider] = useState( + initialModel.provider, + ); + const [providerConfigDrafts, setProviderConfigDrafts] = useState< + Partial> + >(() => + initialModel.provider + ? { [initialModel.provider]: initialProviderConfigValues } + : {}, + ); + const [providerConfigValues, setProviderConfigValues] = + useState(initialProviderConfigValues); + const [clientError, setClientError] = useState(); + const form = useForm({ defaultValues: { name: initial?.name ?? '', - model: initial?.model ?? '', - api_key: initial?.provider_config.api_key ?? '', - api_base: initial?.provider_config.api_base ?? '', + model: initialModel.providerModel, timeout: initial?.timeout != null ? String(initial.timeout) : '', tpm: initial?.rate_limit?.tpm != null ? String(initial.rate_limit.tpm) : '', @@ -95,6 +270,19 @@ export function ModelForm({ : '', }, onSubmit: async ({ value }) => { + if (!provider) { + setClientError(t('models.form.providerRequired')); + return; + } + + const trimmedModel = value.model.trim(); + if (!trimmedModel) { + setClientError(t('models.form.modelRequired')); + return; + } + + setClientError(undefined); + const tpm = parseOptionalNonNegativeInteger(value.tpm); const tpd = parseOptionalNonNegativeInteger(value.tpd); const rpm = parseOptionalNonNegativeInteger(value.rpm); @@ -111,11 +299,11 @@ export function ModelForm({ }; const payload: Model = { name: value.name.trim(), - model: value.model.trim(), - provider_config: { - ...(value.api_key ? { api_key: value.api_key.trim() } : {}), - ...(value.api_base ? { api_base: value.api_base.trim() } : {}), - }, + model: `${provider}/${trimmedModel}`, + provider_config: serializeProviderConfig( + provider, + providerConfigValues, + ), ...(timeout != null ? { timeout } : {}), ...(Object.keys(rateLimit).length > 0 ? { rate_limit: rateLimit } : {}), }; @@ -123,6 +311,52 @@ export function ModelForm({ }, }); + const providerConfigSchema = provider + ? PROVIDER_CONFIG_SCHEMAS[provider] + : undefined; + + function handleProviderChange(nextProvider: string) { + if (!isProviderId(nextProvider)) { + return; + } + + const nextDrafts = provider + ? { + ...providerConfigDrafts, + [provider]: { ...providerConfigValues }, + } + : providerConfigDrafts; + const nextProviderDraft = nextDrafts[nextProvider]; + + setProviderConfigDrafts(nextDrafts); + setProvider(nextProvider); + setProviderConfigValues( + normalizeProviderConfigValues(nextProvider, nextProviderDraft), + ); + setClientError(undefined); + } + + function handleProviderConfigFieldChange( + fieldName: string, + nextValue: string, + ) { + setProviderConfigValues((current) => { + const nextValues = { + ...current, + [fieldName]: nextValue, + }; + + if (provider) { + setProviderConfigDrafts((currentDrafts) => ({ + ...currentDrafts, + [provider]: nextValues, + })); + } + + return nextValues; + }); + } + return (
{ @@ -136,7 +370,7 @@ export function ModelForm({

{t('models.form.basicInfo')}

-
+
{(field) => ( @@ -151,16 +385,36 @@ export function ModelForm({ )} + + + + {(field) => ( - + field.handleChange(e.target.value)} + onChange={(e) => { + setClientError(undefined); + field.handleChange(e.target.value); + }} onBlur={field.handleBlur} placeholder={t('models.form.modelPlaceholder')} /> @@ -176,38 +430,42 @@ export function ModelForm({ {t('models.form.providerConfig')} -
- - {(field) => ( - - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder="sk-…" - autoComplete="off" - /> - + {providerConfigSchema ? ( +
+ {Object.entries(providerConfigSchema.properties).map( + ([fieldName, fieldSchema]) => ( + + + handleProviderConfigFieldChange(fieldName, e.target.value) + } + placeholder={ + fieldSchema.placeholderKey + ? t(fieldSchema.placeholderKey) + : fieldSchema.placeholder + } + autoComplete="off" + /> + + ), )} - - - - {(field) => ( - - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder={t('models.form.apiBasePlaceholder')} - /> - - )} - -
+
+ ) : ( +

+ {t('models.form.providerConfigSelectHint')} +

+ )}
{/* Advanced */} @@ -235,7 +493,7 @@ export function ModelForm({

{t('models.form.rateLimits')}

-
+
{RATE_LIMIT_FIELDS.map(({ name, labelKey, hintKey }) => ( {(field) => ( @@ -259,9 +517,9 @@ export function ModelForm({
- {error && ( + {(clientError ?? error) && (

- {error} + {clientError ?? error}

)} diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index 4fefefa..80a011a 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -81,7 +81,7 @@ "addModel": "Add Model", "deleteModel": "Delete Model", "createTitle": "Create model resource", - "createDesc": "Required: name, model, provider config API key. Optional: timeout, rate limits.", + "createDesc": "Required: name, provider, provider model, provider-specific config. Optional: timeout and rate limits.", "editTitle": "Edit model resource", "createModel": "Create Model", "columns": { @@ -102,14 +102,28 @@ "basicInfo": "Basic Information", "nameLabel": "Name *", "namePlaceholder": "e.g. @my-llm/chat", - "modelLabel": "Model *", - "modelHint": "Format: provider/model-name", - "modelPlaceholder": "e.g. deepseek/deepseek-chat", + "providerLabel": "Provider *", + "providerPlaceholder": "Select provider", + "providerRequired": "Please select a provider before saving.", + "providerConfigSelectHint": "Select a provider to load the matching config form.", + "modelLabel": "Provider Model *", + "modelPlaceholder": "e.g. gpt-4.1, deepseek-chat, or anthropic.claude-3-5-sonnet-20240620-v1:0", + "modelRequired": "Please enter a provider model identifier.", "providerConfig": "Provider Config", "apiKeyLabel": "API Key", "apiBase": "API Base", "apiBaseHint": "Leave blank to use provider default", "apiBasePlaceholder": "https://api.example.com/v1", + "regionLabel": "AWS Region", + "regionHint": "Used to choose the default Bedrock runtime endpoint.", + "accessKeyIdLabel": "AWS Access Key ID", + "secretAccessKeyLabel": "AWS Secret Access Key", + "secretAccessKeyPlaceholder": "AWS secret access key", + "sessionTokenLabel": "AWS Session Token", + "sessionTokenHint": "Optional temporary credential when using STS or assumed roles.", + "sessionTokenPlaceholder": "Optional temporary credential", + "endpointLabel": "Bedrock Endpoint Override", + "endpointHint": "Leave blank to use the standard runtime endpoint for the selected region.", "advanced": "Advanced (Optional)", "timeout": "Timeout (ms)", "rateLimits": "Rate Limits", @@ -121,7 +135,14 @@ "rpmHint": "Requests / minute", "rpd": "RPD", "rpdHint": "Requests / day", - "concurrency": "Concurrency" + "concurrency": "Concurrency", + "providers": { + "openai": "OpenAI", + "anthropic": "Anthropic", + "gemini": "Gemini", + "deepseek": "DeepSeek", + "bedrock": "AWS Bedrock" + } } }, "apiKeys": { diff --git a/ui/src/i18n/locales/zh-CN.json b/ui/src/i18n/locales/zh-CN.json index e83ca46..d628276 100644 --- a/ui/src/i18n/locales/zh-CN.json +++ b/ui/src/i18n/locales/zh-CN.json @@ -81,7 +81,7 @@ "addModel": "添加模型", "deleteModel": "删除模型", "createTitle": "创建模型资源", - "createDesc": "必填:name、model、provider config API key。可选:timeout、rate limits。", + "createDesc": "必填:name、provider、provider model、provider 专属配置。可选:timeout 和 rate limits。", "editTitle": "编辑模型资源", "createModel": "创建模型", "columns": { @@ -102,14 +102,28 @@ "basicInfo": "基础信息", "nameLabel": "名称 *", "namePlaceholder": "例如:@my-llm/chat", - "modelLabel": "Model *", - "modelHint": "格式:provider/model-name", - "modelPlaceholder": "例如:deepseek/deepseek-chat", + "providerLabel": "Provider *", + "providerPlaceholder": "选择 provider", + "providerRequired": "保存前请先选择 provider。", + "providerConfigSelectHint": "先选择 provider,再加载对应的配置表单。", + "modelLabel": "Provider Model *", + "modelPlaceholder": "例如:gpt-4.1、deepseek-chat 或 anthropic.claude-3-5-sonnet-20240620-v1:0", + "modelRequired": "请输入 provider 侧模型标识。", "providerConfig": "Provider 配置", "apiKeyLabel": "API Key", "apiBase": "API Base", "apiBaseHint": "留空则使用 provider 默认值", "apiBasePlaceholder": "https://api.example.com/v1", + "regionLabel": "AWS Region", + "regionHint": "用于选择默认的 Bedrock 运行时地址。", + "accessKeyIdLabel": "AWS Access Key ID", + "secretAccessKeyLabel": "AWS Secret Access Key", + "secretAccessKeyPlaceholder": "AWS secret access key", + "sessionTokenLabel": "AWS Session Token", + "sessionTokenHint": "使用 STS 或 AssumeRole 临时凭证时可选。", + "sessionTokenPlaceholder": "可选的临时凭证", + "endpointLabel": "Bedrock Endpoint Override", + "endpointHint": "留空则根据所选 Region 使用标准运行时地址。", "advanced": "高级(可选)", "timeout": "超时(毫秒)", "rateLimits": "限流", @@ -121,7 +135,14 @@ "rpmHint": "每分钟请求数", "rpd": "RPD", "rpdHint": "每天请求数", - "concurrency": "并发" + "concurrency": "并发", + "providers": { + "openai": "OpenAI", + "anthropic": "Anthropic", + "gemini": "Gemini", + "deepseek": "DeepSeek", + "bedrock": "AWS Bedrock" + } } }, "apiKeys": {