mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
OpenAI (Other) Connector PKI implementation (#219984)
This commit is contained in:
parent
9f7cffc4f3
commit
8fae18a9b2
19 changed files with 1429 additions and 100 deletions
|
@ -9,7 +9,7 @@ applies_to:
|
||||||
|
|
||||||
# OpenAI connector and action [openai-action-type]
|
# 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]
|
## 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:
|
OpenAI connectors have the following configuration properties:
|
||||||
|
|
||||||
Name
|
| Field | Required for | Description |
|
||||||
: The name of the connector.
|
|------------------|---------------------|---------------------------------------------------------------------------------------------|
|
||||||
|
| 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
|
#### PKI Authentication (Other provider only)
|
||||||
: The OpenAI API provider, either OpenAI or Azure OpenAI.
|
|
||||||
|
|
||||||
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:
|
||||||
: The OpenAI request URL.
|
|
||||||
|
|
||||||
Default model
|
- **Certificate data** (`certificateData`): PEM-encoded certificate content, base64-encoded. (**Required for PKI**)
|
||||||
: (optional) The default model to use for requests. This option is available only when the provider is `OpenAI`.
|
- **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
|
**Note:**
|
||||||
: The OpenAI or Azure OpenAI API key for authentication.
|
- 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]
|
## Test connectors [gen-ai-action-configuration]
|
||||||
|
|
||||||
|
|
|
@ -383,6 +383,7 @@ paths:
|
||||||
- $ref: '#/components/schemas/jira_config'
|
- $ref: '#/components/schemas/jira_config'
|
||||||
- $ref: '#/components/schemas/genai_azure_config'
|
- $ref: '#/components/schemas/genai_azure_config'
|
||||||
- $ref: '#/components/schemas/genai_openai_config'
|
- $ref: '#/components/schemas/genai_openai_config'
|
||||||
|
- $ref: '#/components/schemas/genai_openai_other_config'
|
||||||
- $ref: '#/components/schemas/opsgenie_config'
|
- $ref: '#/components/schemas/opsgenie_config'
|
||||||
- $ref: '#/components/schemas/pagerduty_config'
|
- $ref: '#/components/schemas/pagerduty_config'
|
||||||
- $ref: '#/components/schemas/sentinelone_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.
|
The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts.
|
||||||
genai_secrets:
|
genai_secrets:
|
||||||
title: Connector secrets properties for an OpenAI connector
|
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
|
type: object
|
||||||
properties:
|
properties:
|
||||||
apiKey:
|
apiKey:
|
||||||
type: string
|
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:
|
opsgenie_secrets:
|
||||||
title: Connector secrets properties for an Opsgenie connector
|
title: Connector secrets properties for an Opsgenie connector
|
||||||
required:
|
required:
|
||||||
|
@ -74345,6 +74364,52 @@ components:
|
||||||
description: |
|
description: |
|
||||||
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
|
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
|
||||||
type: string
|
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:
|
defender_secrets:
|
||||||
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
|
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
|
||||||
required:
|
required:
|
||||||
|
|
|
@ -435,6 +435,7 @@ paths:
|
||||||
- $ref: '#/components/schemas/jira_config'
|
- $ref: '#/components/schemas/jira_config'
|
||||||
- $ref: '#/components/schemas/genai_azure_config'
|
- $ref: '#/components/schemas/genai_azure_config'
|
||||||
- $ref: '#/components/schemas/genai_openai_config'
|
- $ref: '#/components/schemas/genai_openai_config'
|
||||||
|
- $ref: '#/components/schemas/genai_openai_other_config'
|
||||||
- $ref: '#/components/schemas/opsgenie_config'
|
- $ref: '#/components/schemas/opsgenie_config'
|
||||||
- $ref: '#/components/schemas/pagerduty_config'
|
- $ref: '#/components/schemas/pagerduty_config'
|
||||||
- $ref: '#/components/schemas/sentinelone_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.
|
The URL of the incoming webhook. If you are using the `xpack.actions.allowedHosts` setting, add the hostname to the allowed hosts.
|
||||||
genai_secrets:
|
genai_secrets:
|
||||||
title: Connector secrets properties for an OpenAI connector
|
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
|
type: object
|
||||||
properties:
|
properties:
|
||||||
apiKey:
|
apiKey:
|
||||||
type: string
|
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:
|
opsgenie_secrets:
|
||||||
title: Connector secrets properties for an Opsgenie connector
|
title: Connector secrets properties for an Opsgenie connector
|
||||||
required:
|
required:
|
||||||
|
@ -84258,6 +84277,52 @@ components:
|
||||||
description: |
|
description: |
|
||||||
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
|
A password for HTTP basic authentication. It is applicable only when `usesBasic` is `true`.
|
||||||
type: string
|
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:
|
defender_secrets:
|
||||||
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
|
title: Connector secrets properties for a Microsoft Defender for Endpoint connector
|
||||||
required:
|
required:
|
||||||
|
|
|
@ -164,6 +164,8 @@ actions:
|
||||||
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_azure_config.yaml'
|
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_azure_config.yaml'
|
||||||
# OpenAI (.gen-ai)
|
# OpenAI (.gen-ai)
|
||||||
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/genai_openai_config.yaml'
|
- $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)
|
# Opsgenie (.opsgenie)
|
||||||
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/opsgenie_config.yaml'
|
- $ref: '../../x-pack/platform/plugins/shared/actions/docs/openapi/components/schemas/opsgenie_config.yaml'
|
||||||
# PagerDuty (.pagerduty)
|
# PagerDuty (.pagerduty)
|
||||||
|
|
|
@ -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
|
|
@ -1,7 +1,26 @@
|
||||||
title: Connector secrets properties for an OpenAI connector
|
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
|
type: object
|
||||||
properties:
|
properties:
|
||||||
apiKey:
|
apiKey:
|
||||||
type: string
|
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: []
|
||||||
|
|
|
@ -23,6 +23,7 @@ export type {
|
||||||
ActionType,
|
ActionType,
|
||||||
InMemoryConnector,
|
InMemoryConnector,
|
||||||
ActionsApiRequestHandlerContext,
|
ActionsApiRequestHandlerContext,
|
||||||
|
SSLSettings,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export type { ConnectorWithExtraFindData as FindActionResult } from './application/connector/types';
|
export type { ConnectorWithExtraFindData as FindActionResult } from './application/connector/types';
|
||||||
|
|
|
@ -68,7 +68,8 @@ export function getCustomAgents(
|
||||||
// This is where the global rejectUnauthorized is overridden by a custom host
|
// This is where the global rejectUnauthorized is overridden by a custom host
|
||||||
const customHostNodeSSLOptions = getNodeSSLOptions(
|
const customHostNodeSSLOptions = getNodeSSLOptions(
|
||||||
logger,
|
logger,
|
||||||
sslSettingsFromConfig.verificationMode
|
sslSettingsFromConfig.verificationMode,
|
||||||
|
sslOverrides
|
||||||
);
|
);
|
||||||
if (customHostNodeSSLOptions.rejectUnauthorized !== undefined) {
|
if (customHostNodeSSLOptions.rejectUnauthorized !== undefined) {
|
||||||
agentOptions.rejectUnauthorized = customHostNodeSSLOptions.rejectUnauthorized;
|
agentOptions.rejectUnauthorized = customHostNodeSSLOptions.rejectUnauthorized;
|
||||||
|
@ -116,7 +117,8 @@ export function getCustomAgents(
|
||||||
|
|
||||||
const proxyNodeSSLOptions = getNodeSSLOptions(
|
const proxyNodeSSLOptions = getNodeSSLOptions(
|
||||||
logger,
|
logger,
|
||||||
proxySettings.proxySSLSettings.verificationMode
|
proxySettings.proxySSLSettings.verificationMode,
|
||||||
|
sslOverrides
|
||||||
);
|
);
|
||||||
// At this point, we are going to use a proxy, so we need new agents.
|
// 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
|
// We will though, copy over the calculated ssl options from above, into
|
||||||
|
|
|
@ -16,6 +16,7 @@ import type {
|
||||||
ActionTypeParams,
|
ActionTypeParams,
|
||||||
RenderParameterTemplates,
|
RenderParameterTemplates,
|
||||||
Services,
|
Services,
|
||||||
|
SSLSettings,
|
||||||
ValidatorType as ValidationSchema,
|
ValidatorType as ValidationSchema,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { SubFeature } from '../../common';
|
import type { SubFeature } from '../../common';
|
||||||
|
@ -41,6 +42,7 @@ export type SubActionRequestParams<R> = {
|
||||||
url: string;
|
url: string;
|
||||||
responseSchema: Type<R>;
|
responseSchema: Type<R>;
|
||||||
method?: Method;
|
method?: Method;
|
||||||
|
sslOverrides?: SSLSettings;
|
||||||
} & AxiosRequestConfig;
|
} & AxiosRequestConfig;
|
||||||
|
|
||||||
export type IService<Config, Secrets> = new (
|
export type IService<Config, Secrets> = new (
|
||||||
|
|
|
@ -397,4 +397,101 @@ describe('ConnectorFields renders', () => {
|
||||||
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
SimpleConnectorForm,
|
SimpleConnectorForm,
|
||||||
} from '@kbn/triggers-actions-ui-plugin/public';
|
} from '@kbn/triggers-actions-ui-plugin/public';
|
||||||
import {
|
import {
|
||||||
|
FilePickerField,
|
||||||
SelectField,
|
SelectField,
|
||||||
TextField,
|
TextField,
|
||||||
ToggleField,
|
ToggleField,
|
||||||
|
@ -39,21 +40,27 @@ import {
|
||||||
azureAiConfig,
|
azureAiConfig,
|
||||||
azureAiSecrets,
|
azureAiSecrets,
|
||||||
otherOpenAiConfig,
|
otherOpenAiConfig,
|
||||||
otherOpenAiSecrets,
|
getOtherOpenAiSecrets,
|
||||||
openAiSecrets,
|
openAiSecrets,
|
||||||
providerOptions,
|
providerOptions,
|
||||||
openAiConfig,
|
openAiConfig,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
import { CRT_REQUIRED, KEY_REQUIRED } from '../../common/auth/translations';
|
||||||
|
|
||||||
const { emptyField } = fieldValidators;
|
const { emptyField } = fieldValidators;
|
||||||
|
|
||||||
const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdit }) => {
|
const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdit }) => {
|
||||||
const { euiTheme } = useEuiTheme();
|
const { euiTheme } = useEuiTheme();
|
||||||
const { getFieldDefaultValue } = useFormContext();
|
const { getFieldDefaultValue } = useFormContext();
|
||||||
const [{ config, __internal__, id, name }] = useFormData({
|
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 hasHeaders = __internal__ != null ? __internal__.hasHeaders : false;
|
||||||
const hasHeadersDefaultValue = !!getFieldDefaultValue<boolean | undefined>('config.headers');
|
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(
|
const selectedProviderDefaultValue = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -61,6 +68,14 @@ const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdi
|
||||||
[getFieldDefaultValue]
|
[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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<UseField
|
<UseField
|
||||||
|
@ -105,12 +120,112 @@ const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdi
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{config != null && config.apiProvider === OpenAiProviderType.Other && (
|
{config != null && config.apiProvider === OpenAiProviderType.Other && (
|
||||||
<SimpleConnectorForm
|
<>
|
||||||
isEdit={isEdit}
|
<SimpleConnectorForm
|
||||||
readOnly={readOnly}
|
isEdit={isEdit}
|
||||||
configFormSchema={otherOpenAiConfig}
|
readOnly={readOnly}
|
||||||
secretsFormSchema={otherOpenAiSecrets}
|
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
|
<UseField
|
||||||
path="__internal__.hasHeaders"
|
path="__internal__.hasHeaders"
|
||||||
|
@ -173,7 +288,7 @@ const ConnectorFields: React.FC<ActionConnectorFieldsProps> = ({ readOnly, isEdi
|
||||||
onClick={() => removeItem(item.id)}
|
onClick={() => removeItem(item.id)}
|
||||||
iconType="minusInCircle"
|
iconType="minusInCircle"
|
||||||
aria-label={i18nAuth.DELETE_BUTTON}
|
aria-label={i18nAuth.DELETE_BUTTON}
|
||||||
style={{ marginTop: '28px' }}
|
css={{ marginTop: '28px' }}
|
||||||
/>
|
/>
|
||||||
</EuiFlexItem>
|
</EuiFlexItem>
|
||||||
</EuiFlexGroup>
|
</EuiFlexGroup>
|
||||||
|
|
|
@ -243,11 +243,12 @@ export const azureAiSecrets: SecretsFieldSchema[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const otherOpenAiSecrets: SecretsFieldSchema[] = [
|
export const getOtherOpenAiSecrets = (isRequired = true): SecretsFieldSchema[] => [
|
||||||
{
|
{
|
||||||
id: 'apiKey',
|
id: 'apiKey',
|
||||||
label: i18n.API_KEY_LABEL,
|
label: i18n.API_KEY_LABEL,
|
||||||
isPasswordField: true,
|
isPasswordField: true,
|
||||||
|
isRequired,
|
||||||
helpText: (
|
helpText: (
|
||||||
<FormattedMessage
|
<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}."
|
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}."
|
||||||
|
|
|
@ -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(
|
export const URL_LABEL = i18n.translate(
|
||||||
'xpack.stackConnectors.components.genAi.urlTextFieldLabel',
|
'xpack.stackConnectors.components.genAi.urlTextFieldLabel',
|
||||||
{
|
{
|
||||||
|
@ -118,3 +182,18 @@ export const USAGE_DASHBOARD_LINK = (apiProvider: string, connectorName: string)
|
||||||
values: { apiProvider, connectorName },
|
values: { apiProvider, connectorName },
|
||||||
defaultMessage: 'View {apiProvider} Usage Dashboard for "{ connectorName }" Connector',
|
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.',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -19,6 +19,16 @@ export interface Config {
|
||||||
apiProvider: OpenAiProviderType;
|
apiProvider: OpenAiProviderType;
|
||||||
apiUrl: string;
|
apiUrl: string;
|
||||||
defaultModel?: 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 {
|
export interface Secrets {
|
||||||
|
|
|
@ -5,7 +5,16 @@
|
||||||
* 2.0.
|
* 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('Other (OpenAI Compatible Service) Utils', () => {
|
||||||
describe('sanitizeRequest', () => {
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,6 +5,9 @@
|
||||||
* 2.0.
|
* 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';
|
import type { Secrets } from '../../../../common/openai/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -46,8 +49,204 @@ export const getRequestWithStreamOption = (
|
||||||
return body;
|
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 => {
|
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.
|
* Validates the apiKey in the secrets object for non-PKI authentication.
|
||||||
|
|
|
@ -241,6 +241,19 @@ describe('OpenAIConnector', () => {
|
||||||
connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }, connectorUsageCollector)
|
connector.runApi({ body: JSON.stringify(sampleOpenAiBody) }, connectorUsageCollector)
|
||||||
).rejects.toThrow('API Error');
|
).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', () => {
|
describe('streamApi', () => {
|
||||||
|
@ -1506,6 +1519,155 @@ describe('OpenAIConnector', () => {
|
||||||
expect(response).toEqual({ available: false });
|
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() {
|
function createStreamMock() {
|
||||||
|
|
|
@ -5,12 +5,13 @@
|
||||||
* 2.0.
|
* 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 { SubActionConnector } from '@kbn/actions-plugin/server';
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
import OpenAI from 'openai';
|
import OpenAI from 'openai';
|
||||||
import { PassThrough } from 'stream';
|
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 {
|
import type {
|
||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
ChatCompletionCreateParamsStreaming,
|
ChatCompletionCreateParamsStreaming,
|
||||||
|
@ -55,13 +56,15 @@ import {
|
||||||
pipeStreamingResponse,
|
pipeStreamingResponse,
|
||||||
sanitizeRequest,
|
sanitizeRequest,
|
||||||
} from './lib/utils';
|
} from './lib/utils';
|
||||||
|
import { getPKISSLOverrides, pkiErrorHandler } from './lib/other_openai_utils';
|
||||||
|
|
||||||
export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
|
export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
|
||||||
private url;
|
private url: string;
|
||||||
private provider;
|
private provider: OpenAiProviderType;
|
||||||
private key;
|
private key: string;
|
||||||
private openAI;
|
private openAI: OpenAI;
|
||||||
private headers;
|
private headers: Record<string, string>;
|
||||||
|
private sslOverrides?: SSLSettings;
|
||||||
|
|
||||||
constructor(params: ServiceParams<Config, Secrets>) {
|
constructor(params: ServiceParams<Config, Secrets>) {
|
||||||
super(params);
|
super(params);
|
||||||
|
@ -77,36 +80,88 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
|
||||||
...('projectId' in this.config ? { 'OpenAI-Project': this.config.projectId } : {}),
|
...('projectId' in this.config ? { 'OpenAI-Project': this.config.projectId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { httpAgent, httpsAgent } = getCustomAgents(
|
try {
|
||||||
this.configurationUtilities,
|
let httpAgent;
|
||||||
this.logger,
|
let baseURL = this.config.apiUrl;
|
||||||
this.url
|
const defaultHeaders = { ...this.headers };
|
||||||
);
|
let defaultQuery: Record<string, string> | undefined;
|
||||||
|
|
||||||
this.openAI =
|
if (
|
||||||
this.config.apiProvider === OpenAiProviderType.AzureAi
|
this.provider === OpenAiProviderType.Other &&
|
||||||
? new OpenAI({
|
(('certificateData' in this.secrets && this.secrets.certificateData) ||
|
||||||
apiKey: this.key,
|
('caData' in this.secrets && this.secrets.caData) ||
|
||||||
baseURL: this.config.apiUrl,
|
('privateKeyData' in this.secrets && this.secrets.privateKeyData)) &&
|
||||||
defaultQuery: { 'api-version': getAzureApiVersionParameter(this.config.apiUrl) },
|
'verificationMode' in this.config &&
|
||||||
defaultHeaders: {
|
this.config.verificationMode
|
||||||
...this.headers,
|
) {
|
||||||
'api-key': this.key,
|
this.sslOverrides = getPKISSLOverrides({
|
||||||
},
|
logger: this.logger,
|
||||||
httpAgent: httpsAgent ?? httpAgent,
|
// ! These two values must be defined for PKI use. If undefined, will throw error
|
||||||
})
|
certificateData: this.secrets.certificateData!,
|
||||||
: new OpenAI({
|
privateKeyData: this.secrets.privateKeyData!,
|
||||||
baseURL: removeEndpointFromUrl(this.config.apiUrl),
|
caData: this.secrets.caData,
|
||||||
apiKey: this.key,
|
verificationMode: this.config.verificationMode,
|
||||||
defaultHeaders: {
|
});
|
||||||
...this.headers,
|
const agents = getCustomAgents(
|
||||||
},
|
this.configurationUtilities,
|
||||||
httpAgent: httpsAgent ?? httpAgent,
|
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();
|
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() {
|
private registerSubActions() {
|
||||||
this.registerSubAction({
|
this.registerSubAction({
|
||||||
name: SUB_ACTION.RUN,
|
name: SUB_ACTION.RUN,
|
||||||
|
@ -169,11 +224,11 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
|
||||||
}
|
}
|
||||||
return `API Error: ${error.response?.statusText}${errorMessage ? ` - ${errorMessage}` : ''}`;
|
return `API Error: ${error.response?.statusText}${errorMessage ? ` - ${errorMessage}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* responsible for making a POST request to the external API endpoint and returning the response data
|
* 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.
|
* @param body The stringified request body to be sent in the POST request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public async runApi(
|
public async runApi(
|
||||||
{ body, signal, timeout }: RunActionParams,
|
{ body, signal, timeout }: RunActionParams,
|
||||||
connectorUsageCollector: ConnectorUsageCollector
|
connectorUsageCollector: ConnectorUsageCollector
|
||||||
|
@ -185,24 +240,36 @@ export class OpenAIConnector extends SubActionConnector<Config, Secrets> {
|
||||||
...('defaultModel' in this.config ? [this.config.defaultModel] : [])
|
...('defaultModel' in this.config ? [this.config.defaultModel] : [])
|
||||||
);
|
);
|
||||||
const axiosOptions = getAxiosOptions(this.provider, this.key, false);
|
const axiosOptions = getAxiosOptions(this.provider, this.key, false);
|
||||||
const response = await this.request(
|
|
||||||
{
|
try {
|
||||||
url: this.url,
|
const response = await this.request(
|
||||||
method: 'post',
|
{
|
||||||
responseSchema: RunActionResponseSchema,
|
url: this.url,
|
||||||
data: sanitizedBody,
|
method: 'post',
|
||||||
signal,
|
responseSchema: RunActionResponseSchema,
|
||||||
// give up to 2 minutes for response
|
data: sanitizedBody,
|
||||||
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
|
signal,
|
||||||
...axiosOptions,
|
// give up to 2 minutes for response
|
||||||
headers: {
|
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
|
||||||
...this.headers,
|
...axiosOptions,
|
||||||
...axiosOptions.headers,
|
headers: {
|
||||||
|
...this.headers,
|
||||||
|
...axiosOptions.headers,
|
||||||
|
},
|
||||||
|
sslOverrides: this.sslOverrides,
|
||||||
},
|
},
|
||||||
},
|
connectorUsageCollector
|
||||||
connectorUsageCollector
|
);
|
||||||
);
|
|
||||||
return response.data;
|
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 axiosOptions = getAxiosOptions(this.provider, this.key, stream);
|
||||||
|
try {
|
||||||
const response = await this.request(
|
const response = await this.request(
|
||||||
{
|
{
|
||||||
url: this.url,
|
url: this.url,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema,
|
responseSchema: stream ? StreamingResponseSchema : RunActionResponseSchema,
|
||||||
data: executeBody,
|
data: executeBody,
|
||||||
signal,
|
signal,
|
||||||
...axiosOptions,
|
...axiosOptions,
|
||||||
headers: {
|
headers: {
|
||||||
...this.headers,
|
...this.headers,
|
||||||
...axiosOptions.headers,
|
...axiosOptions.headers,
|
||||||
|
},
|
||||||
|
timeout,
|
||||||
|
sslOverrides: this.sslOverrides,
|
||||||
},
|
},
|
||||||
timeout,
|
connectorUsageCollector
|
||||||
},
|
);
|
||||||
connectorUsageCollector
|
|
||||||
);
|
return stream ? pipeStreamingResponse(response) : response.data;
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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', () => {
|
describe('executor', () => {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue