feat: support authType on AI providers for IAM role authentication (#19016)

## Summary
- Adds an `authType` field (`'api_key' | 'access_key' | 'iam_role'`) to
AI provider config
- Providers like Amazon Bedrock that authenticate via IAM role (instance
profile) can now be registered without explicit API keys or access keys
- Backend: `isProviderConfigured()` checks `apiKey || accessKeyId ||
authType`
- Frontend admin panel: shows green "Configured" badge and "IAM role"
description for providers with `authType: "iam_role"`
- Provider detail page shows "IAM role (instance profile)" in the
credentials row

## Companion PR
- twentyhq/twenty-infra#528 — patches `authType: "iam_role"` into
dev/staging Bedrock catalogs

## Changed files
- **Backend**: new `AiProviderAuthType` type, `isProviderConfigured`
util, updated registry + resolver
- **Frontend**: new `AiProviderAuthType` type, updated provider list
card + detail page

## Test plan
- [ ] Deploy with a Bedrock catalog that includes `"authType":
"iam_role"` — verify Bedrock shows "Configured" in admin AI panel
- [ ] Verify OpenAI/Anthropic with `apiKey` still show "Configured"
- [ ] Verify a provider with no credentials and no `authType` still
shows "No credentials"

Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-26 16:43:09 +01:00
committed by GitHub
parent 1c297e5ace
commit 7a341c6475
9 changed files with 26 additions and 5 deletions
@@ -33,7 +33,9 @@ const getProviderDescription = (provider: AiProviderItem): string => {
parts.push(provider.baseUrl);
}
if (provider.apiKey) {
if (provider.authType === 'role') {
parts.push(t`IAM role`);
} else if (provider.apiKey) {
parts.push(t`API key configured`);
} else if (provider.hasAccessKey) {
parts.push(t`IAM credentials`);
@@ -42,6 +44,9 @@ const getProviderDescription = (provider: AiProviderItem): string => {
return parts.join(' · ');
};
const isProviderConfigured = (provider: AiProviderItem): boolean =>
!!(provider.authType || provider.apiKey || provider.hasAccessKey);
export const SettingsAdminAiProviderListCard = ({
providers,
showAddButton = true,
@@ -70,7 +75,7 @@ export const SettingsAdminAiProviderListCard = ({
getItemLabel={(provider) => provider.label ?? provider.id}
getItemDescription={getProviderDescription}
RowRightComponent={({ item: provider }) =>
provider.apiKey || provider.hasAccessKey ? (
isProviderConfigured(provider) ? (
<Status color="green" text={t`Configured`} weight="medium" />
) : (
<Status color="orange" text={t`No credentials`} weight="medium" />
@@ -0,0 +1 @@
export type AiProviderAuthType = 'key' | 'credentials' | 'role';
@@ -1,5 +1,6 @@
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
import { type AiProviderAuthType } from '@/settings/admin-panel/ai/types/AiProviderAuthType';
import { type AiProviderSource } from '@/settings/admin-panel/ai/types/AiProviderSource';
// AiProviderItem = RawAiProviderConfig (from the backend's Record<string,
@@ -11,6 +12,7 @@ export type AiProviderItem = {
// Optional provider display/catalog name from config (not a model name; models use `models[].name` on the backend).
name?: string;
label?: string;
authType?: AiProviderAuthType;
source?: AiProviderSource;
baseUrl?: string;
region?: string;
@@ -257,6 +257,12 @@ export const SettingsAdminAiProviderDetail = () => {
label: t`Credentials`,
value: t`IAM credentials configured`,
});
} else if (provider.authType === 'role') {
items.push({
Icon: IconKey,
label: t`Credentials`,
value: t`IAM role (instance profile)`,
});
}
if (isCustomProvider && provider.dataResidency) {
@@ -357,6 +357,7 @@ export class AdminPanelResolver {
npm: config.npm,
label: config.label ?? key,
source: isCatalog ? 'catalog' : 'custom',
...(config.authType && { authType: config.authType }),
...(config.name && { name: config.name }),
...(config.baseUrl && { baseUrl: config.baseUrl }),
...(config.region && { region: config.region }),
@@ -26,6 +26,7 @@ import { isAutoSelectModelId } from 'twenty-shared/utils';
import { DEFAULT_MAX_OUTPUT_TOKENS } from 'src/engine/metadata-modules/ai/ai-models/types/default-max-output-tokens.const';
import { buildCompositeModelId } from 'src/engine/metadata-modules/ai/ai-models/utils/composite-model-id.util';
import { inferModelFamily } from 'src/engine/metadata-modules/ai/ai-models/utils/infer-model-family.util';
import { isProviderConfigured } from 'src/engine/metadata-modules/ai/ai-models/utils/is-provider-configured.util';
import {
isModelAllowedByWorkspace,
type WorkspaceModelAvailabilitySettings,
@@ -84,9 +85,7 @@ export class AiModelRegistryService {
continue;
}
const isConfigured = !!(config.apiKey || config.accessKeyId);
const sdkInstance = isConfigured
const sdkInstance = isProviderConfigured(config)
? this.sdkProviderFactory.createProvider(providerKey, config)
: undefined;
@@ -0,0 +1 @@
export type AiProviderAuthType = 'key' | 'credentials' | 'role';
@@ -1,5 +1,6 @@
import { type AiSdkPackage, type DataResidency } from 'twenty-shared/ai';
import { type AiProviderAuthType } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-auth-type.type';
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
export type AiProviderConfig = {
@@ -7,6 +8,7 @@ export type AiProviderConfig = {
// Optional provider display/catalog name (e.g. models.dev label). Not a model name; per-model names live on `models[].name`.
name?: string;
label?: string;
authType?: AiProviderAuthType;
apiKey?: string;
baseUrl?: string;
region?: string;
@@ -0,0 +1,4 @@
import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-config.type';
export const isProviderConfigured = (config: AiProviderConfig): boolean =>
!!(config.apiKey || config.accessKeyId || config.authType);