diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 66bdeb524..4745c8d4c 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -728,6 +728,17 @@ pages: alibaba-cloud-model-studio: description: bailian.console.aliyun.com title: Alibaba Cloud Model Studio + amazon-bedrock: + title: Amazon Bedrock + description: aws.amazon.com/bedrock + config: + api-key: + label: Bedrock API Key + description: Amazon Bedrock API key (generate in AWS Console → Bedrock → API Keys) + placeholder: bedrock-... + region: + label: AWS Region + description: AWS region where Bedrock is enabled (e.g. us-east-1, us-west-2) anthropic: description: anthropic.com title: Anthropic | Claude diff --git a/packages/stage-pages/src/pages/settings/providers/chat/amazon-bedrock.vue b/packages/stage-pages/src/pages/settings/providers/chat/amazon-bedrock.vue new file mode 100644 index 000000000..6ebf5b967 --- /dev/null +++ b/packages/stage-pages/src/pages/settings/providers/chat/amazon-bedrock.vue @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + {{ t('settings.dialogs.onboarding.validationFailed') }} + + {{ t('settings.pages.providers.common.continueAnyway') }} + + + + + + {{ validationMessage }} + + + + + + + {{ t('settings.dialogs.onboarding.validationSuccess') }} + + {{ t('settings.pages.providers.common.goToModelSelection') }} + + + + + + + + + +meta: + layout: settings + stageTransition: + name: slide + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue index b00854f1e..8b764c4af 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/onboarding.vue @@ -43,7 +43,7 @@ const { // Popular providers for first-time setup const popularProviders = computed(() => { - const popular = ['openai', 'azure-openai', 'anthropic', 'google-generative-ai', 'groq', 'nvidia', 'openrouter-ai', 'ollama', 'deepseek', 'player2', 'openai-compatible'] + const popular = ['openai', 'azure-openai', 'anthropic', 'amazon-bedrock', 'google-generative-ai', 'groq', 'nvidia', 'openrouter-ai', 'ollama', 'deepseek', 'player2', 'openai-compatible'] return allChatProvidersMetadata.value .filter(provider => popular.includes(provider.id)) .sort((a, b) => popular.indexOf(a.id) - popular.indexOf(b.id)) @@ -83,6 +83,12 @@ async function saveProviderConfiguration(data: ProviderConfigData) { config.baseUrl = data.baseUrl.trim() if (data.accountId) config.accountId = data.accountId.trim() + if (data.customFields) { + for (const [key, value] of Object.entries(data.customFields)) { + if (value) + config[key] = value.trim() + } + } providers.value[selectedProvider.value.id] = { ...providers.value[selectedProvider.value.id], diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-provider-configuration.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-provider-configuration.vue index 47c419759..02c2e72e9 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-provider-configuration.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-provider-configuration.vue @@ -26,10 +26,13 @@ const apiKey = ref('') const baseUrl = ref('') const accountId = ref('') const enableChatCheck = ref(true) +const customFieldValues = ref>({}) const validation = ref<'unchecked' | 'pending' | 'succeed' | 'failed'>('unchecked') const validationError = ref() +const hasOnboardingFields = computed(() => (props.selectedProvider?.onboardingFields?.length ?? 0) > 0) + // Initialize form with default values when provider changes function initializeForm() { const provider = props.selectedProvider @@ -41,6 +44,13 @@ function initializeForm() { apiKey.value = '' accountId.value = '' + // Initialize custom fields with their default values + const fields: Record = {} + for (const field of provider.onboardingFields ?? []) { + fields[field.key] = field.defaultValue ?? '' + } + customFieldValues.value = fields + // Reset validation and chat check validation.value = 'unchecked' validationError.value = undefined @@ -50,23 +60,29 @@ function initializeForm() { // Watch for provider changes watch(() => props.selectedProvider?.id, initializeForm) -watch([apiKey, baseUrl, accountId], () => { +watch([apiKey, baseUrl, accountId, customFieldValues], () => { if (validation.value === 'failed' || validation.value === 'succeed') { validation.value = 'unchecked' validationError.value = undefined } -}) +}, { deep: true }) // Computed properties const needsApiKey = computed(() => { if (!props.selectedProvider) return false + // Providers with custom onboarding fields handle their own auth + if (hasOnboardingFields.value) + return false return props.selectedProvider.id !== 'ollama' && props.selectedProvider.id !== 'player2' }) const needsBaseUrl = computed(() => { if (!props.selectedProvider) return false + // Providers with custom onboarding fields handle their own endpoints + if (hasOnboardingFields.value) + return false return props.selectedProvider.id !== 'cloudflare-workers-ai' }) @@ -78,8 +94,16 @@ const canProceed = computed(() => { if (!props.selectedProviderId) return false - if (needsApiKey.value && !apiKey.value.trim()) + if (hasOnboardingFields.value) { + const fields = props.selectedProvider?.onboardingFields ?? [] + for (const field of fields) { + if (field.required && !customFieldValues.value[field.key]?.trim()) + return false + } + } + else if (needsApiKey.value && !apiKey.value.trim()) { return false + } return validation.value !== 'pending' }) @@ -101,12 +125,20 @@ async function validateConfiguration() { // Prepare config object const config: Record = {} - if (needsApiKey.value) - config.apiKey = apiKey.value.trim() - if (needsBaseUrl.value) - config.baseUrl = baseUrl.value.trim() - if (props.selectedProvider.id === 'cloudflare-workers-ai') - config.accountId = accountId.value.trim() + if (hasOnboardingFields.value) { + for (const [key, value] of Object.entries(customFieldValues.value)) { + if (value) + config[key] = value.trim() + } + } + else { + if (needsApiKey.value) + config.apiKey = apiKey.value.trim() + if (needsBaseUrl.value) + config.baseUrl = baseUrl.value.trim() + if (props.selectedProvider.id === 'cloudflare-workers-ai') + config.accountId = accountId.value.trim() + } // Validate using provider's validator const metadata = providersStore.getProviderMetadata(props.selectedProvider.id) @@ -132,6 +164,7 @@ async function handleNext() { apiKey: apiKey.value, baseUrl: baseUrl.value, accountId: accountId.value, + customFields: hasOnboardingFields.value ? { ...customFieldValues.value } : undefined, }) } } @@ -144,6 +177,7 @@ async function handleContinueAnyway() { apiKey: apiKey.value, baseUrl: baseUrl.value, accountId: accountId.value, + customFields: hasOnboardingFields.value ? { ...customFieldValues.value } : undefined, }) providersStore.forceProviderConfigured(props.selectedProvider.id) } @@ -209,33 +243,50 @@ initializeForm() - - + + - + - - - - + + + + + + - - - - + + + + + + + + + + diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/types.ts b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/types.ts index 2c3135fb9..32d9d283a 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/types.ts +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/types.ts @@ -7,6 +7,7 @@ export interface ProviderConfigData { apiKey: string baseUrl: string accountId: string + customFields?: Record } export type OnboardingStepNextHandler = (configData?: ProviderConfigData) => Promise | void diff --git a/packages/stage-ui/src/composables/use-provider-validation.ts b/packages/stage-ui/src/composables/use-provider-validation.ts index 5f013c5fe..5c083662e 100644 --- a/packages/stage-ui/src/composables/use-provider-validation.ts +++ b/packages/stage-ui/src/composables/use-provider-validation.ts @@ -137,13 +137,16 @@ export function useProviderValidation(providerId: string) { } } - const debouncedValidateConfiguration = useDebounceFn(() => { - const config = credentials.value - const hasApiKey = 'apiKey' in config && !!config.apiKey?.trim() - const hasBaseUrl = 'baseUrl' in config && !!config.baseUrl?.trim() - const hasAccountId = 'accountId' in config && !!config.accountId?.trim() + const AUTH_FIELDS = ['apiKey', 'baseUrl', 'accountId', 'apiToken', 'accessToken'] as const - if (!hasApiKey && !hasBaseUrl && !hasAccountId) { + const debouncedValidateConfiguration = useDebounceFn(() => { + const config = credentials.value as Record + // Only check auth credential fields — excludes config-only fields like region, endpoint + const hasAnyCredential = AUTH_FIELDS.some((field) => { + const v = config[field] + return v !== null && v !== undefined && String(v).trim() !== '' + }) + if (!hasAnyCredential) { isValid.value = false validationMessage.value = '' isValidating.value = 0 @@ -154,7 +157,11 @@ export function useProviderValidation(providerId: string) { onMounted(() => { providersStore.initializeProvider(providerId) - if (Object.keys(credentials.value).some(key => !!credentials.value[key])) { + const config = credentials.value as Record + if (AUTH_FIELDS.some((field) => { + const v = config[field] + return v !== null && v !== undefined && String(v).trim() !== '' + })) { validateConfiguration() } }) diff --git a/packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.test.ts b/packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.test.ts new file mode 100644 index 000000000..e667036a4 --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { providerAmazonBedrock } from './index' + +describe('providerAmazonBedrock', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should have correct id and tasks', () => { + expect(providerAmazonBedrock.id).toBe('amazon-bedrock') + expect(providerAmazonBedrock.tasks).toContain('chat') + }) + + it('should require validation when apiKey is provided', () => { + expect(providerAmazonBedrock.validationRequiredWhen?.({ + apiKey: 'some-api-key', + region: 'us-east-1', + })).toBe(true) + }) + + it('should not require validation when apiKey is empty', () => { + expect(providerAmazonBedrock.validationRequiredWhen?.({ + apiKey: '', + region: 'us-east-1', + })).toBe(false) + }) + + it('should not require validation when only region is provided', () => { + expect(providerAmazonBedrock.validationRequiredWhen?.({ + apiKey: '', + } as any)).toBe(false) + }) + + it('should create provider with valid config', () => { + const provider = providerAmazonBedrock.createProvider({ + apiKey: 'some-api-key', + region: 'us-east-1', + }) + expect(provider).toBeDefined() + }) + + it('should use default us-east-1 region when not specified', () => { + const provider = providerAmazonBedrock.createProvider({ + apiKey: 'some-api-key', + } as any) + expect(provider).toBeDefined() + }) + + it('should fall back to static models when API is unavailable', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 401, + })) + const models = await providerAmazonBedrock.extraMethods?.listModels?.({ + apiKey: 'invalid-key', + region: 'us-east-1', + }, providerAmazonBedrock.createProvider({ + apiKey: 'invalid-key', + region: 'us-east-1', + })) + expect(models).toBeDefined() + expect(models!.length).toBeGreaterThan(0) + expect(models!.some(m => m.id.includes('nova'))).toBe(true) + }) +}) diff --git a/packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.ts b/packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.ts new file mode 100644 index 000000000..aff7fe6f8 --- /dev/null +++ b/packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.ts @@ -0,0 +1,397 @@ +import type { ModelInfo } from '../../types' + +import { createModelProvider, merge } from '@xsai-ext/providers/utils' +import { z } from 'zod' + +import { defineProvider } from '../registry' + +const amazonBedrockConfigSchema = z.object({ + apiKey: z + .string('Amazon Bedrock API Key') + .min(1), + region: z + .string('AWS Region') + .regex(/^[a-z]{2,3}-[a-z]+-\d+$/, 'Must be a valid AWS region (e.g. us-east-1, ap-southeast-1)') + .optional() + .default('us-east-1'), +}) + +type AmazonBedrockConfig = z.infer + +// Helper: merge consecutive messages with the same role (Converse API requires alternating) +function mergeConsecutiveRoles(messages: Array<{ role: string, content: any[] }>) { + const merged: Array<{ role: string, content: any[] }> = [] + for (const msg of messages) { + const last = merged.at(-1) + if (last && last.role === msg.role) { + last.content.push(...msg.content) + } + else { + merged.push({ role: msg.role, content: [...msg.content] }) + } + } + return merged +} + +// Helper: convert xsai message content to Converse content blocks +function toConverseContent(content: any): Array<{ text: string }> { + if (typeof content === 'string') { + return [{ text: content }] + } + if (Array.isArray(content)) { + return content + .filter((c: any) => c.type === 'text' && c.text) + .map((c: any) => ({ text: c.text })) + } + return [{ text: String(content) }] +} + +// Fallback static model list when API is unavailable +function fallbackModels(): ModelInfo[] { + return [ + { id: 'us.amazon.nova-pro-v1:0', name: 'Amazon Nova Pro', provider: 'amazon-bedrock', description: 'Amazon Nova highly capable multimodal model' }, + { id: 'us.amazon.nova-lite-v1:0', name: 'Amazon Nova Lite', provider: 'amazon-bedrock', description: 'Amazon Nova very low cost multimodal model' }, + { id: 'us.amazon.nova-micro-v1:0', name: 'Amazon Nova Micro', provider: 'amazon-bedrock', description: 'Amazon Nova text only model, lowest cost' }, + { id: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', name: 'Claude Sonnet 3.5 v2', provider: 'amazon-bedrock', description: 'Intelligent, fast Claude 3.5 model on Amazon Bedrock' }, + { id: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', name: 'Claude Sonnet 3.7', provider: 'amazon-bedrock', description: 'Hybrid reasoning model on Amazon Bedrock' }, + ] +} + +function createBedrockConverseProvider(config: { + apiKey: string + region: string +}) { + const { apiKey, region } = config + // baseURL is a placeholder; all actual requests go through the custom fetch interceptor below + const baseURL = `https://bedrock-runtime.${region}.amazonaws.com/v1/` + + const bedrockHeaders = () => ({ + 'authorization': `Bearer ${apiKey}`, + 'content-type': 'application/json', + }) + + return { + chat: (model: string) => ({ + apiKey, + baseURL, + model, + fetch: async (_input: RequestInfo | URL, init?: RequestInit) => { + // Parse xsai chat request body (messages array + model) + const body = JSON.parse((init?.body as string) || '{}') as any + const messages: any[] = body.messages || [] + const modelId: string = body.model || model + + // Separate system messages + const systemMessages = messages.filter(m => m.role === 'system') + const chatMessages = messages.filter(m => m.role !== 'system') + + // Convert to Converse messages format + const converseMessages = mergeConsecutiveRoles( + chatMessages.map(m => ({ + role: m.role as 'user' | 'assistant', + content: toConverseContent(m.content), + })), + ) + + // Build system prompt + const system = systemMessages.length > 0 + ? systemMessages.map(m => ({ + text: typeof m.content === 'string' + ? m.content + : (Array.isArray(m.content) ? m.content.map((c: any) => c.text || '').join('') : String(m.content)), + })) + : undefined + + // Build Converse request body + const converseBody: any = { + messages: converseMessages, + inferenceConfig: { + maxTokens: body.max_tokens || 4096, + ...(body.temperature !== undefined && { temperature: body.temperature }), + }, + } + if (system) + converseBody.system = system + + // Use /converse (non-streaming) — bearer-token auth does not support + // the binary event-stream protocol required by /converse-stream. + // We fetch the complete response and then re-emit it as an SSE stream + // so the rest of the xsai pipeline sees a standard streaming response. + const converseUrl = `https://bedrock-runtime.${region}.amazonaws.com/model/${encodeURIComponent(modelId)}/converse` + + const response = await fetch(converseUrl, { + method: 'POST', + headers: bedrockHeaders(), + body: JSON.stringify(converseBody), + }) + + if (!response.ok) { + return response + } + + const data = await response.json() as { + output: { message: { content: Array<{ text?: string }> } } + stopReason?: string + } + + const fullText = (data.output?.message?.content ?? []) + .filter(c => c.text) + .map(c => c.text!) + .join('') + + const stopReason = data.stopReason === 'end_turn' ? 'stop' : (data.stopReason ?? 'stop') + const id = `chatcmpl-bedrock-${Date.now()}` + const encoder = new TextEncoder() + + // Emit the full response as a single SSE chunk (non-streaming Converse API response). + const stream = new ReadableStream({ + start(controller) { + const enqueue = (chunk: object) => + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) + + enqueue({ + id, + object: 'chat.completion.chunk', + choices: [{ delta: { role: 'assistant' }, index: 0, finish_reason: null }], + }) + + enqueue({ + id, + object: 'chat.completion.chunk', + choices: [{ delta: { content: fullText }, index: 0, finish_reason: null }], + }) + + enqueue({ + id, + object: 'chat.completion.chunk', + choices: [{ delta: {}, index: 0, finish_reason: stopReason }], + }) + controller.enqueue(encoder.encode('data: [DONE]\n\n')) + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + }, + }) + }, + }), + } +} + +export const providerAmazonBedrock = defineProvider({ + id: 'amazon-bedrock', + order: 18, + name: 'Amazon Bedrock', + nameLocalize: ({ t }) => t('settings.pages.providers.provider.amazon-bedrock.title'), + description: 'aws.amazon.com/bedrock', + descriptionLocalize: ({ t }) => t('settings.pages.providers.provider.amazon-bedrock.description'), + tasks: ['chat'], + icon: 'i-lobe-icons:aws', + iconColor: 'i-lobe-icons:aws-color', + + createProviderConfig: ({ t }) => amazonBedrockConfigSchema.extend({ + apiKey: amazonBedrockConfigSchema.shape.apiKey.meta({ + labelLocalized: t('settings.pages.providers.provider.amazon-bedrock.config.api-key.label'), + descriptionLocalized: t('settings.pages.providers.provider.amazon-bedrock.config.api-key.description'), + placeholderLocalized: t('settings.pages.providers.provider.amazon-bedrock.config.api-key.placeholder'), + type: 'password', + }), + region: amazonBedrockConfigSchema.shape.region.meta({ + labelLocalized: t('settings.pages.providers.provider.amazon-bedrock.config.region.label'), + descriptionLocalized: t('settings.pages.providers.provider.amazon-bedrock.config.region.description'), + placeholderLocalized: 'us-east-1', + }), + }), + + onboardingFields: ({ t }) => [ + { + key: 'apiKey', + type: 'password' as const, + label: t('settings.pages.providers.provider.amazon-bedrock.config.api-key.label'), + description: t('settings.pages.providers.provider.amazon-bedrock.config.api-key.description'), + placeholder: t('settings.pages.providers.provider.amazon-bedrock.config.api-key.placeholder'), + required: true, + }, + { + key: 'region', + type: 'text' as const, + label: t('settings.pages.providers.provider.amazon-bedrock.config.region.label'), + description: t('settings.pages.providers.provider.amazon-bedrock.config.region.description'), + placeholder: 'us-east-1', + defaultValue: 'us-east-1', + }, + ], + + createProvider(config) { + const region = config.region + const baseURL = `https://bedrock-runtime.${region}.amazonaws.com/v1/` + const chatProvider = createBedrockConverseProvider({ + apiKey: config.apiKey, + region, + }) + return merge( + chatProvider, + createModelProvider({ apiKey: config.apiKey, baseURL }), + ) + }, + + extraMethods: { + listModels: async (config, _provider) => { + const { apiKey, region } = config + + const base = `https://bedrock.${region}.amazonaws.com` + const headers = { + authorization: `Bearer ${apiKey}`, + } + + try { + // 1. Fetch foundation models for each target provider in parallel + const targetProviders = ['Amazon', 'Anthropic', 'Moonshot', 'Minimax', 'DeepSeek'] + const foundationResults = await Promise.all( + targetProviders.map(async (provider) => { + const url = `${base}/foundation-models?byInferenceType=ON_DEMAND&byOutputModality=TEXT&byProvider=${encodeURIComponent(provider)}` + const res = await fetch(url, { method: 'GET', headers }) + if (!res.ok) + return { modelSummaries: [] as any[] } + return res.json() as Promise<{ modelSummaries: any[] }> + }), + ) + const allFoundationModels = foundationResults.flatMap(r => r.modelSummaries || []) + + // 2. Fetch system-defined inference profiles (cross-region, global/us prefixed) + const profilesRes = await fetch( + `${base}/inference-profiles?type=SYSTEM_DEFINED&maxResults=1000`, + { method: 'GET', headers }, + ) + const profilesData = profilesRes.ok + ? await profilesRes.json() as { inferenceProfileSummaries: any[] } + : { inferenceProfileSummaries: [] } + + // 3. Build lookup map: baseModelId → { global?: profileId, us?: profileId } + const profileMap = new Map() + for (const p of profilesData.inferenceProfileSummaries || []) { + const id: string = p.inferenceProfileId + if (!id) + continue + const dotIdx = id.indexOf('.') + if (dotIdx === -1) + continue + const prefix = id.slice(0, dotIdx) // 'us' or 'global' + const baseId = id.slice(dotIdx + 1) // 'amazon.nova-pro-v1:0' + + if (!profileMap.has(baseId)) + profileMap.set(baseId, {}) + const entry = profileMap.get(baseId)! + if (prefix === 'global') + entry.global = id + else if (prefix === 'us') + entry.us = id + } + + // 4. For each foundation model, pick best profile ID: + // global. > us. > original modelId + const foundationModelIds = new Set(allFoundationModels.map(m => m.modelId)) + const results: ModelInfo[] = allFoundationModels.map((m) => { + const entry = profileMap.get(m.modelId) + const bestId = entry?.global ?? entry?.us ?? m.modelId + + return { + id: bestId, + name: m.modelName, + provider: 'amazon-bedrock', + description: `${m.providerName} · ${m.modelName}`, + } satisfies ModelInfo + }) + + // 5. Also include inference profiles for models NOT in the foundation list + // (e.g., newer models like Claude Sonnet 4.6, Nova 2 Lite only in profiles) + const targetPrefixes = ['amazon.', 'anthropic.', 'moonshot.', 'minimax.', 'deepseek.'] + const seenBaseIds = new Set(foundationModelIds) + + for (const p of profilesData.inferenceProfileSummaries || []) { + const id: string = p.inferenceProfileId + if (!id) + continue + const dotIdx = id.indexOf('.') + if (dotIdx === -1) + continue + const prefix = id.slice(0, dotIdx) // 'us' or 'global' + const baseId = id.slice(dotIdx + 1) // e.g. 'anthropic.claude-sonnet-4-6:0' + + if (prefix !== 'global' && prefix !== 'us') + continue + if (seenBaseIds.has(baseId)) + continue + if (!targetPrefixes.some(pfx => baseId.startsWith(pfx))) + continue + + const existing = profileMap.get(baseId) + if (prefix === 'us' && existing?.global) + continue + + seenBaseIds.add(baseId) + + const name = p.inferenceProfileName || baseId + const providerName = baseId.split('.')[0] + results.push({ + id, + name, + provider: 'amazon-bedrock', + description: `${providerName.charAt(0).toUpperCase() + providerName.slice(1)} · ${name}`, + } satisfies ModelInfo) + } + + return results.length > 0 ? results : fallbackModels() + } + catch { + return fallbackModels() + } + }, + }, + + validationRequiredWhen(config) { + return !!config.apiKey?.trim() + }, + + validators: { + validateConfig: [], + validateProvider: [ + () => ({ + id: 'amazon-bedrock:check-credentials', + name: 'Verify Amazon Bedrock API key', + validator: async (config: Record) => { + const region = config.region || 'us-east-1' + const apiKey = config.apiKey + const errors: Array<{ error: unknown }> = [] + try { + const res = await fetch( + `https://bedrock.${region}.amazonaws.com/foundation-models?byInferenceType=ON_DEMAND&byOutputModality=TEXT&byProvider=Amazon&maxResults=1`, + { + method: 'GET', + headers: { + authorization: `Bearer ${apiKey}`, + }, + }, + ) + if (res.status === 403 || res.status === 401) { + errors.push({ error: new Error('Invalid Amazon Bedrock API key or insufficient permissions.') }) + } + } + catch { + errors.push({ error: new Error('Failed to connect to Amazon Bedrock. Check your region and network.') }) + } + return { + errors, + reason: errors.length > 0 ? (errors[0].error as Error).message : '', + reasonKey: '', + valid: errors.length === 0, + } + }, + }), + ], + }, +}) diff --git a/packages/stage-ui/src/libs/providers/providers/index.ts b/packages/stage-ui/src/libs/providers/providers/index.ts index c9b1abdb1..e40569714 100644 --- a/packages/stage-ui/src/libs/providers/providers/index.ts +++ b/packages/stage-ui/src/libs/providers/providers/index.ts @@ -1,3 +1,4 @@ +import './amazon-bedrock' import './openai' import './aihubmix' import './lm-studio' diff --git a/packages/stage-ui/src/libs/providers/types.ts b/packages/stage-ui/src/libs/providers/types.ts index ee93c6b58..6f73da625 100644 --- a/packages/stage-ui/src/libs/providers/types.ts +++ b/packages/stage-ui/src/libs/providers/types.ts @@ -35,6 +35,16 @@ export function isModelProvider(providerInstance: ProviderInstance): providerIns return false } +export interface ProviderOnboardingField { + key: string + type: 'text' | 'password' + label: string + description?: string + placeholder?: string + required?: boolean + defaultValue?: string +} + export interface ProviderExtraMethods { listModels?: (config: TConfig, provider: ProviderInstance) => Promise listVoices?: (config: TConfig, provider: ProviderInstance) => Promise @@ -165,6 +175,7 @@ export interface ProviderDefinition { requiresCredentials?: boolean createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType + onboardingFields?: (ctx: { t: ComposerTranslation }) => ProviderOnboardingField[] createProvider: (config: TConfig) => ProviderInstance extraMethods?: ProviderExtraMethods validationRequiredWhen?: (config: TConfig) => boolean diff --git a/packages/stage-ui/src/stores/providers.ts b/packages/stage-ui/src/stores/providers.ts index 83644cb11..8bc78815a 100644 --- a/packages/stage-ui/src/stores/providers.ts +++ b/packages/stage-ui/src/stores/providers.ts @@ -18,6 +18,7 @@ import type { VoiceProviderWithExtraOptions, } from 'unspeech' +import type { ProviderOnboardingField } from '../libs/providers/types' import type { AliyunRealtimeSpeechExtraOptions } from './providers/aliyun/stream-transcription' import { isStageTamagotchi, isUrl } from '@proj-airi/stage-shared' @@ -114,6 +115,7 @@ export interface ProviderMetadata { */ iconImage?: string defaultOptions?: () => Record + onboardingFields?: ProviderOnboardingField[] createProvider: ( config: Record, ) => diff --git a/packages/stage-ui/src/stores/providers/converters.ts b/packages/stage-ui/src/stores/providers/converters.ts index 3ef4d067b..fb0fd18d6 100644 --- a/packages/stage-ui/src/stores/providers/converters.ts +++ b/packages/stage-ui/src/stores/providers/converters.ts @@ -113,6 +113,7 @@ export function convertProviderDefinitionToMetadata( iconImage: definition.iconImage, isAvailableBy: definition.isAvailableBy, requiresCredentials: definition.requiresCredentials, + onboardingFields: definition.onboardingFields?.({ t }), defaultOptions: () => { if (Object.keys(schemaDefaults).length > 0) { return { ...schemaDefaults }