mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-10 00:08:33 +00:00
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:
parent
762fd017b0
commit
a189536489
12 changed files with 724 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import './amazon-bedrock'
|
||||
import './openai'
|
||||
import './aihubmix'
|
||||
import './lm-studio'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) =>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue