OpenAI (Other) Connector PKI implementation (#219984)

This commit is contained in:
Antonio Piazza 2025-06-06 22:43:57 +02:00 committed by GitHub
parent 9f7cffc4f3
commit 8fae18a9b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1429 additions and 100 deletions

View file

@ -9,7 +9,7 @@ applies_to:
# OpenAI connector and action [openai-action-type]
The OpenAI connector uses [axios](https://github.com/axios/axios) to send a POST request to an OpenAI provider, either OpenAI or Azure OpenAI.
The OpenAI connector uses [axios](https://github.com/axios/axios) to send a POST request to an OpenAI provider, either OpenAI, Azure OpenAI, or Other (OpenAI-compatible service).
## Create connectors in {{kib}} [define-gen-ai-ui]
@ -30,20 +30,36 @@ To validate that your connectivity problems are caused by using a proxy, you can
OpenAI connectors have the following configuration properties:
Name
: The name of the connector.
| Field | Required for | Description |
|------------------|---------------------|---------------------------------------------------------------------------------------------|
| Name | All | The name of the connector. |
| OpenAI provider | All | The API provider: `OpenAI`, `Azure OpenAI`, or `Other` (OpenAI-compatible service). |
| URL | All | The API endpoint URL for the selected provider. |
| Default model | OpenAI/Other | The default model for requests. **Required** for `Other`, optional for `OpenAI`. |
| Headers | Optional | Custom HTTP headers to include in requests. |
| Verification mode| Other (PKI only) | SSL verification mode for PKI authentication. Default: `full`. |
| API key | OpenAI/Azure/Other | The API key for authentication. **Required** for `OpenAI` and `Azure OpenAI`. For `Other`, required unless PKI authentication is used. |
| PKI fields | Other (PKI only) | See below. Only available for `Other` provider. |
OpenAI provider
: The OpenAI API provider, either OpenAI or Azure OpenAI.
#### PKI Authentication (Other provider only)
URL
: The OpenAI request URL.
When using the `Other` provider, you can use PKI (certificate-based) authentication. With PKI, you can also optionally include an API key if your OpenAI-compatible service supports or requires one. The following fields are supported for PKI:
Default model
: (optional) The default model to use for requests. This option is available only when the provider is `OpenAI`.
- **Certificate data** (`certificateData`): PEM-encoded certificate content, base64-encoded. (**Required for PKI**)
- **Private key data** (`privateKeyData`): PEM-encoded private key content, base64-encoded. (**Required for PKI**)
- **CA data** (`caData`, optional): PEM-encoded CA certificate content, base64-encoded.
- **API key** (`apiKey`, optional): The API key for authentication, if required by your service.
- **Verification mode** (`verificationMode`): SSL verification mode for PKI authentication. Options:
- `full` (default): Verify server's certificate and hostname
- `certificate`: Verify only the server's certificate
- `none`: Skip verification (not recommended for production)
API key
: The OpenAI or Azure OpenAI API key for authentication.
**Note:**
- All PKI fields must be PEM-encoded and base64-encoded when sent via API.
- If any PKI field is provided, both `certificateData` and `privateKeyData` are required and must be valid PEM.
- With PKI, you may also include an API key if your provider supports or requires it.
- If PKI is not used, `apiKey` is required for the `Other` provider.
- For `OpenAI` and `Azure OpenAI`, only `apiKey` is supported for authentication.
## Test connectors [gen-ai-action-configuration]

View file

@ -383,6 +383,7 @@ paths:
- $ref: '#/components/schemas/jira_config'
- $ref: '#/components/schemas/genai_azure_config'
- $ref: '#/components/schemas/genai_openai_config'
- $ref: '#/components/schemas/genai_openai_other_config'
- $ref: '#/components/schemas/opsgenie_config'
- $ref: '#/components/schemas/pagerduty_config'
- $ref: '#/components/schemas/sentinelone_config'
@ -74170,12 +74171,30 @@ components:
The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts.
genai_secrets:
title: Connector secrets properties for an OpenAI connector
description: Defines secrets for connectors when type is `.gen-ai`.
description: |
Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content.
type: object
properties:
apiKey:
type: string
description: The OpenAI API key.
description: |
The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one.
certificateData:
type: string
description: |
Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
privateKeyData:
type: string
description: |
Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
caData:
type: string
description: |
Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional.
minLength: 1
required: []
opsgenie_secrets:
title: Connector secrets properties for an Opsgenie connector
required:
@ -74345,6 +74364,52 @@ components:
description: |
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
type: string
genai_openai_other_config:
title: Connector request properties for an OpenAI connector with Other provider
description: |
Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication.
type: object
required:
- apiProvider
- apiUrl
- defaultModel
properties:
apiProvider:
type: string
description: The OpenAI API provider.
enum:
- Other
apiUrl:
type: string
description: The OpenAI-compatible API endpoint.
defaultModel:
type: string
description: The default model to use for requests.
certificateData:
type: string
description: PEM-encoded certificate content.
minLength: 1
privateKeyData:
type: string
description: PEM-encoded private key content.
minLength: 1
caData:
type: string
description: PEM-encoded CA certificate content.
minLength: 1
verificationMode:
type: string
description: SSL verification mode for PKI authentication.
enum:
- full
- certificate
- none
default: full
headers:
type: object
description: Custom headers to include in requests.
additionalProperties:
type: string
defender_secrets:
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
required:

View file

@ -435,6 +435,7 @@ paths:
- $ref: '#/components/schemas/jira_config'
- $ref: '#/components/schemas/genai_azure_config'
- $ref: '#/components/schemas/genai_openai_config'
- $ref: '#/components/schemas/genai_openai_other_config'
- $ref: '#/components/schemas/opsgenie_config'
- $ref: '#/components/schemas/pagerduty_config'
- $ref: '#/components/schemas/sentinelone_config'
@ -84083,12 +84084,30 @@ components:
The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts.
genai_secrets:
title: Connector secrets properties for an OpenAI connector
description: Defines secrets for connectors when type is `.gen-ai`.
description: |
Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content.
type: object
properties:
apiKey:
type: string
description: The OpenAI API key.
description: |
The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one.
certificateData:
type: string
description: |
Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
privateKeyData:
type: string
description: |
Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
caData:
type: string
description: |
Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional.
minLength: 1
required: []
opsgenie_secrets:
title: Connector secrets properties for an Opsgenie connector
required:
@ -84258,6 +84277,52 @@ components:
description: |
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
type: string
genai_openai_other_config:
title: Connector request properties for an OpenAI connector with Other provider
description: |
Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication.
type: object
required:
- apiProvider
- apiUrl
- defaultModel
properties:
apiProvider:
type: string
description: The OpenAI API provider.
enum:
- Other
apiUrl:
type: string
description: The OpenAI-compatible API endpoint.
defaultModel:
type: string
description: The default model to use for requests.
certificateData:
type: string
description: PEM-encoded certificate content.
minLength: 1
privateKeyData:
type: string
description: PEM-encoded private key content.
minLength: 1
caData:
type: string
description: PEM-encoded CA certificate content.
minLength: 1
verificationMode:
type: string
description: SSL verification mode for PKI authentication.
enum:
- full
- certificate
- none
default: full
headers:
type: object
description: Custom headers to include in requests.
additionalProperties:
type: string
defender_secrets:
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
required:

View file

@ -164,6 +164,8 @@ actions:
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_azure_config.yaml'
# OpenAI (.gen-ai)
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_config.yaml'
# Other OpenAI (.gen-ai)
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_other_config.yaml'
# Opsgenie (.opsgenie)
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/opsgenie_config.yaml'
# PagerDuty (.pagerduty)

View file

@ -0,0 +1,41 @@
title: Connector request properties for an OpenAI connector with Other provider
description: >
Defines properties for connectors when type is `.gen-ai` and the API provider is `Other` (OpenAI-compatible service), including optional PKI authentication.
type: object
required:
- apiProvider
- apiUrl
- defaultModel
properties:
apiProvider:
type: string
description: The OpenAI API provider.
enum: ['Other']
apiUrl:
type: string
description: The OpenAI-compatible API endpoint.
defaultModel:
type: string
description: The default model to use for requests.
certificateData:
type: string
description: PEM-encoded certificate content.
minLength: 1
privateKeyData:
type: string
description: PEM-encoded private key content.
minLength: 1
caData:
type: string
description: PEM-encoded CA certificate content.
minLength: 1
verificationMode:
type: string
description: SSL verification mode for PKI authentication.
enum: ['full', 'certificate', 'none']
default: 'full'
headers:
type: object
description: Custom headers to include in requests.
additionalProperties:
type: string

View file

@ -1,7 +1,26 @@
title: Connector secrets properties for an OpenAI connector
description: Defines secrets for connectors when type is `.gen-ai`.
# Defines secrets for connectors when type is `.gen-ai`, including support for PKI authentication for 'Other' providers.
description: |
Defines secrets for connectors when type is `.gen-ai`. Supports both API key authentication (OpenAI, Azure OpenAI, and `Other`) and PKI authentication (`Other` provider only). PKI fields must be base64-encoded PEM content.
type: object
properties:
apiKey:
type: string
description: The OpenAI API key.
apiKey:
type: string
description: |
The API key for authentication. For OpenAI and Azure OpenAI providers, it is required. For the `Other` provider, it is required if you do not use PKI authentication. With PKI, you can also optionally include an API key if the OpenAI-compatible service supports or requires one.
certificateData:
type: string
description: |
Base64-encoded PEM certificate content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
privateKeyData:
type: string
description: |
Base64-encoded PEM private key content for PKI authentication (Other provider only). Required for PKI.
minLength: 1
caData:
type: string
description: |
Base64-encoded PEM CA certificate content for PKI authentication (Other provider only). Optional.
minLength: 1
required: []

View file

@ -23,6 +23,7 @@ export type {
ActionType,
InMemoryConnector,
ActionsApiRequestHandlerContext,
SSLSettings,
} from './types';
export type { ConnectorWithExtraFindData as FindActionResult } from './application/connector/types';

View file

@ -68,7 +68,8 @@ export function getCustomAgents(
// This is where the global rejectUnauthorized is overridden by a custom host
const customHostNodeSSLOptions = getNodeSSLOptions(
logger,
sslSettingsFromConfig.verificationMode
sslSettingsFromConfig.verificationMode,
sslOverrides
);
if (customHostNodeSSLOptions.rejectUnauthorized !== undefined) {
agentOptions.rejectUnauthorized = customHostNodeSSLOptions.rejectUnauthorized;
@ -116,7 +117,8 @@ export function getCustomAgents(
const proxyNodeSSLOptions = getNodeSSLOptions(
logger,
proxySettings.proxySSLSettings.verificationMode
proxySettings.proxySSLSettings.verificationMode,
sslOverrides
);
// At this point, we are going to use a proxy, so we need new agents.
// We will though, copy over the calculated ssl options from above, into

View file

@ -16,6 +16,7 @@ import type {
ActionTypeParams,
RenderParameterTemplates,
Services,
SSLSettings,
ValidatorType as ValidationSchema,
} from '../types';
import type { SubFeature } from '../../common';
@ -41,6 +42,7 @@ export type SubActionRequestParams<R> = {
url: string;
responseSchema: Type<R>;
method?: Method;
sslOverrides?: SSLSettings;
} & AxiosRequestConfig;
export type IService<Config, Secrets> = new (

View file

@ -397,4 +397,101 @@ describe('ConnectorFields renders', () => {
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
});
describe('PKI Configuration', () => {
it('toggles pki as expected', async () => {
const testFormData = {
...otherOpenAiConnector,
__internal__: {
hasHeaders: false,
hasPKI: false,
},
};
render(
<ConnectorFormTestProvider connector={testFormData}>
<ConnectorFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
const pkiToggle = await screen.findByTestId('openAIViewPKISwitch');
expect(screen.queryByTestId('openAISSLCRTInput')).not.toBeInTheDocument();
expect(screen.queryByTestId('openAISSLKEYInput')).not.toBeInTheDocument();
expect(screen.queryByTestId('verificationModeSelect')).not.toBeInTheDocument();
expect(pkiToggle).toBeInTheDocument();
await userEvent.click(pkiToggle);
expect(screen.getByTestId('openAISSLCRTInput')).toBeInTheDocument();
expect(screen.getByTestId('openAISSLKEYInput')).toBeInTheDocument();
expect(screen.getByTestId('verificationModeSelect')).toBeInTheDocument();
});
it('succeeds without pki', async () => {
const testFormData = {
...otherOpenAiConnector,
__internal__: {
hasHeaders: false,
hasPKI: false,
},
};
const onSubmit = jest.fn();
render(
<ConnectorFormTestProvider connector={testFormData} onSubmit={onSubmit}>
<ConnectorFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
await userEvent.click(await screen.findByTestId('form-test-provide-submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
data: {
...testFormData,
},
isValid: true,
});
});
});
it('succeeds with pki', async () => {
const testFormData = {
...otherOpenAiConnector,
__internal__: {
hasHeaders: false,
hasPKI: false,
},
};
const onSubmit = jest.fn();
render(
<ConnectorFormTestProvider connector={testFormData} onSubmit={onSubmit}>
<ConnectorFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
const pkiToggle = await screen.findByTestId('openAIViewPKISwitch');
await userEvent.click(pkiToggle);
const certFile = new File(['hello'], 'cert.crt', { type: 'text/plain' });
const keyFile = new File(['world'], 'key.key', { type: 'text/plain' });
await userEvent.upload(screen.getByTestId('openAISSLCRTInput'), certFile);
await userEvent.upload(screen.getByTestId('openAISSLKEYInput'), keyFile);
await userEvent.click(await screen.findByTestId('form-test-provide-submit'));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
data: {
...testFormData,
config: {
...testFormData.config,
verificationMode: 'full',
},
secrets: {
apiKey: 'thats-a-nice-looking-key',
certificateData: Buffer.from('hello').toString('base64'),
privateKeyData: Buffer.from('world').toString('base64'),
},
__internal__: {
hasHeaders: false,
hasPKI: true,
},
},
isValid: true,
});
});
});
});
});

View file

@ -11,6 +11,7 @@ import {
SimpleConnectorForm,
} from '@kbn/triggers-actions-ui-plugin/public';
import {
FilePickerField,
SelectField,
TextField,
ToggleField,
@ -39,21 +40,27 @@ import {
azureAiConfig,
azureAiSecrets,
otherOpenAiConfig,
otherOpenAiSecrets,
getOtherOpenAiSecrets,
openAiSecrets,
providerOptions,
openAiConfig,
} from './constants';
import { CRT_REQUIRED, KEY_REQUIRED } from '../../common/auth/translations';
const { emptyField } = fieldValidators;
const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdit }) => {
const { euiTheme } = useEuiTheme();
const { getFieldDefaultValue } = useFormContext();
const [{ config, __internal__, id, name }] = useFormData({
watch: ['config.apiProvider', '__internal__.hasHeaders'],
watch: ['config.apiProvider', '__internal__.hasHeaders', '__internal__.hasPKI'],
});
const hasHeaders = __internal__ != null ? __internal__.hasHeaders : false;
const hasHeadersDefaultValue = !!getFieldDefaultValue<boolean | undefined>('config.headers');
const hasPKI = __internal__ != null ? __internal__.hasPKI : false;
const hasPKIDefaultValue = useMemo(() => {
return !!getFieldDefaultValue<boolean | undefined>('config.verificationMode');
}, [getFieldDefaultValue]);
const selectedProviderDefaultValue = useMemo(
() =>
@ -61,6 +68,14 @@ const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdi
[getFieldDefaultValue]
);
const verificationModeOptions = [
{ value: 'full', text: i18n.VERIFICATION_MODE_FULL },
{ value: 'certificate', text: i18n.VERIFICATION_MODE_CERTIFICATE },
{ value: 'none', text: i18n.VERIFICATION_MODE_NONE },
];
const otherOpenAiSecrets = useMemo(() => getOtherOpenAiSecrets(!hasPKI), [hasPKI]);
return (
<>
<UseField
@ -105,12 +120,112 @@ const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdi
/>
)}
{config != null && config.apiProvider === OpenAiProviderType.Other && (
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={otherOpenAiConfig}
secretsFormSchema={otherOpenAiSecrets}
/>
<>
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={otherOpenAiConfig}
secretsFormSchema={otherOpenAiSecrets}
/>
<EuiSpacer size="s" />
<UseField
path="__internal__.hasPKI"
component={ToggleField}
config={{
defaultValue: hasPKIDefaultValue,
label: i18n.PKI_MODE_LABEL,
}}
componentProps={{
euiFieldProps: {
disabled: readOnly,
'data-test-subj': 'openAIViewPKISwitch',
},
}}
/>
{hasPKI && (
<>
<EuiSpacer size="s" />
<UseField
path="config.verificationMode"
component={SelectField}
config={{
label: i18n.VERIFICATION_MODE_LABEL,
validations: [
{
validator: emptyField(CRT_REQUIRED),
},
],
defaultValue: 'full',
helpText: i18n.VERIFICATION_MODE_DESC,
}}
componentProps={{
euiFieldProps: {
options: verificationModeOptions,
'data-test-subj': 'verificationModeSelect',
fullWidth: true,
disabled: readOnly,
},
}}
/>
<EuiSpacer size="s" />
<UseField
path="secrets.certificateData"
config={{
label: i18n.CERT_DATA_LABEL,
validations: [
{
validator: emptyField(CRT_REQUIRED),
},
],
}}
component={FilePickerField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'openAISSLCRTInput',
display: 'default',
accept: '.crt,.cert,.cer,.pem',
},
}}
helpText={i18n.CERT_DATA_DESC}
/>
<UseField
path="secrets.privateKeyData"
config={{
label: i18n.KEY_DATA_LABEL,
validations: [
{
validator: emptyField(KEY_REQUIRED),
},
],
}}
component={FilePickerField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'openAISSLKEYInput',
display: 'default',
accept: '.key,.pem',
},
}}
helpText={i18n.KEY_DATA_DESC}
/>
<UseField
path="secrets.caData"
config={{
label: i18n.CA_DATA_LABEL,
}}
component={FilePickerField}
componentProps={{
euiFieldProps: {
'data-test-subj': 'openAISSLCAInput',
display: 'default',
accept: '.crt,.cert,.cer,.pem',
},
}}
helpText={i18n.CA_DATA_DESC}
/>
</>
)}
</>
)}
<UseField
path="__internal__.hasHeaders"
@ -173,7 +288,7 @@ const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdi
onClick={() => removeItem(item.id)}
iconType="minusInCircle"
aria-label={i18nAuth.DELETE_BUTTON}
style={{ marginTop: '28px' }}
css={{ marginTop: '28px' }}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -243,11 +243,12 @@ export const azureAiSecrets: SecretsFieldSchema[] = [
},
];
export const otherOpenAiSecrets: SecretsFieldSchema[] = [
export const getOtherOpenAiSecrets = (isRequired = true): SecretsFieldSchema[] => [
{
id: 'apiKey',
label: i18n.API_KEY_LABEL,
isPasswordField: true,
isRequired,
helpText: (
<FormattedMessage
defaultMessage="The Other (OpenAI Compatible Service) API key for HTTP Basic authentication. For more details about generating Other model API keys, refer to the {genAiAPIKeyDocs}."

View file

@ -72,6 +72,70 @@ export const DOCUMENTATION = i18n.translate(
}
);
export const CERT_DATA_LABEL = i18n.translate(
'xpack.stackConnectors.components.genAi.certificateDataLabel',
{
defaultMessage: 'Certificate file',
}
);
export const CERT_DATA_DESC = i18n.translate(
'xpack.stackConnectors.components.genAi.certificateDataDocumentation',
{
defaultMessage: 'Raw PKI certificate content (PEM format) for cloud or on-premise deployments.',
}
);
export const KEY_DATA_LABEL = i18n.translate(
'xpack.stackConnectors.components.genAi.privateKeyDataLabel',
{
defaultMessage: 'Private key file',
}
);
export const KEY_DATA_DESC = i18n.translate(
'xpack.stackConnectors.components.genAi.privateKeyDataDocumentation',
{
defaultMessage: 'Raw PKI private key content (PEM format) for cloud or on-premise deployments.',
}
);
export const VERIFICATION_MODE_LABEL = i18n.translate(
'xpack.stackConnectors.components.genAi.verificationModeLabel',
{
defaultMessage: 'SSL verification mode',
}
);
export const VERIFICATION_MODE_DESC = i18n.translate(
'xpack.stackConnectors.components.genAi.verificationModeDocumentation',
{
defaultMessage:
'Controls SSL/TLS certificate verification: `Full` verifies both certificate and hostname, `Certificate` verifies the certificate but not the hostname, `None` skips all verification. Use `None` cautiously for testing purposes.',
}
);
export const VERIFICATION_MODE_FULL = i18n.translate(
'xpack.stackConnectors.components.genAi.verificationModeFullLabel',
{
defaultMessage: 'Full (Certificate and Hostname)',
}
);
export const VERIFICATION_MODE_CERTIFICATE = i18n.translate(
'xpack.stackConnectors.components.genAi.verificationModeCertificateLabel',
{
defaultMessage: 'Certificate Only',
}
);
export const VERIFICATION_MODE_NONE = i18n.translate(
'xpack.stackConnectors.components.genAi.verificationModeNoneLabel',
{
defaultMessage: 'None (Skip Verification)',
}
);
export const URL_LABEL = i18n.translate(
'xpack.stackConnectors.components.genAi.urlTextFieldLabel',
{
@ -118,3 +182,18 @@ export const USAGE_DASHBOARD_LINK = (apiProvider: string, connectorName: string)
values: { apiProvider, connectorName },
defaultMessage: 'View {apiProvider} Usage Dashboard for "{ connectorName }" Connector',
});
export const PKI_MODE_LABEL = i18n.translate('xpack.stackConnectors.genAi.pkiModeLabel', {
defaultMessage: 'Enable PKI Authentication',
});
export const CA_DATA_LABEL = i18n.translate('xpack.stackConnectors.components.genAi.caDataLabel', {
defaultMessage: 'CA certificate file',
});
export const CA_DATA_DESC = i18n.translate(
'xpack.stackConnectors.components.genAi.caDataDocumentation',
{
defaultMessage: 'Raw CA certificate content (PEM) used to verify the server certificate.',
}
);

View file

@ -19,6 +19,16 @@ export interface Config {
apiProvider: OpenAiProviderType;
apiUrl: string;
defaultModel?: string;
headers?: Record<string, string>;
// PKI properties are only used when apiProvider is OpenAiProviderType.Other
certificateData?: string;
privateKeyData?: string;
verificationMode?: 'full' | 'certificate' | 'none';
caData?: string;
}
export interface InternalConfig {
hasPKI: boolean;
}
export interface Secrets {

View file

@ -5,7 +5,16 @@
* 2.0.
*/
import { sanitizeRequest, getRequestWithStreamOption } from './other_openai_utils';
import type { Logger } from '@kbn/logging';
import {
sanitizeRequest,
getRequestWithStreamOption,
pkiSecretsValidator,
pkiErrorHandler,
getPKISSLOverrides,
} from './other_openai_utils';
import type { AxiosError } from 'axios';
import type { Secrets } from '../../../../common/openai/types';
describe('Other (OpenAI Compatible Service) Utils', () => {
describe('sanitizeRequest', () => {
@ -150,4 +159,203 @@ describe('Other (OpenAI Compatible Service) Utils', () => {
});
});
});
describe('PKI utils', () => {
it('pkiErrorHandler returns undefined for unrecognized PKI-related error messages', () => {
const error = { message: 'Some unknown error occurred' } as AxiosError;
const result = pkiErrorHandler(error);
expect(result).toBeUndefined();
});
it('returns a friendly message for UNABLE_TO_VERIFY_LEAF_SIGNATURE', () => {
const error = {
message: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE: certificate problem',
} as AxiosError;
const result = pkiErrorHandler(error);
expect(result).toMatch(
/Certificate error: UNABLE_TO_VERIFY_LEAF_SIGNATURE: certificate problem. Please check if your PKI certificates are valid or adjust SSL verification mode./
);
});
it('returns a TLS handshake message for ERR_TLS_CERT_ALTNAME_INVALID', () => {
const error = {
message: 'ERR_TLS_CERT_ALTNAME_INVALID: Hostname/IP does not match certificate',
} as AxiosError;
const result = pkiErrorHandler(error);
expect(result).toMatch(
/TLS handshake failed: ERR_TLS_CERT_ALTNAME_INVALID: Hostname\/IP does not match certificate. Verify server certificate hostname and CA configuration./
);
});
it('returns a TLS handshake message for ERR_TLS_HANDSHAKE', () => {
const error = { message: 'ERR_TLS_HANDSHAKE: handshake failed' } as AxiosError;
const result = pkiErrorHandler(error);
expect(result).toMatch(
/TLS handshake failed: ERR_TLS_HANDSHAKE: handshake failed. Verify server certificate hostname and CA configuration./
);
});
it('throws an error if certificateData is missing in pkiSecretsValidator', () => {
const secretsObject = { privateKeyData: 'privateKey' } as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).toThrow(
'Certificate data must be provided for PKI'
);
});
it('throws an error if privateKeyData is missing in pkiSecretsValidator', () => {
const secretsObject = { certificateData: 'certificate' } as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).toThrow(
'Private key data must be provided for PKI'
);
});
it('validates PEM format for privateKeyData in pkiSecretsValidator', () => {
const secretsObject = {
certificateData: Buffer.from(
'-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
).toString('base64'),
privateKeyData: 'bad',
} as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).toThrow(
'Invalid Private key data file format: The file must be a PEM-encoded private key beginning with "-----BEGIN PRIVATE KEY-----" or "-----BEGIN RSA PRIVATE KEY-----".'
);
});
it('validates PEM format for certificateData in pkiSecretsValidator', () => {
const secretsObject = {
certificateData: 'invalidCertificate',
privateKeyData: '-----BEGIN PRIVATE KEY-----',
} as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).toThrow(
'Invalid Certificate data file format: The file must be a PEM-encoded certificate beginning with "-----BEGIN CERTIFICATE-----".'
);
});
it('validates successfully with valid certificateData and privateKeyData PEM', () => {
const secretsObject = {
certificateData: Buffer.from(
'-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
).toString('base64'),
privateKeyData: Buffer.from(
'-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----'
).toString('base64'),
} as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).not.toThrow();
});
it('validates successfully with valid certificateData, privateKeyData, and caData PEM', () => {
const secretsObject = {
certificateData: Buffer.from(
'-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
).toString('base64'),
privateKeyData: Buffer.from(
'-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----'
).toString('base64'),
caData: Buffer.from('-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----').toString(
'base64'
),
} as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).not.toThrow();
});
it('throws an error if caData is not valid PEM in pkiSecretsValidator', () => {
const secretsObject = {
certificateData: Buffer.from(
'-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
).toString('base64'),
privateKeyData: Buffer.from(
'-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----'
).toString('base64'),
caData: Buffer.from('not a valid cert').toString('base64'),
} as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).toThrow(
'Invalid CA certificate data file format: The file must be a PEM-encoded certificate beginning with "-----BEGIN CERTIFICATE-----".'
);
});
it('throws an error if none of the PKI fields are present', () => {
const secretsObject = {} as Secrets;
expect(() => pkiSecretsValidator(secretsObject)).toThrow(
'PKI configuration requires certificate and private key'
);
});
describe('getPKISSLOverrides', () => {
const logger = { error: jest.fn() } as unknown as Logger;
const validCert = Buffer.from(
'-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----'
).toString('base64');
const validKey = Buffer.from(
'-----BEGIN PRIVATE KEY-----\nxyz\n-----END PRIVATE KEY-----'
).toString('base64');
const validCA = Buffer.from(
'-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----'
).toString('base64');
it('returns expected SSLSettings with valid cert, key, and ca', () => {
const result = getPKISSLOverrides({
logger,
certificateData: validCert,
privateKeyData: validKey,
caData: validCA,
verificationMode: 'full',
});
expect(result).toMatchObject({
verificationMode: 'full',
});
expect(result.cert).toBeInstanceOf(Buffer);
expect(result.key).toBeInstanceOf(Buffer);
expect(result.ca).toBeInstanceOf(Buffer);
});
it('returns expected SSLSettings with valid cert and key, ca undefined', () => {
const result = getPKISSLOverrides({
logger,
certificateData: validCert,
privateKeyData: validKey,
caData: undefined,
verificationMode: 'none',
});
expect(result.ca).toBeUndefined();
expect(result.cert).toBeInstanceOf(Buffer);
expect(result.key).toBeInstanceOf(Buffer);
expect(result.verificationMode).toBe('none');
});
it('throws if cert is invalid', () => {
expect(() =>
getPKISSLOverrides({
logger,
certificateData: Buffer.from('not a cert').toString('base64'),
privateKeyData: validKey,
caData: validCA,
verificationMode: 'full',
})
).toThrow();
});
it('throws if key is invalid', () => {
expect(() =>
getPKISSLOverrides({
logger,
certificateData: validCert,
privateKeyData: Buffer.from('not a key').toString('base64'),
caData: validCA,
verificationMode: 'full',
})
).toThrow();
});
it('throws if ca is invalid', () => {
expect(() =>
getPKISSLOverrides({
logger,
certificateData: validCert,
privateKeyData: validKey,
caData: Buffer.from('not a ca').toString('base64'),
verificationMode: 'full',
})
).toThrow();
});
});
});
});

View file

@ -5,6 +5,9 @@
* 2.0.
*/
import type { Logger } from '@kbn/logging';
import type { SSLSettings } from '@kbn/actions-plugin/server';
import type { AxiosError } from 'axios';
import type { Secrets } from '../../../../common/openai/types';
/**
@ -46,8 +49,204 @@ export const getRequestWithStreamOption = (
return body;
};
// PKI utils
/**
* Input interface for SSL overrides.
*/
interface SSLOverridesInput {
logger: Logger; // Logger instance for logging errors or information.
certificateData: string; // PEM-encoded certificate data as a string.
privateKeyData: string; // PEM-encoded private key data as a string.
caData?: string; // PEM-encoded CA certificate data as a string.
verificationMode: 'full' | 'certificate' | 'none'; // SSL verification mode.
}
/**
* Validates that the provided PEM data is in the correct format.
*
* @param data - The PEM data to validate.
* @param type - The type of PEM data ('CERTIFICATE' or 'PRIVATE KEY').
* @param description - A description of the data being validated.
* @throws Will throw an error if the PEM data is not in the correct format.
*/
const PEM_HEADERS: Record<'PRIVATE KEY' | 'CERTIFICATE', string[]> = {
'PRIVATE KEY': ['-----BEGIN PRIVATE KEY-----', '-----BEGIN RSA PRIVATE KEY-----'],
CERTIFICATE: ['-----BEGIN CERTIFICATE-----'],
};
const PEM_FOOTERS: Record<'PRIVATE KEY' | 'CERTIFICATE', string[]> = {
'PRIVATE KEY': ['-----END PRIVATE KEY-----', '-----END RSA PRIVATE KEY-----'],
CERTIFICATE: ['-----END CERTIFICATE-----'],
};
function getPEMHeaderFooter(
type: 'CERTIFICATE' | 'PRIVATE KEY',
content: string
): { header: string; footer: string } | undefined {
const headers = PEM_HEADERS[type];
const footers = PEM_FOOTERS[type];
for (let i = 0; i < headers.length; ++i) {
if (content.includes(headers[i])) {
return { header: headers[i], footer: footers[i] };
}
}
return undefined;
}
const validatePEMData = (
data: string | undefined,
type: 'CERTIFICATE' | 'PRIVATE KEY',
description: string
): void => {
const decodedData = data ? Buffer.from(data, 'base64').toString() : undefined;
if (!decodedData) return;
const headers = PEM_HEADERS[type];
const found = headers.some((h) => decodedData.includes(h));
if (!found) {
const headerMsg =
headers.length === 1 ? `"${headers[0]}"` : headers.map((h) => `"${h}"`).join(' or ');
throw new Error(
`Invalid ${description} file format: The file must be a PEM-encoded ${type.toLowerCase()} beginning with ${headerMsg}.`
);
}
};
/**
* Validates the provided PKI certificates for correct PEM formatting.
*
* @param input - An object containing certificate, private key, and CA data, along with a logger.
* @throws Will log and throw an error if any of the provided certificates are invalid.
*/
const validatePKICertificates = ({
certificateData,
privateKeyData,
caData,
logger,
}: Omit<SSLOverridesInput, 'verificationMode'>): void => {
try {
validatePEMData(certificateData, 'CERTIFICATE', 'Certificate data');
validatePEMData(privateKeyData, 'PRIVATE KEY', 'Private key data');
if (caData) validatePEMData(caData, 'CERTIFICATE', 'CA certificate data');
} catch (error) {
logger.error(`Error validating PKI certificates: ${error.message}`);
throw new Error(`Invalid or inaccessible PKI certificates: ${error.message}`);
}
};
/**
* Converts PEM data into a Buffer.
*
* @param data - The PEM data to convert.
* @param type - The type of PEM data ('CERTIFICATE' or 'PRIVATE KEY').
* @returns A Buffer containing the PEM data.
* @throws Will throw an error if no data is provided.
*/
const loadBuffer = (data: string, type: 'CERTIFICATE' | 'PRIVATE KEY'): Buffer => {
if (data) {
return Buffer.from(formatPEMContent(Buffer.from(data, 'base64').toString(), type));
}
throw new Error(`No ${type?.toLowerCase()} data provided`);
};
/**
* Generates SSL settings for PKI authentication.
*
* @param input - An object containing logger, certificate, private key, and CA data, along with verification mode.
* @returns An SSLSettings object containing the certificate, private key, CA, and verification mode.
* @throws Will throw an error if the provided certificates are invalid or missing.
*/
export const getPKISSLOverrides = ({
logger,
certificateData,
privateKeyData,
caData,
verificationMode,
}: SSLOverridesInput): SSLSettings => {
validatePKICertificates({
logger,
certificateData,
privateKeyData,
caData,
});
const cert = loadBuffer(certificateData, 'CERTIFICATE');
const key = loadBuffer(privateKeyData, 'PRIVATE KEY');
// CA can be undefined for verification mode "none".
const ca = caData ? loadBuffer(caData, 'CERTIFICATE') : undefined;
return { cert, key, ca, verificationMode };
};
/**
* Formats PEM content to ensure proper structure and line breaks.
*
* @param pemContent - The PEM content to format.
* @param type - The type of PEM data ('CERTIFICATE' or 'PRIVATE KEY').
* @returns A properly formatted PEM string.
*/
function formatPEMContent(pemContent: string, type: 'CERTIFICATE' | 'PRIVATE KEY'): string {
if (!pemContent) return pemContent;
const normalizedContent = pemContent.replace(/\s+/g, ' ').trim();
const headerFooter = getPEMHeaderFooter(type, normalizedContent);
if (!headerFooter) {
return pemContent;
}
const { header, footer } = headerFooter;
if (!normalizedContent.startsWith(header) || !normalizedContent.endsWith(footer)) {
return pemContent;
}
const base64Content = normalizedContent
.slice(header.length, normalizedContent.length - footer.length)
.replace(/\s+/g, '');
const formattedBase64 = base64Content.match(/.{1,64}/g)?.join('\n') || base64Content;
return `${header}\n${formattedBase64}\n${footer}`;
}
/**
* Handles PKI-related errors and provides user-friendly error messages.
*
* @param error - The AxiosError object containing details about the error.
* @returns A string with a user-friendly error message, or undefined if the error is not recognized.
*/
export const pkiErrorHandler = (error: AxiosError): string | undefined => {
if (error.message.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) {
return `Certificate error: ${error.message}. Please check if your PKI certificates are valid or adjust SSL verification mode.`;
}
if (
error.message.includes('ERR_TLS_CERT_ALTNAME_INVALID') ||
error.message.includes('ERR_TLS_HANDSHAKE')
) {
return `TLS handshake failed: ${error.message}. Verify server certificate hostname and CA configuration.`;
}
};
/**
* Validates the PKI configuration object to ensure required fields are present and properly formatted.
*
* @param secretsObject - The configuration object containing PKI-related data.
* @throws Will throw an error if required fields are missing or if the provided data is not in valid PEM format.
*/
export const pkiSecretsValidator = (secretsObject: Secrets): void => {
// TODO - implemented in 219984
if (
'caData' in secretsObject ||
'certificateData' in secretsObject ||
'privateKeyData' in secretsObject
) {
if (!secretsObject.certificateData) {
throw new Error('Certificate data must be provided for PKI');
}
if (!secretsObject.privateKeyData) {
throw new Error('Private key data must be provided for PKI');
}
// Validate PEM format for raw data
validatePEMData(secretsObject.certificateData, 'CERTIFICATE', 'Certificate data');
validatePEMData(secretsObject.privateKeyData, 'PRIVATE KEY', 'Private key data');
// CA is optional, but if provided, validate its format
if (secretsObject.caData) {
validatePEMData(secretsObject.caData, 'CERTIFICATE', 'CA certificate data');
}
} else {
throw new Error('PKI configuration requires certificate and private key');
}
};
/**
* Validates the apiKey in the secrets object for non-PKI authentication.

View file

@ -241,6 +241,19 @@ describe('OpenAIConnector', () => {
connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }, connectorUsageCollector)
).rejects.toThrow('API Error');
});
it('passes timeout and signal to runApi', async () => {
const signal = jest.fn();
const timeout = 12345;
await connector.runApi(
{ body: JSON.stringify({ messages: [] }), signal, timeout },
new ConnectorUsageCollector({ logger, connectorId: 'test' })
);
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({ signal, timeout }),
expect.anything()
);
});
});
describe('streamApi', () => {
@ -1506,6 +1519,155 @@ describe('OpenAIConnector', () => {
expect(response).toEqual({ available: false });
});
});
describe('PKI/SSL overrides', () => {
const config = {
apiUrl: 'https://other-openai.local/v1/chat/completions',
apiProvider: OpenAiProviderType.Other,
defaultModel: DEFAULT_OTHER_OPENAI_MODEL,
verificationMode: 'full' as const,
headers: {},
};
const secrets = {
certificateData: Buffer.from(
'-----BEGIN CERTIFICATE-----cert-----END CERTIFICATE-----'
).toString('base64'),
privateKeyData: Buffer.from(
'-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----'
).toString('base64'),
caData: Buffer.from('-----BEGIN CERTIFICATE-----ca-----END CERTIFICATE-----').toString(
'base64'
),
};
it('should initialize PKI SSL overrides when PKI secrets are present', () => {
const connector = new OpenAIConnector({
configurationUtilities: actionsConfigMock.create(),
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
config,
secrets,
logger,
services: actionsMock.createServices(),
});
expect(connector).toBeDefined();
// @ts-ignore
expect(connector.sslOverrides).toBeDefined();
});
it('should throw and log error when PKI secrets are invalid', () => {
const badSecrets = { ...secrets, certificateData: undefined };
expect(() => {
new OpenAIConnector({
configurationUtilities: actionsConfigMock.create(),
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
config,
secrets: badSecrets,
logger,
services: actionsMock.createServices(),
});
}).toThrow();
});
it('should call runApi with sslOverrides when they exist', async () => {
const connector = new OpenAIConnector({
configurationUtilities: actionsConfigMock.create(),
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
config,
secrets,
logger,
services: actionsMock.createServices(),
});
// @ts-ignore
connector.request = jest.fn().mockResolvedValue({ data: {} });
// @ts-ignore
connector.request = mockRequest;
await connector.runApi(
{ body: JSON.stringify({ messages: [] }) },
new ConnectorUsageCollector({ logger, connectorId: 'test' })
);
expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
sslOverrides: expect.objectContaining({
verificationMode: 'full',
}),
}),
expect.anything()
);
});
});
describe('Organization/Project headers', () => {
it('should include OpenAI-Organization and OpenAI-Project headers if present', async () => {
const connector = new OpenAIConnector({
configurationUtilities: actionsConfigMock.create(),
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
config: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
apiProvider: OpenAiProviderType.OpenAi,
defaultModel: DEFAULT_OPENAI_MODEL,
organizationId: 'org-id',
projectId: 'proj-id',
headers: { 'X-My-Custom-Header': 'foo' },
},
secrets: { apiKey: '123' },
logger,
services: actionsMock.createServices(),
});
// @ts-ignore
connector.request = jest.fn().mockResolvedValue({ data: {} });
// @ts-ignore
connector.request = mockRequest;
await connector.runApi(
{ body: JSON.stringify({ messages: [] }) },
new ConnectorUsageCollector({ logger, connectorId: 'test' })
);
const callArgs = mockRequest.mock.calls[0][0];
expect(callArgs.headers['OpenAI-Organization']).toBe('org-id');
expect(callArgs.headers['OpenAI-Project']).toBe('proj-id');
});
});
describe('Enhanced error handling', () => {
const connector = new OpenAIConnector({
configurationUtilities: actionsConfigMock.create(),
connector: { id: '1', type: OPENAI_CONNECTOR_ID },
config: {
apiUrl: 'https://api.openai.com/v1/chat/completions',
apiProvider: OpenAiProviderType.OpenAi,
defaultModel: DEFAULT_OPENAI_MODEL,
headers: {},
},
secrets: { apiKey: '123' },
logger: loggingSystemMock.createLogger(),
services: actionsMock.createServices(),
});
it('returns Azure function error message', () => {
const err = { message: '404 Unrecognized request argument supplied: functions' };
// @ts-ignore
expect(connector.getResponseErrorMessage(err)).toContain(
'Function support with Azure OpenAI API was added'
);
});
it('returns LM Studio error message', () => {
const err = {
response: {
status: 400,
statusText: 'Bad Request',
data: { error: 'LM Studio error' },
},
};
// @ts-ignore
expect(connector.getResponseErrorMessage(err)).toContain('LM Studio error');
});
it('returns Unauthorized error message', () => {
const err = {
response: {
status: 401,
statusText: 'Unauthorized',
data: { error: { message: 'Invalid key' } },
},
};
// @ts-ignore
expect(connector.getResponseErrorMessage(err)).toContain('Unauthorized API Error');
});
});
});
function createStreamMock() {

View file

@ -5,12 +5,13 @@
* 2.0.
*/
import type { ServiceParams } from '@kbn/actions-plugin/server';
import type { ServiceParams, SSLSettings } from '@kbn/actions-plugin/server';
import { SubActionConnector } from '@kbn/actions-plugin/server';
import type { AxiosError } from 'axios';
import OpenAI from 'openai';
import { PassThrough } from 'stream';
import type { IncomingMessage } from 'http';
import type { Agent as HttpsAgent } from 'https';
import type { Agent as HttpAgent, IncomingMessage } from 'http';
import type {
ChatCompletionChunk,
ChatCompletionCreateParamsStreaming,
@ -55,13 +56,15 @@ import {
pipeStreamingResponse,
sanitizeRequest,
} from './lib/utils';
import { getPKISSLOverrides, pkiErrorHandler } from './lib/other_openai_utils';
export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
private url;
private provider;
private key;
private openAI;
private headers;
private url: string;
private provider: OpenAiProviderType;
private key: string;
private openAI: OpenAI;
private headers: Record<string, string>;
private sslOverrides?: SSLSettings;
constructor(params: ServiceParams<Config, Secrets>) {
super(params);
@ -77,36 +80,88 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
...('projectId' in this.config ? { 'OpenAI-Project': this.config.projectId } : {}),
};
const { httpAgent, httpsAgent } = getCustomAgents(
this.configurationUtilities,
this.logger,
this.url
);
try {
let httpAgent;
let baseURL = this.config.apiUrl;
const defaultHeaders = { ...this.headers };
let defaultQuery: Record<string, string> | undefined;
this.openAI =
this.config.apiProvider === OpenAiProviderType.AzureAi
? new OpenAI({
apiKey: this.key,
baseURL: this.config.apiUrl,
defaultQuery: { 'api-version': getAzureApiVersionParameter(this.config.apiUrl) },
defaultHeaders: {
...this.headers,
'api-key': this.key,
},
httpAgent: httpsAgent ?? httpAgent,
})
: new OpenAI({
baseURL: removeEndpointFromUrl(this.config.apiUrl),
apiKey: this.key,
defaultHeaders: {
...this.headers,
},
httpAgent: httpsAgent ?? httpAgent,
});
if (
this.provider === OpenAiProviderType.Other &&
(('certificateData' in this.secrets && this.secrets.certificateData) ||
('caData' in this.secrets && this.secrets.caData) ||
('privateKeyData' in this.secrets && this.secrets.privateKeyData)) &&
'verificationMode' in this.config &&
this.config.verificationMode
) {
this.sslOverrides = getPKISSLOverrides({
logger: this.logger,
// ! These two values must be defined for PKI use. If undefined, will throw error
certificateData: this.secrets.certificateData!,
privateKeyData: this.secrets.privateKeyData!,
caData: this.secrets.caData,
verificationMode: this.config.verificationMode,
});
const agents = getCustomAgents(
this.configurationUtilities,
this.logger,
this.url,
this.sslOverrides
);
httpAgent = agents.httpsAgent;
baseURL = removeEndpointFromUrl(this.url);
} else {
const agents = getCustomAgents(this.configurationUtilities, this.logger, this.url);
httpAgent = agents.httpsAgent ?? agents.httpAgent;
if (this.config.apiProvider === OpenAiProviderType.AzureAi) {
baseURL = this.config.apiUrl;
defaultHeaders['api-key'] = this.key;
const apiVersion = getAzureApiVersionParameter(this.config.apiUrl);
if (apiVersion) {
defaultQuery = { 'api-version': apiVersion };
}
} else {
baseURL = removeEndpointFromUrl(this.config.apiUrl);
}
}
this.openAI = this.createOpenAIClient({
apiKey: this.key,
baseURL,
defaultHeaders,
httpAgent,
defaultQuery,
});
} catch (error) {
this.logger.error(`Error initializing OpenAI client: ${error.message}`);
throw error;
}
this.registerSubActions();
}
private createOpenAIClient({
apiKey,
baseURL,
defaultHeaders,
httpAgent,
defaultQuery,
}: {
apiKey: string;
baseURL: string;
defaultHeaders: Record<string, string>;
httpAgent?: HttpsAgent | HttpAgent;
defaultQuery?: Record<string, string>;
}): OpenAI {
return new OpenAI({
apiKey,
baseURL,
defaultHeaders,
httpAgent,
defaultQuery,
});
}
private registerSubActions() {
this.registerSubAction({
name: SUB_ACTION.RUN,
@ -169,11 +224,11 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
}
return `API Error: ${error.response?.statusText}${errorMessage ? ` - ${errorMessage}` : ''}`;
}
/**
* responsible for making a POST request to the external API endpoint and returning the response data
* @param body The stringified request body to be sent in the POST request.
*/
public async runApi(
{ body, signal, timeout }: RunActionParams,
connectorUsageCollector: ConnectorUsageCollector
@ -185,24 +240,36 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
...('defaultModel' in this.config ? [this.config.defaultModel] : [])
);
const axiosOptions = getAxiosOptions(this.provider, this.key, false);
const response = await this.request(
{
url: this.url,
method: 'post',
responseSchema: RunActionResponseSchema,
data: sanitizedBody,
signal,
// give up to 2 minutes for response
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
...axiosOptions,
headers: {
...this.headers,
...axiosOptions.headers,
try {
const response = await this.request(
{
url: this.url,
method: 'post',
responseSchema: RunActionResponseSchema,
data: sanitizedBody,
signal,
// give up to 2 minutes for response
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
...axiosOptions,
headers: {
...this.headers,
...axiosOptions.headers,
},
sslOverrides: this.sslOverrides,
},
},
connectorUsageCollector
);
return response.data;
connectorUsageCollector
);
return response.data;
} catch (error) {
// special error handling for PKI errors
const errorMessage = pkiErrorHandler(error);
if (errorMessage?.length) {
throw new Error(errorMessage);
}
throw error;
}
}
/**
@ -226,24 +293,34 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
);
const axiosOptions = getAxiosOptions(this.provider, this.key, stream);
const response = await this.request(
{
url: this.url,
method: 'post',
responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema,
data: executeBody,
signal,
...axiosOptions,
headers: {
...this.headers,
...axiosOptions.headers,
try {
const response = await this.request(
{
url: this.url,
method: 'post',
responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema,
data: executeBody,
signal,
...axiosOptions,
headers: {
...this.headers,
...axiosOptions.headers,
},
timeout,
sslOverrides: this.sslOverrides,
},
timeout,
},
connectorUsageCollector
);
return stream ? pipeStreamingResponse(response) : response.data;
connectorUsageCollector
);
return stream ? pipeStreamingResponse(response) : response.data;
} catch (error) {
// special error handling for PKI errors
const errorMessage = pkiErrorHandler(error);
if (errorMessage?.length) {
throw new Error(errorMessage);
}
throw error;
}
}
/**

View file

@ -216,6 +216,174 @@ export default function genAiTest({ getService }: FtrProviderContext) {
});
});
});
it('should return error when creating the connector with PKI auth that has missing crt', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config,
secrets: {
privateKeyData: Buffer.from(
'-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----'
).toString('base64'),
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: Certificate data must be provided for PKI',
});
});
});
it('should return error when creating the connector with PKI auth that has missing key', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config,
secrets: {
certificateData: Buffer.from(
'-----BEGIN CERTIFICATE-----cert-----END CERTIFICATE-----'
).toString('base64'),
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: Private key data must be provided for PKI',
});
});
});
it('should return error when creating the connector with PKI auth that has bad crt', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config,
secrets: {
certificateData: Buffer.from('invalid crt format').toString('base64'),
privateKeyData: Buffer.from(
'-----BEGIN PRIVATE KEY-----key-----END PRIVATE KEY-----'
).toString('base64'),
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: Invalid Certificate data file format: The file must be a PEM-encoded certificate beginning with "-----BEGIN CERTIFICATE-----".',
});
});
});
it('should return error when creating the connector with PKI auth that has bad key', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name,
connector_type_id: connectorTypeId,
config,
secrets: {
certificateData: Buffer.from(
'-----BEGIN CERTIFICATE-----cert-----END CERTIFICATE-----'
).toString('base64'),
privateKeyData: Buffer.from('invalid key format').toString('base64'),
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: Invalid Private key data file format: The file must be a PEM-encoded private key beginning with "-----BEGIN PRIVATE KEY-----" or "-----BEGIN RSA PRIVATE KEY-----".',
});
});
});
it('should create the connector with PKI auth when both certificate and key are valid', async () => {
const validCert = Buffer.from(
'-----BEGIN CERTIFICATE-----\nMIIC+zCCAeOgAwIBAgIJAKKpPKItestcert\n-----END CERTIFICATE-----'
).toString('base64');
const validKey = Buffer.from(
'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----'
).toString('base64');
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: `${name} PKI`,
connector_type_id: connectorTypeId,
config: {
...config,
apiProvider: 'Other',
defaultModel: 'gpt-3.5-turbo',
},
secrets: {
certificateData: validCert,
privateKeyData: validKey,
},
})
.expect(200);
expect(createdAction).to.have.property('id');
expect(createdAction.config.apiProvider).to.equal('Other');
});
it('should execute successfully with valid PKI certificate and key', async () => {
const validCert = Buffer.from(
'-----BEGIN CERTIFICATE-----\nMIIC+zCCAeOgAwIBAgIJAKKpPKItestcert\n-----END CERTIFICATE-----'
).toString('base64');
const validKey = Buffer.from(
'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----'
).toString('base64');
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: `${name} PKI exec`,
connector_type_id: connectorTypeId,
config: {
...config,
apiProvider: 'Other',
defaultModel: 'gpt-3.5-turbo',
},
secrets: {
certificateData: validCert,
privateKeyData: validKey,
},
})
.expect(200);
const { body } = await supertest
.post(`/api/actions/connector/${createdAction.id}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'test',
subActionParams: {
body: '{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"Hello world"}]}',
},
},
})
.expect(200);
expect(body.status).to.equal('ok');
expect(body.connector_id).to.equal(createdAction.id);
});
});
describe('executor', () => {