From dd7ed50d9b55240fc84cf7b6768a90e0b5d25c36 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 23 Apr 2025 08:08:33 +0200 Subject: [PATCH] [Inference] Run EIS locally (#215475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Make sure you're connected to [Infra Vault](https://docs.elastic.dev/vault/infra-vault/home) using oidc: `$ VAULT_ADDR={...} vault login -method oidc` 2. Run the `eis` script: `$ node scripts/eis.js` 2a. After it's started, run ES with: `$ yarn es snapshot --license trial -E xpack.inference.elastic.url=http://localhost:8443` 2b. The command will output credentials for a preconfigured EIS connector. Paste it into kibana(.dev).yml. 3. Start Kibana as usual. 4. Run: `yarn run ts-node --transpile-only x-pack/solutions/observability/packages/kbn-genai-cli/recipes/hello_world.ts` This should output: ```  ~/dev/kibana  eis-connector-cli *219  yarn run ts-node --transpile-only x-pack/solutions/observability/packages/kbn-genai-cli/recipes/hello_world.ts yarn run v1.22.22 $ /Users/dariogieselaar/dev/kibana/node_modules/.bin/ts-node --transpile-only x-pack/solutions/observability/packages/kbn-genai-cli/recipes/hello_world.ts info Discovered kibana running at: http://elastic:changeme@127.0.0.1:5601/kbn info { id: 'extract_personal_details', content: '', output: { name: 'Sarah', age: 29, city: 'San Francisco' } } ✨ Done in 5.47s. ``` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Dima Arnautov Co-authored-by: Elastic Machine --- scripts/eis.js | 11 ++ .../src/tooling_log_text_writer.ts | 11 +- .../chat_model/inference_chat_model.test.ts | 25 --- .../src/chat_model/inference_chat_model.ts | 3 - .../inference-langchain/tsconfig.json | 2 - .../shared/kbn-inference-cli/README.md | 9 + .../shared/kbn-inference-cli/scripts/eis.ts | 23 +++ .../shared/kbn-inference-cli/src/client.ts | 24 ++- .../src/create_inference_client.ts | 24 ++- .../src/eis/assert_docker_available.ts | 20 +++ .../kbn-inference-cli/src/eis/ensure_eis.ts | 122 +++++++++++++ .../kbn-inference-cli/src/eis/file_utils.ts | 47 +++++ .../src/eis/generate_certificate.ts | 97 +++++++++++ .../src/eis/get_docker_compose_yaml.ts | 74 ++++++++ .../src/eis/get_eis_credentials.ts | 164 ++++++++++++++++++ .../src/eis/get_eis_gateway_config.ts | 99 +++++++++++ .../src/eis/get_nginx_conf.ts | 34 ++++ .../src/eis/get_service_configuration.ts | 94 ++++++++++ .../src/eis/until_gateway_ready.ts | 35 ++++ .../kbn-inference-cli/src/select_connector.ts | 6 +- .../shared/kbn-inference-cli/tsconfig.json | 3 + .../shared/kbn-kibana-api-cli/src/client.ts | 17 +- .../src/kibana_fetch_response_error.ts | 6 +- .../create_chat_model.test.ts | 1 - .../inference_client/create_chat_model.ts | 1 - .../common/convert_messages_for_inference.ts | 5 +- .../kbn-genai-cli/utils/run_recipe.ts | 11 +- 27 files changed, 912 insertions(+), 56 deletions(-) create mode 100644 scripts/eis.js create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/scripts/eis.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/assert_docker_available.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/ensure_eis.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/file_utils.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/generate_certificate.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_docker_compose_yaml.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_credentials.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_gateway_config.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_nginx_conf.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_service_configuration.ts create mode 100644 x-pack/platform/packages/shared/kbn-inference-cli/src/eis/until_gateway_ready.ts diff --git a/scripts/eis.js b/scripts/eis.js new file mode 100644 index 000000000000..d34fe291798c --- /dev/null +++ b/scripts/eis.js @@ -0,0 +1,11 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +require('../src/setup_node_env'); +require('@kbn/inference-cli/scripts/eis'); diff --git a/src/platform/packages/shared/kbn-tooling-log/src/tooling_log_text_writer.ts b/src/platform/packages/shared/kbn-tooling-log/src/tooling_log_text_writer.ts index 9d52605837c2..27c456ae2d67 100644 --- a/src/platform/packages/shared/kbn-tooling-log/src/tooling_log_text_writer.ts +++ b/src/platform/packages/shared/kbn-tooling-log/src/tooling_log_text_writer.ts @@ -56,7 +56,7 @@ function shouldWriteType(level: ParsedLogLevel, type: MessageTypes) { return Boolean(level.flags[type === 'success' ? 'info' : type]); } -function stringifyError(error: string | Error): string { +function stringifyError(error: string | Error, depth: number = 0): string { if (typeof error !== 'string' && !(error instanceof Error)) { error = new Error(`"${error}" thrown`); } @@ -69,7 +69,14 @@ function stringifyError(error: string | Error): string { return [error.stack, ...error.errors.map(stringifyError)].join('\n'); } - return error.stack || error.message || String(error); + const msg = error.stack || error.message || String(error); + + // log Error.cause if set + if (depth <= 3 && error.cause && error.cause instanceof Error && error.cause !== error) { + return [msg, `Caused by: ${stringifyError(error.cause, depth + 1)}`].join('\n'); + } + + return msg; } export class ToolingLogTextWriter implements Writer { diff --git a/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.test.ts b/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.test.ts index 19b8a39b674d..74aeb8dc8ad0 100644 --- a/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.test.ts +++ b/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.test.ts @@ -15,7 +15,6 @@ import { SystemMessage, ToolMessage, } from '@langchain/core/messages'; -import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; import { ChatCompleteAPI, ChatCompleteResponse, @@ -97,10 +96,8 @@ const createChunkEvent = (input: ChunkEventInput): ChatCompletionChunkEvent => { describe('InferenceChatModel', () => { let chatComplete: ChatCompleteAPI & jest.MockedFn; let connector: InferenceConnector; - let logger: MockedLogger; beforeEach(() => { - logger = loggerMock.create(); chatComplete = jest.fn(); connector = createConnector(); }); @@ -108,7 +105,6 @@ describe('InferenceChatModel', () => { describe('Request conversion', () => { it('converts a basic message call', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -133,7 +129,6 @@ describe('InferenceChatModel', () => { it('converts a complete conversation call', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -180,7 +175,6 @@ describe('InferenceChatModel', () => { it('converts a tool call conversation', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -258,7 +252,6 @@ describe('InferenceChatModel', () => { it('converts tools', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -321,7 +314,6 @@ describe('InferenceChatModel', () => { it('uses constructor parameters', async () => { const abortCtrl = new AbortController(); const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, temperature: 0.7, @@ -349,7 +341,6 @@ describe('InferenceChatModel', () => { it('uses invocation parameters', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, temperature: 0.7, @@ -386,7 +377,6 @@ describe('InferenceChatModel', () => { describe('Response handling', () => { it('returns the content', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -404,7 +394,6 @@ describe('InferenceChatModel', () => { it('returns tool calls', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -444,7 +433,6 @@ describe('InferenceChatModel', () => { let rawOutput: Record; const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, callbacks: [ @@ -483,7 +471,6 @@ describe('InferenceChatModel', () => { it('throws when the underlying call throws', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, maxRetries: 0, @@ -500,7 +487,6 @@ describe('InferenceChatModel', () => { it('respects the maxRetries parameter', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, maxRetries: 1, @@ -524,7 +510,6 @@ describe('InferenceChatModel', () => { it('does not retry unrecoverable errors', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, maxRetries: 0, @@ -545,7 +530,6 @@ describe('InferenceChatModel', () => { describe('Streaming response handling', () => { it('returns the chunks', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -566,7 +550,6 @@ describe('InferenceChatModel', () => { it('returns tool calls', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -618,7 +601,6 @@ describe('InferenceChatModel', () => { it('returns the token count meta', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -658,7 +640,6 @@ describe('InferenceChatModel', () => { it('throws when the underlying call throws', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, maxRetries: 0, @@ -675,7 +656,6 @@ describe('InferenceChatModel', () => { it('throws when the underlying observable errors', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -703,7 +683,6 @@ describe('InferenceChatModel', () => { describe('#bindTools', () => { it('bind tools to be used for invocation', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -764,7 +743,6 @@ describe('InferenceChatModel', () => { describe('#identifyingParams', () => { it('returns connectorId and modelName from the constructor', () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, model: 'my-super-model', @@ -792,7 +770,6 @@ describe('InferenceChatModel', () => { }); const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, model: 'my-super-model', @@ -813,7 +790,6 @@ describe('InferenceChatModel', () => { describe('#withStructuredOutput', () => { it('binds the correct parameters', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); @@ -887,7 +863,6 @@ describe('InferenceChatModel', () => { it('returns the correct tool call', async () => { const chatModel = new InferenceChatModel({ - logger, chatComplete, connector, }); diff --git a/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.ts b/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.ts index c6353ccb1e4b..47422f1c9c67 100644 --- a/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.ts +++ b/x-pack/platform/packages/shared/ai-infra/inference-langchain/src/chat_model/inference_chat_model.ts @@ -30,7 +30,6 @@ import { RunnableSequence, RunnableLambda, } from '@langchain/core/runnables'; -import type { Logger } from '@kbn/logging'; import { InferenceConnector, ChatCompleteAPI, @@ -60,7 +59,6 @@ import { export interface InferenceChatModelParams extends BaseChatModelParams { connector: InferenceConnector; chatComplete: ChatCompleteAPI; - logger: Logger; functionCallingMode?: FunctionCallingMode; temperature?: number; model?: string; @@ -106,7 +104,6 @@ export class InferenceChatModel extends BaseChatModel { + const controller = new AbortController(); + + addCleanupTask(() => { + controller.abort(); + }); + + return ensureEis({ + log, + signal: controller.signal, + }).catch((error) => { + throw new Error('Failed to start EIS', { cause: error }); + }); +}); diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/client.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/client.ts index 5d4e75c7956a..1a091dfa7b5e 100644 --- a/x-pack/platform/packages/shared/kbn-inference-cli/src/client.ts +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/client.ts @@ -9,6 +9,7 @@ import { BoundOutputAPI, ChatCompleteResponse, ChatCompletionEvent, + InferenceConnector, ToolOptions, UnboundChatCompleteOptions, UnboundOutputOptions, @@ -19,17 +20,18 @@ import { httpResponseIntoObservable } from '@kbn/sse-utils-client'; import { ToolingLog } from '@kbn/tooling-log'; import { defer, from } from 'rxjs'; import { KibanaClient } from '@kbn/kibana-api-cli'; +import { InferenceChatModel } from '@kbn/inference-langchain'; interface InferenceCliClientOptions { log: ToolingLog; kibanaClient: KibanaClient; - connectorId: string; + connector: InferenceConnector; signal: AbortSignal; } function createChatComplete(options: InferenceCliClientOptions): BoundChatCompleteAPI; -function createChatComplete({ connectorId, kibanaClient, signal }: InferenceCliClientOptions) { +function createChatComplete({ connector, kibanaClient, signal }: InferenceCliClientOptions) { return ( options: UnboundChatCompleteOptions ) => { @@ -48,7 +50,7 @@ function createChatComplete({ connectorId, kibanaClient, signal }: InferenceCliC } = options; const body: ChatCompleteRequestBody = { - connectorId, + connectorId: connector.connectorId, messages, modelName, system, @@ -70,7 +72,7 @@ function createChatComplete({ connectorId, kibanaClient, signal }: InferenceCliC kibanaClient .fetch(`/internal/inference/chat_complete/stream`, { method: 'POST', - body: JSON.stringify(body), + body, asRawResponse: true, signal: combineSignal(signal, abortSignal), }) @@ -83,7 +85,7 @@ function createChatComplete({ connectorId, kibanaClient, signal }: InferenceCliC `/internal/inference/chat_complete`, { method: 'POST', - body: JSON.stringify(body), + body, signal: combineSignal(signal, abortSignal), } ); @@ -109,7 +111,7 @@ function combineSignal(left: AbortSignal, right?: AbortSignal) { export class InferenceCliClient { private readonly boundChatCompleteAPI: BoundChatCompleteAPI; private readonly boundOutputAPI: BoundOutputAPI; - constructor(options: InferenceCliClientOptions) { + constructor(private readonly options: InferenceCliClientOptions) { this.boundChatCompleteAPI = createChatComplete(options); const outputAPI = createOutputApi(this.boundChatCompleteAPI); @@ -124,7 +126,7 @@ export class InferenceCliClient { options.log.debug(`Running task ${outputOptions.id}`); return outputAPI({ ...outputOptions, - connectorId: options.connectorId, + connectorId: options.connector.connectorId, abortSignal: combineSignal(options.signal, outputOptions.abortSignal), }); }; @@ -137,4 +139,12 @@ export class InferenceCliClient { output: BoundOutputAPI = (options) => { return this.boundOutputAPI(options); }; + + getLangChainChatModel = (): InferenceChatModel => { + return new InferenceChatModel({ + connector: this.options.connector, + chatComplete: this.boundChatCompleteAPI, + signal: this.options.signal, + }); + }; } diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/create_inference_client.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/create_inference_client.ts index f0e66c68cf6b..c8fc4b63d61d 100644 --- a/x-pack/platform/packages/shared/kbn-inference-cli/src/create_inference_client.ts +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/create_inference_client.ts @@ -10,25 +10,45 @@ import { KibanaClient, createKibanaClient } from '@kbn/kibana-api-cli'; import { InferenceCliClient } from './client'; import { selectConnector } from './select_connector'; +class InvalidLicenseLevelError extends Error { + constructor(license: string) { + super(`License needs to be at least Enterprise, but was ${license}`); + } +} + export async function createInferenceClient({ log, prompt, signal, kibanaClient, + connectorId, }: { log: ToolingLog; prompt?: boolean; signal: AbortSignal; kibanaClient?: KibanaClient; + connectorId?: string; }): Promise { kibanaClient = kibanaClient || (await createKibanaClient({ log, signal })); - const connector = await selectConnector({ log, kibanaClient, prompt }); + const license = await kibanaClient.es.license.get(); + + if (license.license.type !== 'trial' && license.license.type !== 'enterprise') { + throw new InvalidLicenseLevelError(license.license.type); + } + + const connector = await selectConnector({ + log, + kibanaClient, + prompt, + signal, + preferredConnectorId: connectorId, + }); return new InferenceCliClient({ log, kibanaClient, - connectorId: connector.connectorId, + connector, signal, }); } diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/assert_docker_available.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/assert_docker_available.ts new file mode 100644 index 000000000000..218103742559 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/assert_docker_available.ts @@ -0,0 +1,20 @@ +/* + * 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 execa, { ExecaError } from 'execa'; + +class DockerUnavailableError extends Error { + constructor(cause: ExecaError) { + super(`Docker is not available`, { cause }); + } +} + +export async function assertDockerAvailable(): Promise { + await execa.command(`docker info`).catch((error: ExecaError) => { + throw new DockerUnavailableError(error); + }); +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/ensure_eis.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/ensure_eis.ts new file mode 100644 index 000000000000..79d1f4acd417 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/ensure_eis.ts @@ -0,0 +1,122 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import execa from 'execa'; +import Path from 'path'; +import chalk from 'chalk'; +import { assertDockerAvailable } from './assert_docker_available'; +import { getDockerComposeYaml } from './get_docker_compose_yaml'; +import { getEisGatewayConfig } from './get_eis_gateway_config'; +import { DATA_DIR, writeFile } from './file_utils'; +import { getNginxConf } from './get_nginx_conf'; +import { untilGatewayReady } from './until_gateway_ready'; +import { getEisCredentials } from './get_eis_credentials'; + +const DOCKER_COMPOSE_FILE_PATH = Path.join(DATA_DIR, 'docker-compose.yaml'); +const NGINX_CONF_FILE_PATH = Path.join(DATA_DIR, 'nginx.conf'); + +function getPreconfiguredConnectorConfig({ modelId }: { modelId: string }) { + return `xpack.actions.preconfigured: + elastic-llm: + name: Elastic LLM + actionTypeId: .inference + exposeConfig: true + config: + provider: 'elastic' + taskType: 'chat_completion' + inferenceId: '.rainbow-sprinkles-elastic' + providerConfig: + model_id: '${modelId}'`; +} + +async function down(cleanup: boolean = true) { + await execa + .command(`docker compose -f ${DOCKER_COMPOSE_FILE_PATH} down`, { cleanup }) + .catch(() => {}); +} + +export async function ensureEis({ log, signal }: { log: ToolingLog; signal: AbortSignal }) { + log.info(`Ensuring EIS is available`); + + await assertDockerAvailable(); + + const credentials = await getEisCredentials({ + log, + dockerComposeFilePath: DOCKER_COMPOSE_FILE_PATH, + }); + + log.debug(`Stopping existing containers`); + + await down(); + + const eisGatewayConfig = await getEisGatewayConfig({ + credentials, + log, + signal, + }); + + const nginxConf = getNginxConf({ eisGatewayConfig }); + + log.debug(`Wrote nginx config to ${NGINX_CONF_FILE_PATH}`); + + await writeFile(NGINX_CONF_FILE_PATH, nginxConf); + + const dockerComposeYaml = getDockerComposeYaml({ + config: { + eisGateway: eisGatewayConfig, + nginx: { + file: NGINX_CONF_FILE_PATH, + }, + }, + }); + + await writeFile(DOCKER_COMPOSE_FILE_PATH, dockerComposeYaml); + + log.debug(`Wrote docker-compose file to ${DOCKER_COMPOSE_FILE_PATH}`); + + untilGatewayReady({ dockerComposeFilePath: DOCKER_COMPOSE_FILE_PATH }) + .then(() => { + log.write(''); + + log.write( + `${chalk.green( + `✔` + )} EIS Gateway started. Start Elasticsearch with "-E xpack.inference.elastic.url=http://localhost:${ + eisGatewayConfig.ports[0] + }" to connect` + ); + + log.write(''); + + log.write( + `${chalk.green( + `📋` + )} Paste the following config in kibana.(dev.).yml if you don't already have a connector:` + ); + + const lines = getPreconfiguredConnectorConfig({ modelId: eisGatewayConfig.model.id }).split( + '\n' + ); + + log.write(''); + + lines.forEach((line) => { + if (line) { + log.write(line); + } + }); + }) + .catch((error) => { + log.error(error); + }); + + await execa.command(`docker compose -f ${DOCKER_COMPOSE_FILE_PATH} up`, { + stdio: 'inherit', + cleanup: true, + }); +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/file_utils.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/file_utils.ts new file mode 100644 index 000000000000..c5af7cd59f79 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/file_utils.ts @@ -0,0 +1,47 @@ +/* + * 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 { promises as Fs } from 'fs'; +import Path from 'path'; +import Os from 'os'; +import { REPO_ROOT } from '@kbn/repo-info'; + +export const DATA_DIR = Path.join(REPO_ROOT, 'data', 'eis'); + +export async function createDirIfNotExists(dir: string): Promise { + const dirExists = await Fs.stat(dir) + .then((stat) => stat.isDirectory()) + .catch(() => false); + + if (!dirExists) { + await Fs.mkdir(dir, { recursive: true }); + } +} + +export async function fileExists(filePath: string): Promise { + return await Fs.stat(filePath) + .then((stat) => stat.isFile()) + .catch(() => false); +} + +export async function writeTempfile(fileName: string, content: string): Promise { + const tempDir = await Fs.mkdtemp(Path.join(Os.tmpdir(), 'eis-')); + const filePath = Path.join(tempDir, fileName); + + // Write the provided ACL content to the file + await Fs.writeFile(filePath, content, 'utf8'); + + return filePath; +} + +export async function writeFile(filePath: string, content: string): Promise { + const dir = Path.dirname(filePath); + + await createDirIfNotExists(dir); + + // Write the provided ACL content to the file + await Fs.writeFile(filePath, content, 'utf8'); +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/generate_certificate.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/generate_certificate.ts new file mode 100644 index 000000000000..17d9b38429a9 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/generate_certificate.ts @@ -0,0 +1,97 @@ +/* + * 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 execa from 'execa'; +import Path from 'path'; +import { promises as Fs } from 'fs'; +import { ToolingLog } from '@kbn/tooling-log'; +import { DATA_DIR, createDirIfNotExists, fileExists } from './file_utils'; + +const CERTS_DIR = Path.join(DATA_DIR, 'certs'); + +const TLS_CERT_PATH = Path.join(CERTS_DIR, 'tls.crt'); +const FULL_CHAIN_PATH = Path.join(CERTS_DIR, 'fullchain.crt'); +const TLS_KEY_PATH = Path.join(CERTS_DIR, 'tls.key'); + +interface CertificateFiles { + tls: { + key: string; + cert: string; + }; + ca: { + cert: string; + }; +} + +async function ensureMkcert({ log }: { log: ToolingLog }) { + const mkCertExists = await execa + .command('which mkcert') + .then(() => true) + .catch(() => false); + + if (!mkCertExists) { + const brewExists = await execa + .command('which brew') + .then(() => true) + .catch(() => false); + + if (!brewExists) { + throw new Error(`mkcert is not available and needed to install locally-trusted certificates`); + } + + log.info('Installing mkcert'); + + await execa.command(`brew install mkcert`); + } + + await execa.command('mkcert -install'); + + const caRoot = await execa.command(`mkcert -CAROOT`).then((val) => val.stdout); + + const caCertFilePath = `${caRoot}/rootCA.pem`; + + return { + caCertFilePath, + }; +} + +export async function generateCertificates({ + log, +}: { + log: ToolingLog; +}): Promise { + const { caCertFilePath } = await ensureMkcert({ log }); + + const allExists = ( + await Promise.all([fileExists(FULL_CHAIN_PATH), fileExists(TLS_KEY_PATH)]) + ).every(Boolean); + + if (!allExists) { + log.info(`Generating certificates`); + + await createDirIfNotExists(CERTS_DIR); + + await execa.command(`mkcert -cert-file=${TLS_CERT_PATH} -key-file=${TLS_KEY_PATH} localhost`); + } + + const allFileContents = await Promise.all([ + Fs.readFile(TLS_CERT_PATH, 'utf8'), + Fs.readFile(caCertFilePath, 'utf8'), + ]); + + await Fs.writeFile(FULL_CHAIN_PATH, allFileContents.join('\n')); + + return { + tls: { + cert: FULL_CHAIN_PATH, + key: TLS_KEY_PATH, + }, + ca: { + cert: caCertFilePath, + }, + }; +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_docker_compose_yaml.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_docker_compose_yaml.ts new file mode 100644 index 000000000000..c21177ec8de2 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_docker_compose_yaml.ts @@ -0,0 +1,74 @@ +/* + * 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 dedent from 'dedent'; +import { EisGatewayConfig } from './get_eis_gateway_config'; + +export function getDockerComposeYaml({ + config, +}: { + config: { + eisGateway: EisGatewayConfig; + nginx: { + file: string; + }; + }; +}) { + const credentials = Object.entries(config.eisGateway.credentials).map(([key, value]) => { + return `${key.toUpperCase()}: "${value}"`; + }); + + return dedent(` + services: + eis-gateway: + image: ${config.eisGateway.image} + expose: + - "8443" + - "8051" + volumes: + - "${config.eisGateway.mount.acl}:/app/acl/acl.yaml:ro" + - "${config.eisGateway.mount.tls.cert}:/certs/tls/tls.crt:ro" + - "${config.eisGateway.mount.tls.key}:/certs/tls/tls.key:ro" + - "${config.eisGateway.mount.ca.cert}:/certs/ca/ca.crt:ro" + environment: +${credentials + .map((line) => { + // white-space is important here 😀 + return ` ${line}`; + }) + .join('\n')} + ACL_FILE_PATH: "/app/acl/acl.yaml" + ENTITLEMENTS_SKIP_CHECK: "true" + TELEMETRY_EXPORTER_TYPE: "none" + TLS_VERIFY_CLIENT_CERTS: "false" + LOGGER_LEVEL: "error" + healthcheck: + test: [ + 'CMD-SHELL', + 'echo ''package main; import ("net/http";"os");func main(){resp,err:=http.Get("http://localhost:${ + config.eisGateway.ports[1] + }/health");if err!=nil||resp.StatusCode!=200{os.Exit(1)}}'' > /tmp/health.go; go run /tmp/health.go', + ] + interval: 1s + timeout: 2s + retries: 10 + + gateway-proxy: + image: nginx:alpine + ports: + - "${config.eisGateway.ports[0]}:80" + volumes: + - ${config.nginx.file}:/etc/nginx/nginx.conf:ro + depends_on: + - eis-gateway + healthcheck: + test: ["CMD", "curl", "http://localhost:80/" ] + interval: 1s + timeout: 2s + retries: 10 +`); +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_credentials.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_credentials.ts new file mode 100644 index 000000000000..da700b133d42 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_credentials.ts @@ -0,0 +1,164 @@ +/* + * 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. + */ + +/* eslint-disable max-classes-per-file */ +import execa from 'execa'; +import { ToolingLog } from '@kbn/tooling-log'; +import { pickBy, mapKeys } from 'lodash'; + +class VaultUnavailableError extends Error { + constructor(cause: Error) { + super(`Vault is not available. See https://docs.elastic.dev/vault.`, { cause }); + } +} + +class VaultTimedOutError extends Error { + constructor(cause: Error) { + super( + `Vault timed out. Make sure you are connected to the VPN if this is needed for the specified Vault cluster. See https://docs.elastic.dev/vault.`, + { cause } + ); + } +} + +class VaultAccessError extends Error { + constructor(cause: Error) { + super(`Could not read from Vault`, { cause }); + } +} + +async function getEisCreditsFromVault() { + await execa.command(`which vault`).catch((error) => { + throw new VaultUnavailableError(error); + }); + + await execa.command('vault status', { timeout: 2500 }).catch((error) => { + if (error.timedOut) { + throw new VaultTimedOutError(error); + } + throw new VaultAccessError(error); + }); + + const secretPath = + process.env.VAULT_SECRET_PATH || 'secret/kibana-issues/dev/inference/kibana-eis-bedrock-config'; + const vaultAddress = process.env.VAULT_ADDR || 'https://secrets.elastic.co:8200'; + + const output = await execa + .command(`vault kv get -format json ${secretPath}`, { + // extends env + env: { + VAULT_ADDR: vaultAddress, + }, + }) + .then((value) => { + const creds = (JSON.parse(value.stdout) as { data: EisCredentials }).data; + + return mapKeys(creds, (val, key) => { + // temp until secret gets updated + return key + .replace('_access_key_id', '_aws_access_key_id') + .replace('_secret_access_key', '_aws_secret_access_key') + .toUpperCase(); + }); + }) + .catch((error) => { + throw new VaultAccessError(error); + }); + + return output as EisCredentials; +} + +export interface EisCredentials { + [x: string]: string; +} + +function getCredentialCandidatesFromEnv( + env?: Array<[string, string | undefined]> +): Record | undefined { + if (!env) { + return undefined; + } + + const candidates = env.filter( + (pair): pair is [string, string] => !!pair[1] && pair[0].toLowerCase().startsWith('aws_') + ); + return candidates.length ? Object.fromEntries(candidates) : undefined; +} + +async function getEnvFromConfig({ + dockerComposeFilePath, + log, +}: { + dockerComposeFilePath: string; + log: ToolingLog; +}) { + const eisGatewayContainerName = await execa + .command(`docker compose -f ${dockerComposeFilePath} ps --all -q eis-gateway`) + .then(({ stdout }) => stdout) + .catch((error) => { + return undefined; + }); + + if (!eisGatewayContainerName) { + log.debug(`No EIS container found to get env variables from`); + return undefined; + } + + const config = await execa + .command(`docker inspect ${eisGatewayContainerName}`) + .then(({ stdout }) => { + return JSON.parse(stdout)[0] as { Config: { Env: string[] } }; + }) + .catch(() => { + return undefined; + }); + + const envVariables = getCredentialCandidatesFromEnv( + config?.Config.Env.map((line) => { + const [key, ...value] = line.split('='); + return [key, value.join('=')]; + }) + ); + + return envVariables; +} + +export async function getEisCredentials({ + log, + dockerComposeFilePath, +}: { + log: ToolingLog; + dockerComposeFilePath: string; +}): Promise { + log.debug(`Fetching EIS credentials`); + + const envVariables = getCredentialCandidatesFromEnv(Object.entries(process.env)); + + const existingContainerEnv = await getEnvFromConfig({ dockerComposeFilePath, log }); + + const credentials = await getEisCreditsFromVault() + .catch((error) => { + if (envVariables || existingContainerEnv) { + log.debug( + `Gracefully handling Vault error, as environment variables are found: ${error.message}` + ); + return {}; + } + throw error; + }) + .then((creds) => { + return { + ...existingContainerEnv, + ...pickBy(creds, (val) => !!val), + ...envVariables, + }; + }); + + log.debug(`Using credentials: ${JSON.stringify(credentials)}`); + + return credentials; +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_gateway_config.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_gateway_config.ts new file mode 100644 index 000000000000..fff5286e4ac5 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_eis_gateway_config.ts @@ -0,0 +1,99 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import { dump } from 'js-yaml'; +import { writeTempfile } from './file_utils'; +import { generateCertificates } from './generate_certificate'; +import { getServiceConfigurationFromYaml } from './get_service_configuration'; +import { EisCredentials } from './get_eis_credentials'; + +export interface EisGatewayConfig { + image: string; + ports: [number, number]; + mount: { + acl: string; + tls: { + cert: string; + key: string; + }; + ca: { + cert: string; + }; + }; + credentials: EisCredentials; + model: { + id: string; + }; +} + +const EIS_CHAT_MODEL_NAME = `rainbow-sprinkles`; + +interface AccessControlListConfig { + [x: string]: { + allow_cloud_trials: boolean; + hosted: { + mode: 'allow' | 'deny'; + accounts: string[]; + }; + serverless: { + mode: 'allow' | 'deny'; + organizations: string[]; + }; + task_types: string[]; + }; +} + +export async function getEisGatewayConfig({ + log, + signal, + credentials, +}: { + log: ToolingLog; + signal: AbortSignal; + credentials: EisCredentials; +}): Promise { + log.debug(`Getting EIS Gateway config`); + + const { version } = await getServiceConfigurationFromYaml<{}>('eis-gateway'); + + const aclContents: AccessControlListConfig = { + [EIS_CHAT_MODEL_NAME]: { + allow_cloud_trials: true, + hosted: { + mode: 'deny', + accounts: [], + }, + serverless: { + mode: 'deny', + organizations: [], + }, + task_types: ['chat'], + }, + }; + + const aclFilePath = await writeTempfile('acl.yaml', dump(aclContents)); + + log.debug(`Wrote ACL file to ${aclFilePath}`); + + const { tls, ca } = await generateCertificates({ + log, + }); + + return { + ports: [8443, 8051], + credentials, + image: `docker.elastic.co/cloud-ci/k8s-arch/eis-gateway:git-${version}`, + model: { + id: EIS_CHAT_MODEL_NAME, + }, + mount: { + acl: aclFilePath, + tls, + ca, + }, + }; +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_nginx_conf.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_nginx_conf.ts new file mode 100644 index 000000000000..03657037a0c4 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_nginx_conf.ts @@ -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 dedent from 'dedent'; +import { EisGatewayConfig } from './get_eis_gateway_config'; + +export function getNginxConf({ eisGatewayConfig }: { eisGatewayConfig: EisGatewayConfig }) { + return dedent(`error_log /dev/stderr error; +events {} + +http { + access_log off; + + upstream eis_gateway { + server eis-gateway:${eisGatewayConfig.ports[0]}; + } + + server { + listen 80; + + location / { + proxy_pass https://eis_gateway; + # Disable SSL verification since we're using self-signed certs + proxy_ssl_verify off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + } +}`); +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_service_configuration.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_service_configuration.ts new file mode 100644 index 000000000000..5614af35f41a --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/get_service_configuration.ts @@ -0,0 +1,94 @@ +/* + * 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 { promises as Fs } from 'fs'; +import Path from 'path'; +import os from 'os'; +import simpleGit from 'simple-git'; +import { load } from 'js-yaml'; + +class GitCheckoutError extends Error { + constructor(cause: Error) { + super(`Failed to checkout repository. Make sure you've authenticated to Git`, { cause }); + } +} + +async function getFiles(files: string[]): Promise { + // Create a temporary directory + const tmpDir = await Fs.mkdtemp(Path.join(os.tmpdir(), 'serverless-gitops-')); + const git = simpleGit(tmpDir); + + // Initialize an empty repository and add remote + await git.init(); + await git.raw(['config', 'core.sparseCheckout', 'true']); + + const sparseCheckoutPath = Path.join(tmpDir, '.git', 'info', 'sparse-checkout'); + await Fs.writeFile(sparseCheckoutPath, files.join('\n'), 'utf-8'); + + async function pull() { + return await git.pull('origin', 'main', { '--depth': '1' }); + } + + await git.addRemote('origin', `git@github.com:elastic/serverless-gitops.git`); + + await pull() + .catch(async () => { + await git.remote(['set-url', 'origin', 'https://github.com/elastic/serverless-gitops.git']); + await pull(); + }) + .catch((error) => { + throw new GitCheckoutError(error); + }); + + const fileContents = await Promise.all( + files.map(async (filePath) => { + return await Fs.readFile(Path.join(tmpDir, filePath), 'utf-8'); + }) + ); + + await Fs.rm(tmpDir, { recursive: true, force: true }); + + return fileContents; +} + +export async function getServiceConfigurationFromYaml( + serviceName: string, + environment: string = 'qa' +): Promise<{ + version: string; + config: T; +}> { + const [configFile, versionsFile] = await getFiles([ + `services/${serviceName}/values/${environment}/default.yaml`, + `services/${serviceName}/versions.yaml`, + ]); + + const config = load(configFile) as T; + const versions = load(versionsFile) as { + services: { + [x: string]: { + versions: Record; + }; + }; + }; + + const versionMap = Object.values(versions.services)[0].versions; + + const versionKey = Object.keys(versionMap).find((key) => key.startsWith(environment)); + + if (!versionKey) { + throw new Error( + `No version found for environment ${environment}, available versions ${Object.keys( + versionMap + ).join(', ')}` + ); + } + + return { + config, + version: versionMap[versionKey], + }; +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/until_gateway_ready.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/until_gateway_ready.ts new file mode 100644 index 000000000000..9c7b2c050c1f --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/eis/until_gateway_ready.ts @@ -0,0 +1,35 @@ +/* + * 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 { backOff } from 'exponential-backoff'; +import execa from 'execa'; + +export async function untilGatewayReady({ + dockerComposeFilePath, +}: { + dockerComposeFilePath: string; +}) { + async function isGatewayReady() { + const { stdout: gatewayProxyContainerName } = await execa.command( + `docker compose -f ${dockerComposeFilePath} ps -q gateway-proxy` + ); + + const { stdout } = await execa.command( + `docker inspect --format='{{.State.Health.Status}}' ${gatewayProxyContainerName}` + ); + + if (stdout !== "'healthy'") { + throw new Error(`gateway-proxy not healthy: ${stdout}`); + } + } + + return await backOff(isGatewayReady, { + delayFirstAttempt: true, + startingDelay: 500, + jitter: 'full', + numOfAttempts: 20, + }); +} diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/src/select_connector.ts b/x-pack/platform/packages/shared/kbn-inference-cli/src/select_connector.ts index f6c66159e14e..9cabbf379f90 100644 --- a/x-pack/platform/packages/shared/kbn-inference-cli/src/select_connector.ts +++ b/x-pack/platform/packages/shared/kbn-inference-cli/src/select_connector.ts @@ -16,18 +16,18 @@ export async function selectConnector({ kibanaClient, prompt = true, preferredConnectorId, + signal, }: { log: ToolingLog; kibanaClient: KibanaClient; prompt?: boolean; preferredConnectorId?: string; + signal: AbortSignal; }): Promise { const connectors = await getConnectors(kibanaClient); if (!connectors.length) { - throw new Error( - `No connectors available for inference. See https://www.elastic.co/guide/en/kibana/current/action-types.html` - ); + throw new Error(`No connectors available.`); } const connector = connectors.find((item) => item.connectorId === preferredConnectorId); diff --git a/x-pack/platform/packages/shared/kbn-inference-cli/tsconfig.json b/x-pack/platform/packages/shared/kbn-inference-cli/tsconfig.json index 4cc624f454cf..65431717a556 100644 --- a/x-pack/platform/packages/shared/kbn-inference-cli/tsconfig.json +++ b/x-pack/platform/packages/shared/kbn-inference-cli/tsconfig.json @@ -19,5 +19,8 @@ "@kbn/sse-utils-client", "@kbn/tooling-log", "@kbn/kibana-api-cli", + "@kbn/dev-cli-runner", + "@kbn/inference-langchain", + "@kbn/repo-info", ] } diff --git a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts index 8e2f43200bf5..f2e60a725ad4 100644 --- a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts +++ b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/client.ts @@ -14,7 +14,7 @@ import { createProxyTransport } from './proxy_transport'; import { getInternalKibanaHeaders } from './get_internal_kibana_headers'; type FetchInputOptions = string | URL; -type FetchInitOptions = globalThis.RequestInit; +type FetchInitOptions = Omit & { body: unknown }; interface KibanaClientOptions { baseUrl: string; @@ -107,6 +107,7 @@ export class KibanaClient { ...init?.headers, }, signal: combineSignal(this.options.signal, init?.signal), + body: init?.body ? JSON.stringify(init?.body) : undefined, }); if (init?.asRawResponse) { @@ -114,7 +115,19 @@ export class KibanaClient { } if (response.status >= 400) { - throw new FetchResponseError(response); + const content = response.headers.get('content-type')?.includes('application/json') + ? await response + .json() + .then((jsonResponse) => { + if ('message' in jsonResponse) { + return jsonResponse.message; + } + return JSON.stringify(jsonResponse); + }) + .catch(() => {}) + : await response.text().catch(() => {}); + + throw new FetchResponseError(response, content ?? response.statusText); } return response.json() as Promise; diff --git a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/kibana_fetch_response_error.ts b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/kibana_fetch_response_error.ts index e26a55c6b94f..4399c651c977 100644 --- a/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/kibana_fetch_response_error.ts +++ b/x-pack/platform/packages/shared/kbn-kibana-api-cli/src/kibana_fetch_response_error.ts @@ -6,8 +6,10 @@ */ export class FetchResponseError extends Error { - constructor(public readonly response: globalThis.Response) { - super(response.statusText); + public readonly statusCode: number; + constructor(public response: globalThis.Response, content?: string) { + super(content ?? response.statusText); + this.statusCode = response.status; this.name = 'FetchResponseError'; } } diff --git a/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.test.ts b/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.test.ts index 9a2d4ffa2562..de49e8de8145 100644 --- a/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.test.ts +++ b/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.test.ts @@ -108,7 +108,6 @@ describe('createChatModel', () => { expect(InferenceChatModelMock).toHaveBeenCalledWith({ chatComplete: inferenceClient.chatComplete, connector, - logger, temperature: 0.3, }); }); diff --git a/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.ts b/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.ts index a3956127889e..bac7672b9ae8 100644 --- a/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.ts +++ b/x-pack/platform/plugins/shared/inference/server/inference_client/create_chat_model.ts @@ -39,6 +39,5 @@ export const createChatModel = async ({ ...chatModelOptions, chatComplete: client.chatComplete, connector, - logger, }); }; diff --git a/x-pack/platform/plugins/shared/observability_ai_assistant/common/convert_messages_for_inference.ts b/x-pack/platform/plugins/shared/observability_ai_assistant/common/convert_messages_for_inference.ts index 20fd3cb775f5..297a19330b5a 100644 --- a/x-pack/platform/plugins/shared/observability_ai_assistant/common/convert_messages_for_inference.ts +++ b/x-pack/platform/plugins/shared/observability_ai_assistant/common/convert_messages_for_inference.ts @@ -16,11 +16,8 @@ import { Message, MessageRole } from '.'; function safeJsonParse(jsonString: string | undefined, logger: Pick) { try { - return JSON.parse(jsonString ?? '{}'); + return JSON.parse(jsonString?.trim() ?? '{}'); } catch (error) { - logger.error( - `Failed to parse function call arguments when converting messages for inference: ${error}` - ); // if the LLM returns invalid JSON, it is likley because it is hallucinating // the function. We don't want to propogate the error about invalid JSON here. // Any errors related to the function call will be caught when the function and diff --git a/x-pack/solutions/observability/packages/kbn-genai-cli/utils/run_recipe.ts b/x-pack/solutions/observability/packages/kbn-genai-cli/utils/run_recipe.ts index 5dc810ef9302..b73b66dc7bb6 100644 --- a/x-pack/solutions/observability/packages/kbn-genai-cli/utils/run_recipe.ts +++ b/x-pack/solutions/observability/packages/kbn-genai-cli/utils/run_recipe.ts @@ -18,7 +18,7 @@ type RunRecipeCallback = (options: { export function runRecipe(callback: RunRecipeCallback) { run( - async ({ log, addCleanupTask }) => { + async ({ log, addCleanupTask, flags }) => { const controller = new AbortController(); const signal = controller.signal; @@ -32,7 +32,9 @@ export function runRecipe(callback: RunRecipeCallback) { log, signal, kibanaClient, + connectorId: flags.connectorId as string | undefined, }); + return await callback({ inferenceClient, kibanaClient, @@ -41,7 +43,12 @@ export function runRecipe(callback: RunRecipeCallback) { }); }, { - flags: {}, + flags: { + boolean: ['connectorId'], + help: ` + --connectorId Use a specific connector id + `, + }, } ); }