From a189536489bdd81c8c74098bce69a97a596428ea Mon Sep 17 00:00:00 2001 From: chaosreload Date: Thu, 23 Apr 2026 01:37:29 +0800 Subject: [PATCH] feat(providers): add Amazon Bedrock provider (#1256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add support for Amazon Bedrock as an LLM provider using the native **Converse API** with API Key authentication. ## Motivation AWS Bedrock provides access to frontier models (Claude, Amazon Nova, Llama, DeepSeek etc.) with enterprise-grade security and compliance. Many teams running AI workloads on AWS infrastructure want to use Bedrock directly, keeping all traffic within the AWS network. ## Implementation - Uses Amazon Bedrock's native **Converse API** (`bedrock-runtime.{region}.amazonaws.com/model/{modelId}/converse`) - Authentication via **Amazon Bedrock API Key** (`Authorization: Bearer `) — no SigV4 signing, no extra dependencies - Config: `apiKey` (Bedrock API key) + `region` (AWS region, default: `us-east-1`) - All Bedrock-specific config is declared via the generic `onboardingFields` mechanism on the provider definition — no `isAmazonBedrock` branching, no `ProviderConfigData` type changes - Dynamic model listing via `ListFoundationModels` + `ListInferenceProfiles` APIs, with fallback to a static list - Streaming: calls `/converse` (standard JSON response), then re-emits the full text as `text/event-stream` character-by-character — bearer-token auth does not support the binary AWS Event Stream protocol required by `/converse-stream` ## Supported Models **Anthropic Claude (via Bedrock)** - Claude Opus/Sonnet/Haiku 4.x - Claude Sonnet 3.7 (hybrid reasoning) - Claude Sonnet 3.5 v2 **Amazon Nova** - Nova Pro (multimodal) - Nova Lite (multimodal, low cost) - Nova Micro (text-only, lowest cost) **Meta / Others** - Llama 3.3 70B Instruct - DeepSeek, Moonshot, Minimax models available via inference profiles ## Authentication Generate a Bedrock API key in: **AWS Console → Amazon Bedrock → API Keys** > Note: Long-term API keys have a 30-day expiry. AWS recommends them for development/exploration use. ## Notes ⚠️ **CORS in browser**: Like other providers (Anthropic, etc.), direct browser calls to Bedrock are subject to CORS restrictions. Works out of the box in Tauri desktop app and Node.js server environments. For browser use, a CORS proxy is needed. ## Testing ``` pnpm vitest run packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.test.ts ``` --------- Co-authored-by-agent: Claude Opus 4.6 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- packages/i18n/src/locales/en/settings.yaml | 11 + .../providers/chat/amazon-bedrock.vue | 130 ++++++ .../dialogs/onboarding/onboarding.vue | 8 +- .../step-provider-configuration.vue | 115 +++-- .../scenarios/dialogs/onboarding/types.ts | 1 + .../composables/use-provider-validation.ts | 21 +- .../providers/amazon-bedrock/index.test.ts | 66 +++ .../providers/amazon-bedrock/index.ts | 397 ++++++++++++++++++ .../src/libs/providers/providers/index.ts | 1 + packages/stage-ui/src/libs/providers/types.ts | 11 + packages/stage-ui/src/stores/providers.ts | 2 + .../src/stores/providers/converters.ts | 1 + 12 files changed, 724 insertions(+), 40 deletions(-) create mode 100644 packages/stage-pages/src/pages/settings/providers/chat/amazon-bedrock.vue create mode 100644 packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.test.ts create mode 100644 packages/stage-ui/src/libs/providers/providers/amazon-bedrock/index.ts 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 @@ + + + + + +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 }