mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 10:40:07 -04:00
[Inference] Run EIS locally (#215475)
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 <arnautov.dima@gmail.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
cd8254bd26
commit
dd7ed50d9b
27 changed files with 912 additions and 56 deletions
11
scripts/eis.js
Normal file
11
scripts/eis.js
Normal file
|
@ -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');
|
|
@ -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 {
|
||||
|
|
|
@ -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<ChatCompleteAPI>;
|
||||
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<string, any>;
|
||||
|
||||
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,
|
||||
});
|
||||
|
|
|
@ -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<InferenceChatModelCallOpti
|
|||
super(args);
|
||||
this.chatComplete = args.chatComplete;
|
||||
this.connector = args.connector;
|
||||
this.logger = args.logger;
|
||||
|
||||
this.temperature = args.temperature;
|
||||
this.functionCallingMode = args.functionCallingMode;
|
||||
|
|
|
@ -16,7 +16,5 @@
|
|||
"kbn_references": [
|
||||
"@kbn/inference-common",
|
||||
"@kbn/zod",
|
||||
"@kbn/logging",
|
||||
"@kbn/logging-mocks"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -38,3 +38,12 @@ Running a recipe:
|
|||
```
|
||||
$ yarn run ts-node x-pack/solutions/observability/packages/kbn-genai-cli/recipes/hello_world.ts
|
||||
```
|
||||
|
||||
## EIS
|
||||
|
||||
You can set up a local instance of the Elastic Inference Service by running `node scripts/eis.js`.
|
||||
This starts the EIS Gateway in a Docker container, and handles certificates and configuration.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
EIS connects to external LLM providers, so you need to supply authentication. By default, the setup script will try to get credentials from Vault. Make sure you have configured Vault to point at Elastic's Infra Vault server, and that you're logged in. If you want to, you can run Vault locally and set VAULT_ADDR and VAULT_SECRET_PATH. By default the script will try to get credentials from the [Infra Vault](https://docs.elastic.dev/vault/infra-vault/home) cluster, at `secret/kibana-issues/dev/inference/*`, which is accessible for all employees.
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { run } from '@kbn/dev-cli-runner';
|
||||
import { ensureEis } from '../src/eis/ensure_eis';
|
||||
|
||||
run(({ log, addCleanupTask }) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
addCleanupTask(() => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
return ensureEis({
|
||||
log,
|
||||
signal: controller.signal,
|
||||
}).catch((error) => {
|
||||
throw new Error('Failed to start EIS', { cause: error });
|
||||
});
|
||||
});
|
|
@ -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 <TToolOptions extends ToolOptions, TStream extends boolean = false>(
|
||||
options: UnboundChatCompleteOptions<TToolOptions, TStream>
|
||||
) => {
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<InferenceCliClient> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
await execa.command(`docker info`).catch((error: ExecaError) => {
|
||||
throw new DockerUnavailableError(error);
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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<void> {
|
||||
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<boolean> {
|
||||
return await Fs.stat(filePath)
|
||||
.then((stat) => stat.isFile())
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
export async function writeTempfile(fileName: string, content: string): Promise<string> {
|
||||
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<void> {
|
||||
const dir = Path.dirname(filePath);
|
||||
|
||||
await createDirIfNotExists(dir);
|
||||
|
||||
// Write the provided ACL content to the file
|
||||
await Fs.writeFile(filePath, content, 'utf8');
|
||||
}
|
|
@ -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<CertificateFiles> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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
|
||||
`);
|
||||
}
|
|
@ -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<string, string> | 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<EisCredentials> {
|
||||
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;
|
||||
}
|
|
@ -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<EisGatewayConfig> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}`);
|
||||
}
|
|
@ -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<string[]> {
|
||||
// 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<T>(
|
||||
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<string, string>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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],
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -16,18 +16,18 @@ export async function selectConnector({
|
|||
kibanaClient,
|
||||
prompt = true,
|
||||
preferredConnectorId,
|
||||
signal,
|
||||
}: {
|
||||
log: ToolingLog;
|
||||
kibanaClient: KibanaClient;
|
||||
prompt?: boolean;
|
||||
preferredConnectorId?: string;
|
||||
signal: AbortSignal;
|
||||
}): Promise<InferenceConnector> {
|
||||
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);
|
||||
|
|
|
@ -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",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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<globalThis.RequestInit, 'body'> & { 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<T>;
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -108,7 +108,6 @@ describe('createChatModel', () => {
|
|||
expect(InferenceChatModelMock).toHaveBeenCalledWith({
|
||||
chatComplete: inferenceClient.chatComplete,
|
||||
connector,
|
||||
logger,
|
||||
temperature: 0.3,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -39,6 +39,5 @@ export const createChatModel = async ({
|
|||
...chatModelOptions,
|
||||
chatComplete: client.chatComplete,
|
||||
connector,
|
||||
logger,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -16,11 +16,8 @@ import { Message, MessageRole } from '.';
|
|||
|
||||
function safeJsonParse(jsonString: string | undefined, logger: Pick<Logger, 'error'>) {
|
||||
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
|
||||
|
|
|
@ -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
|
||||
`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue