[Security solution] Implement dashboard to track Gen AI Token Usage (#159075)

This commit is contained in:
Steph Milovic 2023-06-15 13:05:39 -06:00 committed by GitHub
parent 03f86a9450
commit 6c80b49a15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1475 additions and 222 deletions

View file

@ -9,6 +9,8 @@ import { isPlainObject, isEmpty } from 'lodash';
import { Type } from '@kbn/config-schema';
import { Logger } from '@kbn/logging';
import axios, { AxiosInstance, AxiosResponse, AxiosError, AxiosRequestHeaders } from 'axios';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { assertURL } from './helpers/validators';
import { ActionsConfigurationUtilities } from '../actions_config';
import { SubAction, SubActionRequestParams } from './types';
@ -28,6 +30,8 @@ export abstract class SubActionConnector<Config, Secrets> {
private subActions: Map<string, SubAction> = new Map();
private configurationUtilities: ActionsConfigurationUtilities;
protected logger: Logger;
protected esClient: ElasticsearchClient;
protected savedObjectsClient: SavedObjectsClientContract;
protected connector: ServiceParams<Config, Secrets>['connector'];
protected config: Config;
protected secrets: Secrets;
@ -37,6 +41,8 @@ export abstract class SubActionConnector<Config, Secrets> {
this.logger = params.logger;
this.config = params.config;
this.secrets = params.secrets;
this.savedObjectsClient = params.services.savedObjectsClient;
this.esClient = params.services.scopedClusterClient;
this.configurationUtilities = params.configurationUtilities;
this.axiosInstance = axios.create();
}

View file

@ -36,6 +36,8 @@
"@kbn/core-http-server-mocks",
"@kbn/tinymath",
"@kbn/core-saved-objects-utils-server",
"@kbn/core-saved-objects-api-server",
"@kbn/core-elasticsearch-server",
],
"exclude": [
"target/**/*",

View file

@ -16,6 +16,7 @@ export const GEN_AI_TITLE = i18n.translate(
export const GEN_AI_CONNECTOR_ID = '.gen-ai';
export enum SUB_ACTION {
RUN = 'run',
DASHBOARD = 'getDashboard',
TEST = 'test',
}
export enum OpenAiProviderType {

View file

@ -44,3 +44,12 @@ export const GenAiRunActionResponseSchema = schema.object(
},
{ unknowns: 'ignore' }
);
// Run action schema
export const GenAiDashboardActionParamsSchema = schema.object({
dashboardId: schema.string(),
});
export const GenAiDashboardActionResponseSchema = schema.object({
available: schema.boolean(),
});

View file

@ -11,9 +11,13 @@ import {
GenAiSecretsSchema,
GenAiRunActionParamsSchema,
GenAiRunActionResponseSchema,
GenAiDashboardActionParamsSchema,
GenAiDashboardActionResponseSchema,
} from './schema';
export type GenAiConfig = TypeOf<typeof GenAiConfigSchema>;
export type GenAiSecrets = TypeOf<typeof GenAiSecretsSchema>;
export type GenAiRunActionParams = TypeOf<typeof GenAiRunActionParamsSchema>;
export type GenAiRunActionResponse = TypeOf<typeof GenAiRunActionResponseSchema>;
export type GenAiDashboardActionParams = TypeOf<typeof GenAiDashboardActionParamsSchema>;
export type GenAiDashboardActionResponse = TypeOf<typeof GenAiDashboardActionResponseSchema>;

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { getDashboard } from './api';
import { SUB_ACTION } from '../../../common/gen_ai/constants';
const response = {
available: true,
};
describe('Gen AI Dashboard API', () => {
const http = httpServiceMock.createStartContract();
beforeEach(() => jest.resetAllMocks());
describe('getDashboard', () => {
test('should call get dashboard API', async () => {
const abortCtrl = new AbortController();
http.post.mockResolvedValueOnce(response);
const res = await getDashboard({
http,
signal: abortCtrl.signal,
connectorId: 'te/st',
dashboardId: 'cool-dashboard',
});
expect(res).toEqual(response);
expect(http.post).toHaveBeenCalledWith('/api/actions/connector/te%2Fst/_execute', {
body: `{"params":{"subAction":"${SUB_ACTION.DASHBOARD}","subActionParams":{"dashboardId":"cool-dashboard"}}}`,
signal: abortCtrl.signal,
});
});
});
});

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { HttpSetup } from '@kbn/core-http-browser';
import { ActionTypeExecutorResult, BASE_ACTION_API_PATH } from '@kbn/actions-plugin/common';
import { SUB_ACTION } from '../../../common/gen_ai/constants';
import { ConnectorExecutorResult, rewriteResponseToCamelCase } from '../lib/rewrite_response_body';
export async function getDashboard({
http,
signal,
dashboardId,
connectorId,
}: {
http: HttpSetup;
signal: AbortSignal;
connectorId: string;
dashboardId: string;
}): Promise<ActionTypeExecutorResult<{ available: boolean }>> {
const res = await http.post<ConnectorExecutorResult<{ available: boolean }>>(
`${BASE_ACTION_API_PATH}/connector/${encodeURIComponent(connectorId)}/_execute`,
{
body: JSON.stringify({
params: { subAction: SUB_ACTION.DASHBOARD, subActionParams: { dashboardId } },
}),
signal,
}
);
return rewriteResponseToCamelCase(res);
}

View file

@ -8,27 +8,54 @@
import React from 'react';
import GenerativeAiConnectorFields from './connector';
import { ConnectorFormTestProvider } from '../lib/test_utils';
import { act, render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OpenAiProviderType } from '../../../common/gen_ai/constants';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import { useGetDashboard } from './use_get_dashboard';
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
jest.mock('./use_get_dashboard');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const mockDashboard = useGetDashboard as jest.Mock;
const openAiConnector = {
actionTypeId: '.gen-ai',
name: 'genAi',
id: '123',
config: {
apiUrl: 'https://openaiurl.com',
apiProvider: OpenAiProviderType.OpenAi,
},
secrets: {
apiKey: 'thats-a-nice-looking-key',
},
isDeprecated: false,
};
const azureConnector = {
...openAiConnector,
config: {
apiUrl: 'https://azureaiurl.com',
apiProvider: OpenAiProviderType.AzureAi,
},
secrets: {
apiKey: 'thats-a-nice-looking-key',
},
};
const navigateToUrl = jest.fn();
describe('GenerativeAiConnectorFields renders', () => {
beforeEach(() => {
jest.clearAllMocks();
useKibanaMock().services.application.navigateToUrl = navigateToUrl;
mockDashboard.mockImplementation(({ connectorId }) => ({
dashboardUrl: `https://dashboardurl.com/${connectorId}`,
}));
});
test('open ai connector fields are rendered', async () => {
const actionConnector = {
actionTypeId: '.gen-ai',
name: 'genAi',
config: {
apiUrl: 'https://openaiurl.com',
apiProvider: OpenAiProviderType.OpenAi,
},
secrets: {
apiKey: 'thats-a-nice-looking-key',
},
isDeprecated: false,
};
const { getAllByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector}>
<ConnectorFormTestProvider connector={openAiConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
@ -37,31 +64,18 @@ describe('GenerativeAiConnectorFields renders', () => {
</ConnectorFormTestProvider>
);
expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument();
expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl);
expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(openAiConnector.config.apiUrl);
expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument();
expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue(
actionConnector.config.apiProvider
openAiConnector.config.apiProvider
);
expect(getAllByTestId('open-ai-api-doc')[0]).toBeInTheDocument();
expect(getAllByTestId('open-ai-api-keys-doc')[0]).toBeInTheDocument();
});
test('azure ai connector fields are rendered', async () => {
const actionConnector = {
actionTypeId: '.gen-ai',
name: 'genAi',
config: {
apiUrl: 'https://azureaiurl.com',
apiProvider: OpenAiProviderType.AzureAi,
},
secrets: {
apiKey: 'thats-a-nice-looking-key',
},
isDeprecated: false,
};
const { getAllByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector}>
<ConnectorFormTestProvider connector={azureConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
@ -69,31 +83,72 @@ describe('GenerativeAiConnectorFields renders', () => {
/>
</ConnectorFormTestProvider>
);
expect(getAllByTestId('config.apiUrl-input')[0]).toBeInTheDocument();
expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(actionConnector.config.apiUrl);
expect(getAllByTestId('config.apiUrl-input')[0]).toHaveValue(azureConnector.config.apiUrl);
expect(getAllByTestId('config.apiProvider-select')[0]).toBeInTheDocument();
expect(getAllByTestId('config.apiProvider-select')[0]).toHaveValue(
actionConnector.config.apiProvider
azureConnector.config.apiProvider
);
expect(getAllByTestId('azure-ai-api-doc')[0]).toBeInTheDocument();
expect(getAllByTestId('azure-ai-api-keys-doc')[0]).toBeInTheDocument();
});
describe('Dashboard link', () => {
it('Does not render if isEdit is false and dashboardUrl is defined', async () => {
const { queryByTestId } = render(
<ConnectorFormTestProvider connector={openAiConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument();
});
it('Does not render if isEdit is true and dashboardUrl is null', async () => {
mockDashboard.mockImplementation((id: string) => ({
dashboardUrl: null,
}));
const { queryByTestId } = render(
<ConnectorFormTestProvider connector={openAiConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
expect(queryByTestId('link-gen-ai-token-dashboard')).not.toBeInTheDocument();
});
it('Renders if isEdit is true and dashboardUrl is defined', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={openAiConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={true}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
expect(getByTestId('link-gen-ai-token-dashboard')).toBeInTheDocument();
});
it('On click triggers redirect with correct saved object id', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={openAiConnector}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={true}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
fireEvent.click(getByTestId('link-gen-ai-token-dashboard'));
expect(navigateToUrl).toHaveBeenCalledWith(`https://dashboardurl.com/123`);
});
});
describe('Validation', () => {
const onSubmit = jest.fn();
const actionConnector = {
actionTypeId: '.gen-ai',
name: 'genAi',
config: {
apiUrl: 'https://openaiurl.com',
apiProvider: OpenAiProviderType.OpenAi,
},
secrets: {
apiKey: 'thats-a-nice-looking-key',
},
isDeprecated: false,
};
beforeEach(() => {
jest.clearAllMocks();
@ -101,7 +156,7 @@ describe('GenerativeAiConnectorFields renders', () => {
it('connector validation succeeds when connector config is valid', async () => {
const { getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<ConnectorFormTestProvider connector={openAiConnector} onSubmit={onSubmit}>
<GenerativeAiConnectorFields
readOnly={false}
isEdit={false}
@ -119,16 +174,16 @@ describe('GenerativeAiConnectorFields renders', () => {
});
expect(onSubmit).toBeCalledWith({
data: actionConnector,
data: openAiConnector,
isValid: true,
});
});
it('validates correctly if the apiUrl is empty', async () => {
const connector = {
...actionConnector,
...openAiConnector,
config: {
...actionConnector.config,
...openAiConnector.config,
apiUrl: '',
},
};
@ -159,9 +214,9 @@ describe('GenerativeAiConnectorFields renders', () => {
];
it.each(tests)('validates correctly %p', async (field, value) => {
const connector = {
...actionConnector,
...openAiConnector,
config: {
...actionConnector.config,
...openAiConnector.config,
headers: [],
},
};

View file

@ -5,151 +5,59 @@
* 2.0.
*/
import React, { useMemo } from 'react';
import React, { useCallback, useMemo } from 'react';
import {
ActionConnectorFieldsProps,
ConfigFieldSchema,
SecretsFieldSchema,
SimpleConnectorForm,
} from '@kbn/triggers-actions-ui-plugin/public';
import { SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { EuiLink, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import {
UseField,
useFormContext,
useFormData,
} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { useGetDashboard } from './use_get_dashboard';
import { OpenAiProviderType } from '../../../common/gen_ai/constants';
import * as i18n from './translations';
import { DEFAULT_URL, DEFAULT_URL_AZURE } from './constants';
import {
azureAiConfig,
azureAiSecrets,
openAiConfig,
openAiSecrets,
providerOptions,
} from './constants';
const { emptyField } = fieldValidators;
const openAiConfig: ConfigFieldSchema[] = [
{
id: 'apiUrl',
label: i18n.API_URL_LABEL,
isUrlField: true,
defaultValue: DEFAULT_URL,
helpText: (
<FormattedMessage
defaultMessage="The OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
id="xpack.stackConnectors.components.genAi.openAiDocumentation"
values={{
genAiAPIUrlDocs: (
<EuiLink
data-test-subj="open-ai-api-doc"
href="https://platform.openai.com/docs/api-reference"
target="_blank"
>
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
const azureAiConfig: ConfigFieldSchema[] = [
{
id: 'apiUrl',
label: i18n.API_URL_LABEL,
isUrlField: true,
defaultValue: DEFAULT_URL_AZURE,
helpText: (
<FormattedMessage
defaultMessage="The Azure OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
id="xpack.stackConnectors.components.genAi.azureAiDocumentation"
values={{
genAiAPIUrlDocs: (
<EuiLink
data-test-subj="azure-ai-api-doc"
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference"
target="_blank"
>
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
const openAiSecrets: SecretsFieldSchema[] = [
{
id: 'apiKey',
label: i18n.API_KEY_LABEL,
isPasswordField: true,
helpText: (
<FormattedMessage
defaultMessage="The OpenAI API authentication key for HTTP Basic authentication. For more details about generating OpenAI API keys, refer to the {genAiAPIKeyDocs}."
id="xpack.stackConnectors.components.genAi.openAiApiKeyDocumentation"
values={{
genAiAPIKeyDocs: (
<EuiLink
data-test-subj="open-ai-api-keys-doc"
href="https://platform.openai.com/account/api-keys"
target="_blank"
>
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
const azureAiSecrets: SecretsFieldSchema[] = [
{
id: 'apiKey',
label: i18n.API_KEY_LABEL,
isPasswordField: true,
helpText: (
<FormattedMessage
defaultMessage="The Azure API key for HTTP Basic authentication. For more details about generating Azure OpenAI API keys, refer to the {genAiAPIKeyDocs}."
id="xpack.stackConnectors.components.genAi.azureAiApiKeyDocumentation"
values={{
genAiAPIKeyDocs: (
<EuiLink
data-test-subj="azure-ai-api-keys-doc"
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference#authentication"
target="_blank"
>
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
const providerOptions = [
{
value: OpenAiProviderType.OpenAi,
text: i18n.OPEN_AI,
label: i18n.OPEN_AI,
},
{
value: OpenAiProviderType.AzureAi,
text: i18n.AZURE_AI,
label: i18n.AZURE_AI,
},
];
const GenerativeAiConnectorFields: React.FC<ActionConnectorFieldsProps> = ({
readOnly,
isEdit,
}) => {
const { getFieldDefaultValue } = useFormContext();
const [{ config }] = useFormData({
const [{ config, id, name }] = useFormData({
watch: ['config.apiProvider'],
});
const {
services: {
application: { navigateToUrl },
},
} = useKibana();
const { dashboardUrl } = useGetDashboard({ connectorId: id });
const onClick = useCallback(
(e) => {
e.preventDefault();
if (dashboardUrl) {
navigateToUrl(dashboardUrl);
}
},
[dashboardUrl, navigateToUrl]
);
const selectedProviderDefaultValue = useMemo(
() =>
getFieldDefaultValue<OpenAiProviderType>('config.apiProvider') ?? OpenAiProviderType.OpenAi,
@ -199,6 +107,11 @@ const GenerativeAiConnectorFields: React.FC<ActionConnectorFieldsProps> = ({
secretsFormSchema={azureAiSecrets}
/>
)}
{isEdit && dashboardUrl != null && (
<EuiLink data-test-subj="link-gen-ai-token-dashboard" onClick={onClick}>
{i18n.USAGE_DASHBOARD_LINK(selectedProviderDefaultValue, name)}
</EuiLink>
)}
</>
);
};

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const DEFAULT_URL = 'https://api.openai.com/v1/chat/completions' as const;
export const DEFAULT_URL_AZURE =
'https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/chat/completions?api-version={api-version}' as const;
export const DEFAULT_BODY = `{
"model":"gpt-3.5-turbo",
"messages": [{
"role":"user",
"content":"Hello world"
}]
}`;
export const DEFAULT_BODY_AZURE = `{
"messages": [{
"role":"user",
"content":"Hello world"
}]
}`;

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ConfigFieldSchema, SecretsFieldSchema } from '@kbn/triggers-actions-ui-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink } from '@elastic/eui';
import { OpenAiProviderType } from '../../../common/gen_ai/constants';
import * as i18n from './translations';
export const DEFAULT_URL = 'https://api.openai.com/v1/chat/completions' as const;
export const DEFAULT_URL_AZURE =
'https://{your-resource-name}.openai.azure.com/openai/deployments/{deployment-id}/chat/completions?api-version={api-version}' as const;
export const DEFAULT_BODY = `{
"model":"gpt-3.5-turbo",
"messages": [{
"role":"user",
"content":"Hello world"
}]
}`;
export const DEFAULT_BODY_AZURE = `{
"messages": [{
"role":"user",
"content":"Hello world"
}]
}`;
export const openAiConfig: ConfigFieldSchema[] = [
{
id: 'apiUrl',
label: i18n.API_URL_LABEL,
isUrlField: true,
defaultValue: DEFAULT_URL,
helpText: (
<FormattedMessage
defaultMessage="The OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
id="xpack.stackConnectors.components.genAi.openAiDocumentation"
values={{
genAiAPIUrlDocs: (
<EuiLink
data-test-subj="open-ai-api-doc"
href="https://platform.openai.com/docs/api-reference"
target="_blank"
>
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
export const azureAiConfig: ConfigFieldSchema[] = [
{
id: 'apiUrl',
label: i18n.API_URL_LABEL,
isUrlField: true,
defaultValue: DEFAULT_URL_AZURE,
helpText: (
<FormattedMessage
defaultMessage="The Azure OpenAI API endpoint URL. For more information on the URL, refer to the {genAiAPIUrlDocs}."
id="xpack.stackConnectors.components.genAi.azureAiDocumentation"
values={{
genAiAPIUrlDocs: (
<EuiLink
data-test-subj="azure-ai-api-doc"
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference"
target="_blank"
>
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
export const openAiSecrets: SecretsFieldSchema[] = [
{
id: 'apiKey',
label: i18n.API_KEY_LABEL,
isPasswordField: true,
helpText: (
<FormattedMessage
defaultMessage="The OpenAI API authentication key for HTTP Basic authentication. For more details about generating OpenAI API keys, refer to the {genAiAPIKeyDocs}."
id="xpack.stackConnectors.components.genAi.openAiApiKeyDocumentation"
values={{
genAiAPIKeyDocs: (
<EuiLink
data-test-subj="open-ai-api-keys-doc"
href="https://platform.openai.com/account/api-keys"
target="_blank"
>
{`${i18n.OPEN_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
export const azureAiSecrets: SecretsFieldSchema[] = [
{
id: 'apiKey',
label: i18n.API_KEY_LABEL,
isPasswordField: true,
helpText: (
<FormattedMessage
defaultMessage="The Azure API key for HTTP Basic authentication. For more details about generating Azure OpenAI API keys, refer to the {genAiAPIKeyDocs}."
id="xpack.stackConnectors.components.genAi.azureAiApiKeyDocumentation"
values={{
genAiAPIKeyDocs: (
<EuiLink
data-test-subj="azure-ai-api-keys-doc"
href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference#authentication"
target="_blank"
>
{`${i18n.AZURE_AI} ${i18n.DOCUMENTATION}`}
</EuiLink>
),
}}
/>
),
},
];
export const providerOptions = [
{
value: OpenAiProviderType.OpenAi,
text: i18n.OPEN_AI,
label: i18n.OPEN_AI,
},
{
value: OpenAiProviderType.AzureAi,
text: i18n.AZURE_AI,
label: i18n.AZURE_AI,
},
];
export const getDashboardId = (spaceId: string): string => `generative-ai-token-usage-${spaceId}`;

View file

@ -33,14 +33,12 @@ const messageVariables = [
describe('Gen AI Params Fields renders', () => {
test('all params fields are rendered', () => {
const actionParams = {
subAction: SUB_ACTION.RUN,
subActionParams: { body: '{"message": "test"}' },
};
const { getByTestId } = render(
<GenerativeAiParamsFields
actionParams={actionParams}
actionParams={{
subAction: SUB_ACTION.RUN,
subActionParams: { body: '{"message": "test"}' },
}}
errors={{ body: [] }}
editAction={() => {}}
index={0}
@ -118,17 +116,16 @@ describe('Gen AI Params Fields renders', () => {
});
it('calls editAction function with the correct arguments ', () => {
const actionParams = {
subAction: SUB_ACTION.RUN,
subActionParams: {
body: '{"key": "value"}',
},
};
const editAction = jest.fn();
const errors = {};
const { getByTestId } = render(
<GenerativeAiParamsFields
actionParams={actionParams}
actionParams={{
subAction: SUB_ACTION.RUN,
subActionParams: {
body: '{"key": "value"}',
},
}}
editAction={editAction}
index={0}
messageVariables={messageVariables}

View file

@ -18,13 +18,6 @@ export const API_KEY_LABEL = i18n.translate('xpack.stackConnectors.components.ge
defaultMessage: 'API Key',
});
export const API_PROVIDER_HEADING = i18n.translate(
'xpack.stackConnectors.components.genAi.providerHeading',
{
defaultMessage: 'OpenAI provider',
}
);
export const API_PROVIDER_LABEL = i18n.translate(
'xpack.stackConnectors.components.genAi.apiProviderLabel',
{
@ -87,3 +80,16 @@ export const API_PROVIDER_REQUIRED = i18n.translate(
defaultMessage: 'API provider is required.',
}
);
export const USAGE_DASHBOARD_LINK = (apiProvider: string, connectorName: string) =>
i18n.translate('xpack.stackConnectors.components.genAi.dashboardLink', {
values: { apiProvider, connectorName },
defaultMessage: 'View {apiProvider} Usage Dashboard for "{ connectorName }" Connector',
});
export const GET_DASHBOARD_API_ERROR = i18n.translate(
'xpack.stackConnectors.components.genAi.error.dashboardApiError',
{
defaultMessage: 'Error finding Generative AI Token Usage Dashboard.',
}
);

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook } from '@testing-library/react-hooks';
import { useGetDashboard } from './use_get_dashboard';
import { getDashboard } from './api';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
jest.mock('./api');
const mockToasts = { addDanger: jest.fn() };
const mockSpace = {
id: 'space',
name: 'space',
disabledFeatures: [],
};
const mockHttp = jest.fn();
const mockGetRedirectUrl = jest.fn();
jest.mock('@kbn/triggers-actions-ui-plugin/public');
const connectorId = '123';
const mockServices = {
http: mockHttp,
notifications: { toasts: mockToasts },
dashboard: {
locator: {
getRedirectUrl: mockGetRedirectUrl.mockImplementation(
({ dashboardId }) => `http://localhost:5601/app/dashboards#/view/${dashboardId}`
),
},
},
spaces: {
getActiveSpace: jest.fn().mockResolvedValue(mockSpace),
},
};
const mockDashboard = getDashboard as jest.Mock;
const mockKibana = useKibana as jest.Mock;
describe('useGetDashboard_function', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDashboard.mockResolvedValue({ data: { available: true } });
mockKibana.mockReturnValue({
services: mockServices,
});
});
it('fetches the dashboard and sets the dashboard URL', async () => {
const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId }));
await waitForNextUpdate();
expect(mockDashboard).toHaveBeenCalledWith(
expect.objectContaining({
connectorId,
dashboardId: 'generative-ai-token-usage-space',
})
);
expect(mockGetRedirectUrl).toHaveBeenCalledWith({
query: {
language: 'kuery',
query: `kibana.saved_objects: { id : ${connectorId} }`,
},
dashboardId: 'generative-ai-token-usage-space',
});
expect(result.current.isLoading).toBe(false);
expect(result.current.dashboardUrl).toBe(
'http://localhost:5601/app/dashboards#/view/generative-ai-token-usage-space'
);
});
it('handles the case where the dashboard is not available.', async () => {
mockDashboard.mockResolvedValue({ data: { available: false } });
const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId }));
await waitForNextUpdate();
expect(mockDashboard).toHaveBeenCalledWith(
expect.objectContaining({
connectorId,
dashboardId: 'generative-ai-token-usage-space',
})
);
expect(mockGetRedirectUrl).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.dashboardUrl).toBe(null);
});
it('handles the case where the spaces API is not available.', async () => {
mockKibana.mockReturnValue({
services: { ...mockServices, spaces: null },
});
const { result } = renderHook(() => useGetDashboard({ connectorId }));
expect(mockDashboard).not.toHaveBeenCalled();
expect(mockGetRedirectUrl).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.dashboardUrl).toBe(null);
});
it('handles the case where connectorId is empty string', async () => {
const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId: '' }));
await waitForNextUpdate();
expect(mockDashboard).not.toHaveBeenCalled();
expect(mockGetRedirectUrl).not.toHaveBeenCalled();
expect(result.current.isLoading).toBe(false);
expect(result.current.dashboardUrl).toBe(null);
});
it('handles the case where the dashboard locator is not available.', async () => {
mockKibana.mockReturnValue({
services: { ...mockServices, dashboard: {} },
});
const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId }));
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(result.current.dashboardUrl).toBe(null);
});
it('correctly handles errors and displays the appropriate toast messages.', async () => {
mockDashboard.mockRejectedValue(new Error('Error fetching dashboard'));
const { result, waitForNextUpdate } = renderHook(() => useGetDashboard({ connectorId }));
await waitForNextUpdate();
expect(result.current.isLoading).toBe(false);
expect(mockToasts.addDanger).toHaveBeenCalledWith({
title: 'Error finding Generative AI Token Usage Dashboard.',
text: 'Error fetching dashboard',
});
});
});

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import { getDashboardId } from './constants';
import { getDashboard } from './api';
import * as i18n from './translations';
interface Props {
connectorId: string;
}
export interface UseGetDashboard {
dashboardUrl: string | null;
isLoading: boolean;
}
export const useGetDashboard = ({ connectorId }: Props): UseGetDashboard => {
const {
dashboard,
http,
notifications: { toasts },
spaces,
} = useKibana().services;
const [spaceId, setSpaceId] = useState<string | null>(null);
useEffect(() => {
let didCancel = false;
if (spaces) {
spaces.getActiveSpace().then((space) => {
if (!didCancel) setSpaceId(space.id);
});
}
return () => {
didCancel = true;
};
}, [spaces]);
const [isLoading, setIsLoading] = useState(false);
const [dashboardUrl, setDashboardUrl] = useState<string | null>(null);
const [dashboardCheckComplete, setDashboardCheckComplete] = useState<boolean>(false);
const abortCtrl = useRef(new AbortController());
const setUrl = useCallback(
(dashboardId: string) => {
const url = dashboard?.locator?.getRedirectUrl({
query: {
language: 'kuery',
query: `kibana.saved_objects: { id : ${connectorId} }`,
},
dashboardId,
});
setDashboardUrl(url ?? null);
},
[connectorId, dashboard?.locator]
);
useEffect(() => {
let didCancel = false;
const fetchData = async (dashboardId: string) => {
abortCtrl.current = new AbortController();
if (!didCancel) setIsLoading(true);
try {
const res = await getDashboard({
http,
signal: abortCtrl.current.signal,
connectorId,
dashboardId,
});
if (!didCancel) {
setDashboardCheckComplete(true);
setIsLoading(false);
if (res.data?.available) {
setUrl(dashboardId);
}
if (res.status && res.status === 'error') {
toasts.addDanger({
title: i18n.GET_DASHBOARD_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
});
}
}
} catch (error) {
if (!didCancel) {
setDashboardCheckComplete(true);
setIsLoading(false);
toasts.addDanger({
title: i18n.GET_DASHBOARD_API_ERROR,
text: error.message,
});
}
}
};
if (spaceId != null && connectorId.length > 0 && !dashboardCheckComplete) {
abortCtrl.current.abort();
fetchData(getDashboardId(spaceId));
}
return () => {
didCancel = true;
setIsLoading(false);
abortCtrl.current.abort();
};
}, [connectorId, dashboardCheckComplete, dashboardUrl, http, setUrl, spaceId, toasts]);
return {
isLoading,
dashboardUrl,
};
};

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { initGenAiDashboard } from './create_dashboard';
import { getGenAiDashboard } from './dashboard';
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { Logger } from '@kbn/logging';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('12345'),
}));
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
const savedObjectsClient = savedObjectsClientMock.create();
describe('createDashboard', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('fetches the Gen Ai Dashboard saved object', async () => {
const dashboardId = 'test-dashboard-id';
const result = await initGenAiDashboard({ logger, savedObjectsClient, dashboardId });
expect(result.success).toBe(true);
expect(logger.error).not.toHaveBeenCalled();
expect(savedObjectsClient.get).toHaveBeenCalledWith('dashboard', dashboardId);
});
it('creates the Gen Ai Dashboard saved object when the dashboard saved object does not exist', async () => {
const dashboardId = 'test-dashboard-id';
const soClient = {
...savedObjectsClient,
get: jest.fn().mockRejectedValue({
output: {
statusCode: 404,
payload: {
statusCode: 404,
error: 'Not Found',
message: 'Saved object [dashboard/generative-ai-token-usage-default] not found',
},
headers: {},
},
}),
};
const result = await initGenAiDashboard({ logger, savedObjectsClient: soClient, dashboardId });
expect(soClient.get).toHaveBeenCalledWith('dashboard', dashboardId);
expect(soClient.create).toHaveBeenCalledWith(
'dashboard',
getGenAiDashboard(dashboardId).attributes,
{ overwrite: true, id: dashboardId }
);
expect(result.success).toBe(true);
});
it('handles an error when fetching the dashboard saved object', async () => {
const soClient = {
...savedObjectsClient,
get: jest.fn().mockRejectedValue({
output: {
statusCode: 500,
payload: {
statusCode: 500,
error: 'Internal Server Error',
message: 'Error happened',
},
headers: {},
},
}),
};
const dashboardId = 'test-dashboard-id';
const result = await initGenAiDashboard({ logger, savedObjectsClient: soClient, dashboardId });
expect(result.success).toBe(false);
expect(result.error?.message).toBe('Internal Server Error: Error happened');
expect(result.error?.statusCode).toBe(500);
expect(soClient.get).toHaveBeenCalledWith('dashboard', dashboardId);
});
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { DashboardAttributes } from '@kbn/dashboard-plugin/common';
import { Logger } from '@kbn/logging';
import { getGenAiDashboard } from './dashboard';
export interface OutputError {
message: string;
statusCode: number;
}
export const initGenAiDashboard = async ({
logger,
savedObjectsClient,
dashboardId,
}: {
logger: Logger;
savedObjectsClient: SavedObjectsClientContract;
dashboardId: string;
}): Promise<{
success: boolean;
error?: OutputError;
}> => {
try {
await savedObjectsClient.get<DashboardAttributes>('dashboard', dashboardId);
return {
success: true,
};
} catch (error) {
// if 404, does not yet exist. do not error, continue to create
if (error.output.statusCode !== 404) {
return {
success: false,
error: {
message: `${error.output.payload.error}${
error.output.payload.message ? `: ${error.output.payload.message}` : ''
}`,
statusCode: error.output.statusCode,
},
};
}
}
try {
await savedObjectsClient.create<DashboardAttributes>(
'dashboard',
getGenAiDashboard(dashboardId).attributes,
{
overwrite: true,
id: dashboardId,
}
);
logger.info(`Successfully created Gen Ai Dashboard ${dashboardId}`);
return { success: true };
} catch (error) {
return {
success: false,
error: { message: error.message, statusCode: error.output.statusCode },
};
}
};

View file

@ -0,0 +1,407 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DashboardAttributes } from '@kbn/dashboard-plugin/common';
import { v4 as uuidv4 } from 'uuid';
import { SavedObject } from '@kbn/core-saved-objects-common/src/server_types';
export const dashboardTitle = `Generative AI Token Usage`;
export const getGenAiDashboard = (dashboardId: string): SavedObject<DashboardAttributes> => {
const ids: Record<string, string> = {
genAiSavedObjectId: dashboardId,
tokens: uuidv4(),
totalTokens: uuidv4(),
tag: uuidv4(),
};
return {
attributes: {
description: 'Displays OpenAI token consumption per Kibana user',
kibanaSavedObjectMeta: {
searchSourceJSON:
'{"query":{"query":"kibana.saved_objects: { type_id : \\".gen-ai\\" } ","language":"kuery"},"filter":[]}',
},
optionsJSON:
'{"useMargins":true,"syncColors":false,"syncCursor":true,"syncTooltips":false,"hidePanelTitles":false}',
panelsJSON: JSON.stringify([
{
version: '8.9.0',
type: 'visualization',
gridData: {
x: 0,
y: 0,
w: 48,
h: 4,
i: '1c425103-57a6-4598-a092-03b8d550b440',
},
panelIndex: '1c425103-57a6-4598-a092-03b8d550b440',
embeddableConfig: {
savedVis: {
id: '',
title: '',
description: '',
type: 'markdown',
params: {
fontSize: 12,
openLinksInNewTab: false,
markdown:
// TODO: update with better copy and link to the docs page for the Gen AI connector before 8.9 release!
'The data powering this dashboard requires special index permissions. To access the dashboard data, contact a Kibana admin to set up a "read only" role for non-admin users who may want to view this dashboard. ',
},
uiState: {},
data: {
aggs: [],
searchSource: {
query: {
query: '',
language: 'kuery',
},
filter: [],
},
},
},
hidePanelTitles: false,
enhancements: {},
},
title: 'Permissions note',
},
{
version: '8.9.0',
type: 'lens',
gridData: {
x: 0,
y: 0,
w: 48,
h: 20,
i: '1e45fe29-05d3-4dbd-a0bb-fc3bc5ee3d6d',
},
panelIndex: '1e45fe29-05d3-4dbd-a0bb-fc3bc5ee3d6d',
embeddableConfig: {
attributes: {
title: '',
description: '',
visualizationType: 'lnsXY',
type: 'lens',
references: [],
state: {
visualization: {
title: 'Empty XY chart',
legend: {
isVisible: true,
position: 'right',
},
valueLabels: 'hide',
preferredSeriesType: 'bar_stacked',
layers: [
{
layerId: '475e8ca0-e78e-454a-8597-a5492f70dce3',
accessors: [
'0f9814ec-0964-4efa-93a3-c7f173df2483',
'b0e390e4-d754-4eb4-9fcc-4347dadda394',
],
position: 'top',
seriesType: 'bar_stacked',
showGridlines: false,
layerType: 'data',
xAccessor: '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519',
yConfig: [
{
forAccessor: '0f9814ec-0964-4efa-93a3-c7f173df2483',
color: '#9170b8',
},
{
forAccessor: 'b0e390e4-d754-4eb4-9fcc-4347dadda394',
color: '#3383cd',
},
],
},
],
labelsOrientation: {
x: 0,
yLeft: 0,
yRight: 0,
},
yTitle: 'Sum of GenAi Completion + Prompt Tokens',
axisTitlesVisibilitySettings: {
x: true,
yLeft: true,
yRight: true,
},
},
query: {
query: 'kibana.saved_objects:{ type_id: ".gen-ai" }',
language: 'kuery',
},
filters: [],
datasourceStates: {
formBased: {
layers: {
'475e8ca0-e78e-454a-8597-a5492f70dce3': {
columns: {
'0f9814ec-0964-4efa-93a3-c7f173df2483': {
label: 'GenAI Completion Tokens',
dataType: 'number',
operationType: 'sum',
sourceField: 'kibana.action.execution.gen_ai.usage.completion_tokens',
isBucketed: false,
scale: 'ratio',
params: {
emptyAsNull: true,
},
customLabel: true,
},
'5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519': {
label: 'user.name',
dataType: 'string',
operationType: 'terms',
scale: 'ordinal',
sourceField: 'user.name',
isBucketed: true,
params: {
size: 10000,
orderBy: {
type: 'custom',
},
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
parentFormat: {
id: 'terms',
},
include: [],
exclude: [],
includeIsRegex: false,
excludeIsRegex: false,
orderAgg: {
label: 'Sum of kibana.action.execution.gen_ai.usage.total_tokens',
dataType: 'number',
operationType: 'sum',
sourceField: 'kibana.action.execution.gen_ai.usage.total_tokens',
isBucketed: false,
scale: 'ratio',
params: {
emptyAsNull: true,
},
},
secondaryFields: [],
},
customLabel: true,
},
'b0e390e4-d754-4eb4-9fcc-4347dadda394': {
label: 'GenAi Prompt Tokens',
dataType: 'number',
operationType: 'sum',
sourceField: 'kibana.action.execution.gen_ai.usage.prompt_tokens',
isBucketed: false,
scale: 'ratio',
params: {
emptyAsNull: true,
},
customLabel: true,
},
},
columnOrder: [
'5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519',
'0f9814ec-0964-4efa-93a3-c7f173df2483',
'b0e390e4-d754-4eb4-9fcc-4347dadda394',
],
sampling: 1,
incompleteColumns: {},
},
},
},
textBased: {
layers: {},
},
},
internalReferences: [
{
type: 'index-pattern',
id: ids.tokens,
name: 'indexpattern-datasource-layer-475e8ca0-e78e-454a-8597-a5492f70dce3',
},
],
adHocDataViews: {
[ids.tokens]: {
id: ids.tokens,
title: '.kibana-event-log-*',
timeFieldName: '@timestamp',
sourceFilters: [],
fieldFormats: {},
runtimeFieldMap: {
'kibana.action.execution.gen_ai.usage.completion_tokens': {
type: 'long',
},
'kibana.action.execution.gen_ai.usage.prompt_tokens': {
type: 'long',
},
},
fieldAttrs: {},
allowNoIndex: false,
name: 'Event Log',
},
},
},
},
hidePanelTitles: false,
enhancements: {},
},
title: 'Prompt + Completion Tokens per User',
},
{
version: '8.9.0',
type: 'lens',
gridData: {
x: 0,
y: 20,
w: 48,
h: 20,
i: '80f745c6-a18b-492b-bacf-4a2499a2f95d',
},
panelIndex: '80f745c6-a18b-492b-bacf-4a2499a2f95d',
embeddableConfig: {
attributes: {
title: '',
description: '',
visualizationType: 'lnsXY',
type: 'lens',
references: [],
state: {
visualization: {
title: 'Empty XY chart',
legend: {
isVisible: true,
position: 'right',
},
valueLabels: 'hide',
preferredSeriesType: 'bar_stacked',
layers: [
{
layerId: '475e8ca0-e78e-454a-8597-a5492f70dce3',
accessors: ['b0e390e4-d754-4eb4-9fcc-4347dadda394'],
position: 'top',
seriesType: 'bar_stacked',
showGridlines: false,
layerType: 'data',
xAccessor: '5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519',
yConfig: [
{
forAccessor: 'b0e390e4-d754-4eb4-9fcc-4347dadda394',
color: '#332182',
},
],
},
],
},
query: {
query: 'kibana.saved_objects: { type_id : ".gen-ai" } ',
language: 'kuery',
},
filters: [],
datasourceStates: {
formBased: {
layers: {
'475e8ca0-e78e-454a-8597-a5492f70dce3': {
columns: {
'5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519': {
label: 'user.name',
dataType: 'string',
operationType: 'terms',
scale: 'ordinal',
sourceField: 'user.name',
isBucketed: true,
params: {
size: 10000,
orderBy: {
type: 'column',
columnId: 'b0e390e4-d754-4eb4-9fcc-4347dadda394',
},
orderDirection: 'desc',
otherBucket: true,
missingBucket: false,
parentFormat: {
id: 'terms',
},
include: [],
exclude: [],
includeIsRegex: false,
excludeIsRegex: false,
},
customLabel: true,
},
'b0e390e4-d754-4eb4-9fcc-4347dadda394': {
label: 'Sum of GenAI Total Tokens',
dataType: 'number',
operationType: 'sum',
sourceField: 'kibana.action.execution.gen_ai.usage.total_tokens',
isBucketed: false,
scale: 'ratio',
params: {
emptyAsNull: true,
},
customLabel: true,
},
},
columnOrder: [
'5352fcb2-7b8e-4b5a-bce9-73a7f3b2b519',
'b0e390e4-d754-4eb4-9fcc-4347dadda394',
],
sampling: 1,
incompleteColumns: {},
},
},
},
textBased: {
layers: {},
},
},
internalReferences: [
{
type: 'index-pattern',
id: ids.totalTokens,
name: 'indexpattern-datasource-layer-475e8ca0-e78e-454a-8597-a5492f70dce3',
},
],
adHocDataViews: {
[ids.totalTokens]: {
id: ids.totalTokens,
title: '.kibana-event-log-*',
timeFieldName: '@timestamp',
sourceFilters: [],
fieldFormats: {},
runtimeFieldMap: {
'kibana.action.execution.gen_ai.usage.total_tokens': {
type: 'long',
},
},
fieldAttrs: {},
allowNoIndex: false,
name: 'Event Log',
},
},
},
},
hidePanelTitles: false,
enhancements: {},
},
title: 'Total Tokens per User',
},
]),
timeRestore: false,
title: dashboardTitle,
version: 1,
},
coreMigrationVersion: '8.8.0',
created_at: '2023-06-01T19:00:04.629Z',
id: ids.genAiSavedObjectId,
managed: false,
type: 'dashboard',
typeMigrationVersion: '8.7.0',
updated_at: '2023-06-01T19:00:04.629Z',
references: [],
};
};

View file

@ -11,6 +11,8 @@ import { GEN_AI_CONNECTOR_ID, OpenAiProviderType } from '../../../common/gen_ai/
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { GenAiRunActionResponseSchema } from '../../../common/gen_ai/schema';
import { initGenAiDashboard } from './create_dashboard';
jest.mock('./create_dashboard');
describe('GenAiConnector', () => {
const sampleBody = JSON.stringify({
@ -96,4 +98,80 @@ describe('GenAiConnector', () => {
expect(response).toEqual({ result: 'success' });
});
});
describe('Token dashboard', () => {
const connector = new GenAiConnector({
configurationUtilities: actionsConfigMock.create(),
connector: { id: '1', type: GEN_AI_CONNECTOR_ID },
config: { apiUrl: 'https://example.com/api', apiProvider: OpenAiProviderType.AzureAi },
secrets: { apiKey: '123' },
logger: loggingSystemMock.createLogger(),
services: actionsMock.createServices(),
});
const mockGenAi = initGenAiDashboard as jest.Mock;
beforeEach(() => {
// @ts-ignore
connector.esClient.transport.request = mockRequest;
mockRequest.mockResolvedValue({ has_all_requested: true });
mockGenAi.mockResolvedValue({ success: true });
jest.clearAllMocks();
});
it('the create dashboard API call returns available: true when user has correct permissions', async () => {
const response = await connector.getDashboard({ dashboardId: '123' });
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
});
expect(response).toEqual({ available: true });
});
it('the create dashboard API call returns available: false when user has correct permissions', async () => {
mockRequest.mockResolvedValue({ has_all_requested: false });
const response = await connector.getDashboard({ dashboardId: '123' });
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
});
expect(response).toEqual({ available: false });
});
it('the create dashboard API call returns available: false when init dashboard fails', async () => {
mockGenAi.mockResolvedValue({ success: false });
const response = await connector.getDashboard({ dashboardId: '123' });
expect(mockRequest).toBeCalledTimes(1);
expect(mockRequest).toHaveBeenCalledWith({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
});
expect(response).toEqual({ available: false });
});
});
});

View file

@ -7,9 +7,11 @@
import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server';
import type { AxiosError } from 'axios';
import { initGenAiDashboard } from './create_dashboard';
import {
GenAiRunActionParamsSchema,
GenAiRunActionResponseSchema,
GenAiDashboardActionParamsSchema,
} from '../../../common/gen_ai/schema';
import type {
GenAiConfig,
@ -18,6 +20,10 @@ import type {
GenAiRunActionResponse,
} from '../../../common/gen_ai/types';
import { OpenAiProviderType, SUB_ACTION } from '../../../common/gen_ai/constants';
import {
GenAiDashboardActionParams,
GenAiDashboardActionResponse,
} from '../../../common/gen_ai/types';
export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets> {
private url;
@ -46,6 +52,12 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
method: 'runApi',
schema: GenAiRunActionParamsSchema,
});
this.registerSubAction({
name: SUB_ACTION.DASHBOARD,
method: 'getDashboard',
schema: GenAiDashboardActionParamsSchema,
});
}
protected getResponseErrorMessage(error: AxiosError): string {
@ -73,4 +85,34 @@ export class GenAiConnector extends SubActionConnector<GenAiConfig, GenAiSecrets
});
return response.data;
}
public async getDashboard({
dashboardId,
}: GenAiDashboardActionParams): Promise<GenAiDashboardActionResponse> {
const privilege = (await this.esClient.transport.request({
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
index: [
{
names: ['.kibana-event-log-*'],
allow_restricted_indices: true,
privileges: ['read'],
},
],
},
})) as { has_all_requested: boolean };
if (!privilege?.has_all_requested) {
return { available: false };
}
const response = await initGenAiDashboard({
logger: this.logger,
savedObjectsClient: this.savedObjectsClient,
dashboardId,
});
return { available: response.success };
}
}

View file

@ -27,6 +27,12 @@
"@kbn/test-jest-helpers",
"@kbn/securitysolution-io-ts-utils",
"@kbn/safer-lodash-set",
"@kbn/dashboard-plugin",
"@kbn/core-http-browser",
"@kbn/core-saved-objects-api-server",
"@kbn/core-saved-objects-common",
"@kbn/core-http-browser-mocks",
"@kbn/core-saved-objects-api-server-mocks",
],
"exclude": [
"target/**/*",

View file

@ -21,7 +21,8 @@
"dataViews",
"dataViewEditor",
"alerting",
"actions"
"actions",
"dashboard",
],
"optionalPlugins": [
"cloud",

View file

@ -28,6 +28,7 @@ import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
import { ruleDetailsRoute } from '@kbn/rule-data-utils';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { DashboardStart } from '@kbn/dashboard-plugin/public';
import { suspendedComponentWithProps } from './lib/suspended_component_with_props';
import {
ActionTypeRegistryContract,
@ -52,6 +53,7 @@ export interface TriggersAndActionsUiServices extends CoreStart {
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
dataViewEditor: DataViewEditorStart;
dashboard: DashboardStart;
charts: ChartsPluginStart;
alerting?: AlertingStart;
spaces?: SpacesPluginStart;

View file

@ -26,6 +26,7 @@ import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
import { DashboardStart } from '@kbn/dashboard-plugin/public';
import { suspendedComponentWithProps } from './lib/suspended_component_with_props';
import {
ActionTypeRegistryContract,
@ -47,6 +48,7 @@ export interface TriggersAndActionsUiServices extends CoreStart {
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
dataViewEditor: DataViewEditorStart;
dashboard: DashboardStart;
charts: ChartsPluginStart;
alerting?: AlertingStart;
spaces?: SpacesPluginStart;

View file

@ -30,6 +30,7 @@ export async function getMatchingIndices({
http: HttpSetup;
}): Promise<Record<string, any>> {
try {
// prepend and append index search requests with `*` to match the given text in middle of index names
const formattedPattern = formatPattern(pattern);
const { indices } = await http.post<ReturnType<typeof getMatchingIndices>>(

View file

@ -10,6 +10,7 @@ import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks';
import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { TriggersAndActionsUiServices } from '../../../application/app';
@ -40,6 +41,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
},
history: scopedHistoryMock.create(),
setBreadcrumbs: jest.fn(),
dashboard: dashboardPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
dataViewEditor: {

View file

@ -24,6 +24,7 @@ import { Storage } from '@kbn/kibana-utils-plugin/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import { triggersActionsRoute } from '@kbn/rule-data-utils';
import { DashboardStart } from '@kbn/dashboard-plugin/public';
import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar';
import { TypeRegistry } from './application/type_registry';
@ -148,6 +149,7 @@ interface PluginsStart {
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
dataViewEditor: DataViewEditorStart;
dashboard: DashboardStart;
charts: ChartsPluginStart;
alerting?: AlertingStart;
spaces?: SpacesPluginStart;
@ -259,6 +261,7 @@ export class Plugin
return renderApp({
...coreStart,
actions: plugins.actions,
dashboard: pluginsStart.dashboard,
data: pluginsStart.data,
dataViews: pluginsStart.dataViews,
dataViewEditor: pluginsStart.dataViewEditor,
@ -306,6 +309,7 @@ export class Plugin
return renderApp({
...coreStart,
actions: plugins.actions,
dashboard: pluginsStart.dashboard,
data: pluginsStart.data,
dataViews: pluginsStart.dataViews,
dataViewEditor: pluginsStart.dataViewEditor,

View file

@ -52,6 +52,7 @@
"@kbn/ecs",
"@kbn/alerts-as-data-utils",
"@kbn/core-ui-settings-common",
"@kbn/dashboard-plugin",
],
"exclude": ["target/**/*"]
}

View file

@ -41,7 +41,7 @@ export class ObjectRemover {
`${getUrlPrefix(spaceId)}/${isInternal ? 'internal' : 'api'}/${plugin}/${type}/${id}`
)
.set('kbn-xsrf', 'foo')
.expect(204);
.expect(plugin === 'saved_objects' ? 200 : 204);
})
);
this.objectsToRemove = [];

View file

@ -12,6 +12,7 @@ import {
genAiSuccessResponse,
} from '@kbn/actions-simulators-plugin/server/gen_ai_simulation';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib';
const connectorTypeId = '.gen-ai';
const name = 'A genAi action';
@ -24,11 +25,13 @@ const defaultConfig = { apiProvider: 'OpenAI' };
// eslint-disable-next-line import/no-default-export
export default function genAiTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const objectRemover = new ObjectRemover(supertest);
const supertestWithoutAuth = getService('supertestWithoutAuth');
const configService = getService('config');
const createConnector = async (apiUrl: string) => {
const retry = getService('retry');
const createConnector = async (apiUrl: string, spaceId?: string) => {
const { body } = await supertest
.post('/api/actions/connector')
.post(`${getUrlPrefix(spaceId ?? 'default')}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name,
@ -38,10 +41,15 @@ export default function genAiTest({ getService }: FtrProviderContext) {
})
.expect(200);
objectRemover.add(spaceId ?? 'default', body.id, 'connector', 'actions');
return body.id;
};
describe('GenAi', () => {
after(() => {
objectRemover.removeAll();
});
describe('action creation', () => {
const simulator = new GenAiSimulator({
returnError: false,
@ -271,6 +279,118 @@ export default function genAiTest({ getService }: FtrProviderContext) {
data: genAiSuccessResponse,
});
});
describe('gen ai dashboard', () => {
const dashboardId = 'specific-dashboard-id-default';
it('should not create a dashboard when user does not have kibana event log permissions', async () => {
const { body } = await supertestWithoutAuth
.post(`/api/actions/connector/${genAiActionId}/_execute`)
.auth('global_read', 'global_read-password')
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'getDashboard',
subActionParams: {
dashboardId,
},
},
})
.expect(200);
// check dashboard has not been created
await supertest
.get(`/api/saved_objects/dashboard/${dashboardId}`)
.set('kbn-xsrf', 'foo')
.expect(404);
expect(body).to.eql({
status: 'ok',
connector_id: genAiActionId,
data: { available: false },
});
});
it('should create a dashboard when user has correct permissions', async () => {
const { body } = await supertest
.post(`/api/actions/connector/${genAiActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'getDashboard',
subActionParams: {
dashboardId,
},
},
})
.expect(200);
// check dashboard has been created
await retry.try(async () =>
supertest
.get(`/api/saved_objects/dashboard/${dashboardId}`)
.set('kbn-xsrf', 'foo')
.expect(200)
);
objectRemover.add('default', dashboardId, 'dashboard', 'saved_objects');
expect(body).to.eql({
status: 'ok',
connector_id: genAiActionId,
data: { available: true },
});
});
});
});
describe('non-default space simulator', () => {
const simulator = new GenAiSimulator({
proxy: {
config: configService.get('kbnTestServer.serverArgs'),
},
});
let apiUrl: string;
let genAiActionId: string;
before(async () => {
apiUrl = await simulator.start();
genAiActionId = await createConnector(apiUrl, 'space1');
});
after(() => {
simulator.close();
});
const dashboardId = 'specific-dashboard-id-space1';
it('should create a dashboard in non-default space', async () => {
const { body } = await supertest
.post(`${getUrlPrefix('space1')}/api/actions/connector/${genAiActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'getDashboard',
subActionParams: {
dashboardId,
},
},
})
.expect(200);
// check dashboard has been created
await retry.try(
async () =>
await supertest
.get(`${getUrlPrefix('space1')}/api/saved_objects/dashboard/${dashboardId}`)
.set('kbn-xsrf', 'foo')
.expect(200)
);
objectRemover.add('space1', dashboardId, 'dashboard', 'saved_objects');
expect(body).to.eql({
status: 'ok',
connector_id: genAiActionId,
data: { available: true },
});
});
});
describe('error response simulator', () => {