From 155aed42708bba173afd3883eaaa3428fbb1310c Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Thu, 23 Apr 2026 16:05:28 +0800 Subject: [PATCH 1/2] feat(ui): add bedrock provider --- ui/src/components/models/model-form.tsx | 276 ++++++++++++++++++++---- ui/src/i18n/locales/en.json | 28 ++- ui/src/i18n/locales/zh-CN.json | 28 ++- 3 files changed, 286 insertions(+), 46 deletions(-) diff --git a/ui/src/components/models/model-form.tsx b/ui/src/components/models/model-form.tsx index 66d4997..a59525f 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,24 @@ export interface ModelFormProps { extraActions?: React.ReactNode; } +type ProviderId = 'anthropic' | 'bedrock' | 'deepseek' | 'gemini' | 'openai'; + +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 +74,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', + placeholder: 'AWS secret access key', + }, + session_token: { + type: 'string', + titleKey: 'models.form.sessionTokenLabel', + descriptionKey: 'models.form.sessionTokenHint', + inputType: 'password', + placeholder: 'Optional temporary credential', + }, + 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: ProviderId; + providerModel: string; +} { + if (!model) { + return { provider: 'openai', providerModel: '' }; + } + + const separatorIndex = model.indexOf('/'); + if (separatorIndex === -1) { + return { provider: 'openai', providerModel: model }; + } + + const provider = model.slice(0, separatorIndex).toLowerCase(); + const providerModel = model.slice(separatorIndex + 1); + if (!isProviderId(provider) || !providerModel) { + return { provider: 'openai', 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 +229,20 @@ export function ModelForm({ extraActions, }: ModelFormProps) { const { t } = useTranslation(); + const initialModel = splitModelIdentifier(initial?.model); + const [provider, setProvider] = useState(initialModel.provider); + const [providerConfigValues, setProviderConfigValues] = + useState(() => + normalizeProviderConfigValues( + initialModel.provider, + initial?.provider_config, + ), + ); + 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) : '', @@ -111,11 +274,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}/${value.model.trim()}`, + provider_config: serializeProviderConfig( + provider, + providerConfigValues, + ), ...(timeout != null ? { timeout } : {}), ...(Object.keys(rateLimit).length > 0 ? { rate_limit: rateLimit } : {}), }; @@ -123,6 +286,29 @@ export function ModelForm({ }, }); + const providerConfigSchema = PROVIDER_CONFIG_SCHEMAS[provider]; + + function handleProviderChange(nextProvider: string) { + if (!isProviderId(nextProvider)) { + return; + } + + setProvider(nextProvider); + setProviderConfigValues((current) => + normalizeProviderConfigValues(nextProvider, current), + ); + } + + function handleProviderConfigFieldChange( + fieldName: string, + nextValue: string, + ) { + setProviderConfigValues((current) => ({ + ...current, + [fieldName]: nextValue, + })); + } + return (
{ @@ -136,7 +322,7 @@ export function ModelForm({

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

-
+
{(field) => ( @@ -151,6 +337,26 @@ export function ModelForm({ )} + + + + {(field) => ( -
- - {(field) => ( - - field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder="sk-…" - autoComplete="off" - /> - - )} - - - - {(field) => ( +
+ {Object.entries(providerConfigSchema.properties).map( + ([fieldName, fieldSchema]) => ( field.handleChange(e.target.value)} - onBlur={field.handleBlur} - placeholder={t('models.form.apiBasePlaceholder')} + required={providerConfigSchema.required.includes(fieldName)} + type={fieldSchema.inputType ?? 'text'} + value={providerConfigValues[fieldName] ?? ''} + onChange={(e) => + handleProviderConfigFieldChange(fieldName, e.target.value) + } + placeholder={ + fieldSchema.placeholderKey + ? t(fieldSchema.placeholderKey) + : fieldSchema.placeholder + } + autoComplete="off" /> - )} - + ), + )}
@@ -235,7 +439,7 @@ export function ModelForm({

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

-
+
{RATE_LIMIT_FIELDS.map(({ name, labelKey, hintKey }) => ( {(field) => ( diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index 4fefefa..c737c50 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,25 @@ "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 *", + "providerHint": "Choose the upstream provider before entering the provider model identifier.", + "providerPlaceholder": "Select provider", + "modelLabel": "Provider Model *", + "modelHint": "Enter the provider-side model identifier without the provider prefix.", + "modelPlaceholder": "e.g. gpt-4.1, deepseek-chat, or anthropic.claude-3-5-sonnet-20240620-v1:0", "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", + "sessionTokenLabel": "AWS Session Token", + "sessionTokenHint": "Optional temporary credential when using STS or assumed roles.", + "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 +132,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..6fd61b8 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,25 @@ "basicInfo": "基础信息", "nameLabel": "名称 *", "namePlaceholder": "例如:@my-llm/chat", - "modelLabel": "Model *", - "modelHint": "格式:provider/model-name", - "modelPlaceholder": "例如:deepseek/deepseek-chat", + "providerLabel": "Provider *", + "providerHint": "先选择上游 provider,再填写 provider 侧模型标识。", + "providerPlaceholder": "选择 provider", + "modelLabel": "Provider Model *", + "modelHint": "填写 provider 前缀后面的模型标识,不要再包含 provider/。", + "modelPlaceholder": "例如:gpt-4.1、deepseek-chat 或 anthropic.claude-3-5-sonnet-20240620-v1:0", "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", + "sessionTokenLabel": "AWS Session Token", + "sessionTokenHint": "使用 STS 或 AssumeRole 临时凭证时可选。", + "endpointLabel": "Bedrock Endpoint Override", + "endpointHint": "留空则根据所选 Region 使用标准运行时地址。", "advanced": "高级(可选)", "timeout": "超时(毫秒)", "rateLimits": "限流", @@ -121,7 +132,14 @@ "rpmHint": "每分钟请求数", "rpd": "RPD", "rpdHint": "每天请求数", - "concurrency": "并发" + "concurrency": "并发", + "providers": { + "openai": "OpenAI", + "anthropic": "Anthropic", + "gemini": "Gemini", + "deepseek": "DeepSeek", + "bedrock": "AWS Bedrock" + } } }, "apiKeys": { From 4efacd75e05dacf85ebecda31a69d2bf5a9dea3a Mon Sep 17 00:00:00 2001 From: bzp2010 Date: Thu, 23 Apr 2026 16:25:19 +0800 Subject: [PATCH 2/2] fix comments --- ui/src/components/models/model-form.tsx | 176 ++++++++++++++++-------- ui/src/i18n/locales/en.json | 7 +- ui/src/i18n/locales/zh-CN.json | 7 +- 3 files changed, 125 insertions(+), 65 deletions(-) diff --git a/ui/src/components/models/model-form.tsx b/ui/src/components/models/model-form.tsx index a59525f..c764a1a 100644 --- a/ui/src/components/models/model-form.tsx +++ b/ui/src/components/models/model-form.tsx @@ -27,6 +27,7 @@ export interface ModelFormProps { } type ProviderId = 'anthropic' | 'bedrock' | 'deepseek' | 'gemini' | 'openai'; +type ProviderSelection = ProviderId | ''; type ProviderConfigValues = Record; @@ -119,14 +120,14 @@ const BEDROCK_CONFIG_SCHEMA: ProviderConfigSchema = { type: 'string', titleKey: 'models.form.secretAccessKeyLabel', inputType: 'password', - placeholder: 'AWS secret access key', + placeholderKey: 'models.form.secretAccessKeyPlaceholder', }, session_token: { type: 'string', titleKey: 'models.form.sessionTokenLabel', descriptionKey: 'models.form.sessionTokenHint', inputType: 'password', - placeholder: 'Optional temporary credential', + placeholderKey: 'models.form.sessionTokenPlaceholder', }, endpoint: { type: 'string', @@ -151,22 +152,22 @@ function isProviderId(value: string): value is ProviderId { } function splitModelIdentifier(model: string | undefined): { - provider: ProviderId; + provider: ProviderSelection; providerModel: string; } { if (!model) { - return { provider: 'openai', providerModel: '' }; + return { provider: '', providerModel: '' }; } const separatorIndex = model.indexOf('/'); if (separatorIndex === -1) { - return { provider: 'openai', providerModel: model }; + return { provider: '', providerModel: model }; } const provider = model.slice(0, separatorIndex).toLowerCase(); const providerModel = model.slice(separatorIndex + 1); if (!isProviderId(provider) || !providerModel) { - return { provider: 'openai', providerModel: model }; + return { provider: '', providerModel: model }; } return { provider, providerModel }; @@ -230,14 +231,25 @@ export function ModelForm({ }: ModelFormProps) { const { t } = useTranslation(); const initialModel = splitModelIdentifier(initial?.model); - const [provider, setProvider] = useState(initialModel.provider); - const [providerConfigValues, setProviderConfigValues] = - useState(() => - normalizeProviderConfigValues( + 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: { @@ -258,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); @@ -274,7 +299,7 @@ export function ModelForm({ }; const payload: Model = { name: value.name.trim(), - model: `${provider}/${value.model.trim()}`, + model: `${provider}/${trimmedModel}`, provider_config: serializeProviderConfig( provider, providerConfigValues, @@ -286,27 +311,50 @@ export function ModelForm({ }, }); - const providerConfigSchema = PROVIDER_CONFIG_SCHEMAS[provider]; + 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((current) => - normalizeProviderConfigValues(nextProvider, current), + setProviderConfigValues( + normalizeProviderConfigValues(nextProvider, nextProviderDraft), ); + setClientError(undefined); } function handleProviderConfigFieldChange( fieldName: string, nextValue: string, ) { - setProviderConfigValues((current) => ({ - ...current, - [fieldName]: nextValue, - })); + setProviderConfigValues((current) => { + const nextValues = { + ...current, + [fieldName]: nextValue, + }; + + if (provider) { + setProviderConfigDrafts((currentDrafts) => ({ + ...currentDrafts, + [provider]: nextValues, + })); + } + + return nextValues; + }); } return ( @@ -337,11 +385,11 @@ 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')} /> @@ -382,36 +430,42 @@ export function ModelForm({ {t('models.form.providerConfig')} -
- {Object.entries(providerConfigSchema.properties).map( - ([fieldName, fieldSchema]) => ( - - - handleProviderConfigFieldChange(fieldName, e.target.value) + {providerConfigSchema ? ( +
+ {Object.entries(providerConfigSchema.properties).map( + ([fieldName, fieldSchema]) => ( + - - ), - )} -
+ > + + handleProviderConfigFieldChange(fieldName, e.target.value) + } + placeholder={ + fieldSchema.placeholderKey + ? t(fieldSchema.placeholderKey) + : fieldSchema.placeholder + } + autoComplete="off" + /> +
+ ), + )} +
+ ) : ( +

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

+ )} {/* Advanced */} @@ -463,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 c737c50..80a011a 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -103,11 +103,12 @@ "nameLabel": "Name *", "namePlaceholder": "e.g. @my-llm/chat", "providerLabel": "Provider *", - "providerHint": "Choose the upstream provider before entering the provider model identifier.", "providerPlaceholder": "Select provider", + "providerRequired": "Please select a provider before saving.", + "providerConfigSelectHint": "Select a provider to load the matching config form.", "modelLabel": "Provider Model *", - "modelHint": "Enter the provider-side model identifier without the provider prefix.", "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", @@ -117,8 +118,10 @@ "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)", diff --git a/ui/src/i18n/locales/zh-CN.json b/ui/src/i18n/locales/zh-CN.json index 6fd61b8..d628276 100644 --- a/ui/src/i18n/locales/zh-CN.json +++ b/ui/src/i18n/locales/zh-CN.json @@ -103,11 +103,12 @@ "nameLabel": "名称 *", "namePlaceholder": "例如:@my-llm/chat", "providerLabel": "Provider *", - "providerHint": "先选择上游 provider,再填写 provider 侧模型标识。", "providerPlaceholder": "选择 provider", + "providerRequired": "保存前请先选择 provider。", + "providerConfigSelectHint": "先选择 provider,再加载对应的配置表单。", "modelLabel": "Provider Model *", - "modelHint": "填写 provider 前缀后面的模型标识,不要再包含 provider/。", "modelPlaceholder": "例如:gpt-4.1、deepseek-chat 或 anthropic.claude-3-5-sonnet-20240620-v1:0", + "modelRequired": "请输入 provider 侧模型标识。", "providerConfig": "Provider 配置", "apiKeyLabel": "API Key", "apiBase": "API Base", @@ -117,8 +118,10 @@ "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": "高级(可选)",