feat(providers): add Amazon Bedrock provider (#1256)

## 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
<key>`) — 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) <noreply@anthropic.com>

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
chaosreload 2026-04-23 01:37:29 +08:00 committed by GitHub
parent 762fd017b0
commit a189536489
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 724 additions and 40 deletions

View file

@ -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

View file

@ -0,0 +1,130 @@
<script setup lang="ts">
import type { RemovableRef } from '@vueuse/core'
import {
Alert,
ProviderAccountIdInput,
ProviderApiKeyInput,
ProviderBasicSettings,
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useProviderValidation } from '@proj-airi/stage-ui/composables/use-provider-validation'
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const providerId = 'amazon-bedrock'
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
const { providers } = storeToRefs(providersStore) as { providers: RemovableRef<Record<string, any>> }
const { activeProvider } = storeToRefs(consciousnessStore)
const apiKey = computed({
get: () => providers.value[providerId]?.apiKey || '',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].apiKey = value
},
})
const region = computed({
get: () => providers.value[providerId]?.region || 'us-east-1',
set: (value) => {
if (!providers.value[providerId])
providers.value[providerId] = {}
providers.value[providerId].region = value
},
})
const {
t,
router,
providerMetadata,
isValidating,
isValid,
validationMessage,
handleResetSettings,
forceValid,
} = useProviderValidation(providerId)
function goToModelSelection() {
activeProvider.value = providerId
router.push('/settings/modules/consciousness')
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<ProviderBasicSettings
:title="t('settings.pages.providers.common.section.basic.title')"
:description="t('settings.pages.providers.common.section.basic.description')"
:on-reset="handleResetSettings"
>
<ProviderApiKeyInput
v-model="apiKey"
:label="t('settings.pages.providers.provider.amazon-bedrock.config.api-key.label')"
:provider-name="providerMetadata?.localizedName"
: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
/>
<ProviderAccountIdInput
v-model="region"
: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"
/>
</ProviderBasicSettings>
<!-- Validation Status -->
<Alert v-if="!isValid && isValidating === 0 && validationMessage" type="error">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationFailed') }}</span>
<button
type="button"
class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs text-red-600 font-medium transition-colors dark:bg-red-800/30 hover:bg-red-200 dark:text-red-300 dark:hover:bg-red-700/40"
@click="forceValid"
>
{{ t('settings.pages.providers.common.continueAnyway') }}
</button>
</div>
</template>
<template v-if="validationMessage" #content>
<div class="whitespace-pre-wrap break-all">
{{ validationMessage }}
</div>
</template>
</Alert>
<Alert v-if="isValid && isValidating === 0" type="success">
<template #title>
<div class="w-full flex items-center justify-between">
<span>{{ t('settings.dialogs.onboarding.validationSuccess') }}</span>
<button
type="button"
:class="['ml-2 rounded px-2 py-0.5 text-xs font-medium transition-colors', 'bg-green-100 text-green-600 hover:bg-green-200', 'dark:bg-green-800/30 dark:text-green-300 dark:hover:bg-green-700/40']"
@click="goToModelSelection"
>
{{ t('settings.pages.providers.common.goToModelSelection') }}
</button>
</div>
</template>
</Alert>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>

View file

@ -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],

View file

@ -26,10 +26,13 @@ const apiKey = ref('')
const baseUrl = ref('')
const accountId = ref('')
const enableChatCheck = ref(true)
const customFieldValues = ref<Record<string, string>>({})
const validation = ref<'unchecked' | 'pending' | 'succeed' | 'failed'>('unchecked')
const validationError = ref<any>()
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<string, string> = {}
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<string, unknown> = {}
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()
</div>
</Callout>
<div class="space-y-4">
<!-- API Key Input -->
<div v-if="needsApiKey">
<!-- Custom onboarding fields (provider-specific, e.g. Amazon Bedrock SigV4) -->
<template v-if="hasOnboardingFields">
<FieldInput
v-model="apiKey"
:placeholder="getApiKeyPlaceholder(props.selectedProvider.id)"
type="password"
label="API Key"
description="Enter your API key for the selected provider."
required
v-for="field in props.selectedProvider.onboardingFields"
:key="field.key"
v-model="customFieldValues[field.key]"
:type="field.type"
:label="field.label"
:description="field.description"
:placeholder="field.placeholder || ''"
:required="field.required"
/>
</div>
</template>
<!-- Base URL Input -->
<div v-if="needsBaseUrl">
<FieldInput
v-model="baseUrl"
:placeholder="getBaseUrlPlaceholder(props.selectedProvider.id)"
type="text"
label="Base URL"
description="Enter the base URL for the provider's API."
/>
</div>
<!-- Standard fields for other providers -->
<template v-else>
<!-- API Key Input -->
<div v-if="needsApiKey">
<FieldInput
v-model="apiKey"
:placeholder="getApiKeyPlaceholder(props.selectedProvider.id)"
type="password"
label="API Key"
description="Enter your API key for the selected provider."
required
/>
</div>
<!-- Account ID for Cloudflare -->
<div v-if="props.selectedProvider.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</div>
<!-- Base URL Input -->
<div v-if="needsBaseUrl">
<FieldInput
v-model="baseUrl"
:placeholder="getBaseUrlPlaceholder(props.selectedProvider.id)"
type="text"
label="Base URL"
description="Enter the base URL for the provider's API."
/>
</div>
<!-- Account ID for Cloudflare -->
<div v-if="props.selectedProvider.id === 'cloudflare-workers-ai'">
<ProviderAccountIdInput v-model="accountId" />
</div>
</template>
</div>
<!-- Chat Ping Check Option -->

View file

@ -7,6 +7,7 @@ export interface ProviderConfigData {
apiKey: string
baseUrl: string
accountId: string
customFields?: Record<string, string>
}
export type OnboardingStepNextHandler = (configData?: ProviderConfigData) => Promise<void> | void

View file

@ -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<string, unknown>
// 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<string, unknown>
if (AUTH_FIELDS.some((field) => {
const v = config[field]
return v !== null && v !== undefined && String(v).trim() !== ''
})) {
validateConfiguration()
}
})

View file

@ -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)
})
})

View file

@ -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<typeof amazonBedrockConfigSchema>
// 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<AmazonBedrockConfig>({
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<string, { global?: string, us?: string }>()
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<string, any>) => {
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,
}
},
}),
],
},
})

View file

@ -1,3 +1,4 @@
import './amazon-bedrock'
import './openai'
import './aihubmix'
import './lm-studio'

View file

@ -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<TConfig> {
listModels?: (config: TConfig, provider: ProviderInstance) => Promise<ModelInfo[]>
listVoices?: (config: TConfig, provider: ProviderInstance) => Promise<VoiceInfo[]>
@ -165,6 +175,7 @@ export interface ProviderDefinition<TConfig extends any = any> {
requiresCredentials?: boolean
createProviderConfig: (contextOptions: { t: ComposerTranslation }) => $ZodType<TConfig>
onboardingFields?: (ctx: { t: ComposerTranslation }) => ProviderOnboardingField[]
createProvider: (config: TConfig) => ProviderInstance
extraMethods?: ProviderExtraMethods<TConfig>
validationRequiredWhen?: (config: TConfig) => boolean

View file

@ -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<string, unknown>
onboardingFields?: ProviderOnboardingField[]
createProvider: (
config: Record<string, unknown>,
) =>

View file

@ -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 }