/* eslint-disable @typescript-eslint/no-explicit-any */ import axios from 'axios'; import type { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; import { Readable } from 'stream'; import createClient from 'openapi-fetch'; import type { Client } from 'openapi-fetch'; import { decode } from 'jsonwebtoken'; import type { paths } from '../types/api.types'; import { Routes } from '../types/routes.types'; import { getSdkIdentifier } from '../utils/version'; import { objectToSnake, toCamel } from 'ts-case-convert'; import type { ObjectToSnake, ObjectToCamel } from 'ts-case-convert'; import { GalileoAPIError, isGalileoAPIStandardErrorData } from '../types/errors.types'; // Type guards for snake_case and camelCase conversion type ValidatedSnakeCase = ObjectToSnake extends TTarget ? TTarget : never; type ValidatedCamelCase = ObjectToCamel extends TTarget ? TTarget : never; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; function objectToCamelPreservingUuids(obj: unknown): unknown { if (obj === null || obj === undefined || typeof obj !== 'object') return obj; if (obj instanceof Uint8Array || obj instanceof Date) return obj; if (Array.isArray(obj)) return obj.map(objectToCamelPreservingUuids); const result: Record = {}; for (const [k, v] of Object.entries(obj as Record)) { result[UUID_RE.test(k) ? k : toCamel(k)] = objectToCamelPreservingUuids(v); } return result; } export enum RequestMethod { GET = 'GET', PATCH = 'PATCH', POST = 'POST', PUT = 'PUT', DELETE = 'DELETE' } export const GENERIC_ERROR_MESSAGE = 'This error has been automatically tracked. Please try again.'; export class BaseClient { private _apiUrl: string = ''; protected token: string = ''; protected client: Client | undefined = undefined; protected get apiUrl(): string { return this._apiUrl; } protected set apiUrl(url: string) { this._apiUrl = url.replace(/\/$/, ''); } /** * Make an HTTP request to the Galileo API and return the raw Axios response. */ public async makeRequestRaw( request_method: Method, endpoint: Routes, data?: string | Record | null, params?: Record, extraHeaders?: Record ): Promise> { await this.refreshTokenIfNeeded(endpoint); let headers: Record = { 'X-Galileo-SDK': getSdkIdentifier() }; if (this.token) { headers = { ...this.getAuthHeader(this.token), ...headers }; } headers = { ...headers, ...extraHeaders }; const endpointPath = `${this.apiUrl}/${endpoint .replace( '{project_id}', params && 'project_id' in params ? (params.project_id as string) : '' ) .replace( '{log_stream_id}', params && 'log_stream_id' in params ? (params.log_stream_id as string) : '' ) .replace( '{run_id}', params && 'run_id' in params ? (params.run_id as string) : '' ) .replace( '{job_id}', params && 'job_id' in params ? (params.job_id as string) : '' ) .replace( '{dataset_id}', params && 'dataset_id' in params ? (params.dataset_id as string) : '' ) .replace( '{experiment_id}', params && 'experiment_id' in params ? (params.experiment_id as string) : '' ) .replace( '{template_id}', params && 'template_id' in params ? (params.template_id as string) : '' ) .replace( '{version}', params && 'version' in params ? (params.version as string) : '' ) .replace( '{trace_id}', params && 'trace_id' in params ? (params.trace_id as string) : '' ) .replace( '{session_id}', params && 'session_id' in params ? (params.session_id as string) : '' ) .replace( '{span_id}', params && 'span_id' in params ? (params.span_id as string) : '' ) .replace( '{user_id}', params && 'user_id' in params ? (params.user_id as string) : '' ) .replace( '{scorer_id}', params && 'scorer_id' in params ? (params.scorer_id as string) : '' ) .replace( '{group_id}', params && 'group_id' in params ? (params.group_id as string) : '' ) .replace( '{tag_id}', params && 'tag_id' in params ? (params.tag_id as string) : '' ) .replace( '{version_index}', params && 'version_index' in params ? (params.version_index as string) : '' )}`; const config: AxiosRequestConfig = { method: request_method, url: endpointPath, params, headers, data }; const response = await axios.request(config); return response; } public async makeRequestWithConversion< SourceCamelType extends object, SourceSnakeType extends object, ResponseSnakeType extends object, ResponseCamelType extends object >( request_method: Method, endpoint: Routes, data: SourceCamelType, params?: Record ) { const request = this.convertToSnakeCase( data ); const response = await this.makeRequest( request_method, endpoint, request, params ); return this.convertToCamelCase( response ); } public async makeRequest( request_method: Method, endpoint: Routes, data?: string | Record | null, params?: Record, extraHeaders?: Record ): Promise { try { const response = await this.makeRequestRaw( request_method, endpoint, data, params, extraHeaders ); this.validateAxiosResponse(response); return response.data; } catch (error) { return await this.validateError(error); } } public async makeStreamingRequest( request_method: Method, endpoint: Routes, data?: string | Record | null, params?: Record, extraHeaders?: Record ): Promise { await this.refreshTokenIfNeeded(endpoint); let headers: Record = { 'X-Galileo-SDK': getSdkIdentifier() }; if (this.token) { headers = { ...this.getAuthHeader(this.token), ...headers }; } headers = { ...headers, ...extraHeaders }; const endpointPath = `${this.apiUrl}/${endpoint .replace( '{project_id}', params && 'project_id' in params ? (params.project_id as string) : '' ) .replace( '{log_stream_id}', params && 'log_stream_id' in params ? (params.log_stream_id as string) : '' ) .replace( '{run_id}', params && 'run_id' in params ? (params.run_id as string) : '' ) .replace( '{dataset_id}', params && 'dataset_id' in params ? (params.dataset_id as string) : '' ) .replace( '{experiment_id}', params && 'experiment_id' in params ? (params.experiment_id as string) : '' ) .replace( '{template_id}', params && 'template_id' in params ? (params.template_id as string) : '' ) .replace( '{version}', params && 'version' in params ? (params.version as string) : '' ) .replace( '{user_id}', params && 'user_id' in params ? (params.user_id as string) : '' ) .replace( '{group_id}', params && 'group_id' in params ? (params.group_id as string) : '' )}`; try { const response = await axios.request({ method: request_method, url: endpointPath, params, headers, data, responseType: 'stream' }); this.validateAxiosResponse(response); return response.data; } catch (error) { return await this.validateError(error); } } public convertToSnakeCase( obj: T ): ValidatedSnakeCase { return objectToSnake(obj) as ValidatedSnakeCase; } public convertToCamelCase( obj: T ): ValidatedCamelCase { return objectToCamelPreservingUuids(obj) as ValidatedCamelCase; } protected initializeClient(): void { if (this.apiUrl && this.token) { this.client = createClient({ baseUrl: this.apiUrl, headers: { Authorization: `Bearer ${this.token}`, 'X-Galileo-SDK': getSdkIdentifier() } }); } } protected getApiUrl(projectType: string): string { let consoleUrl = process.env.GALILEO_CONSOLE_URL; if (!consoleUrl && projectType === 'gen_ai') { return 'https://api.galileo.ai'; } if (!consoleUrl) { throw new Error('❗ GALILEO_CONSOLE_URL must be set'); } if (consoleUrl.includes('localhost') || consoleUrl.includes('127.0.0.1')) { return 'http://localhost:8088'; } consoleUrl = consoleUrl .replace('app.galileo.ai', 'api.galileo.ai') .replace('console', 'api'); // remove trailing slash if (consoleUrl.endsWith('/')) { consoleUrl = consoleUrl.slice(0, -1); } return consoleUrl; } protected async healthCheck(): Promise { return await this.makeRequest( RequestMethod.GET, Routes.healthCheck ); } protected getAuthHeader(token: string): { Authorization: string } { return { Authorization: `Bearer ${token}` }; } protected validateAxiosResponse(response: AxiosResponse): void { if (response.status >= 300) { this.generateApiError(response.data, response.status); } } protected async refreshTokenIfNeeded(endpoint: Routes): Promise { if ( ![ Routes.login, Routes.apiKeyLogin, Routes.socialLogin, Routes.refreshToken ].includes(endpoint) && this.token ) { const payload = decode(this.token, { json: true }); if (payload?.exp && payload.exp < Math.floor(Date.now() / 1000)) { throw new Error( 'Token expired - refreshToken not implemented in base class' ); } } } private readStreamToString(stream: Readable): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; stream.on('data', (chunk: Buffer | string) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) ); stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); stream.on('error', reject); }); } private async validateError(error: unknown): Promise { if (axios.isAxiosError(error)) { if (error.response) { let data: unknown = error.response.data; if (data instanceof Readable) { const raw = await this.readStreamToString(data); try { data = JSON.parse(raw); } catch { data = { detail: raw }; } this.validateAxiosResponse({ ...error.response, data } as AxiosResponse); } else { this.validateAxiosResponse(error.response); } } const errorMessage = error.message || GENERIC_ERROR_MESSAGE; throw new Error(`Request failed: ${errorMessage}`); } throw error; } private generateApiError(error: unknown, statusCode?: number): never { if (error && typeof error === 'object') { if ('standard_error' in error) { if (isGalileoAPIStandardErrorData(error.standard_error)) { throw new GalileoAPIError(error.standard_error); } else { throw new Error( `❗ Something didn't go quite right. The API returned an error, but the details could not be parsed.` ); } } else if ('detail' in error) { const errorMessage = typeof error.detail === 'string' ? error.detail : Array.isArray(error.detail) && typeof error.detail[0]?.msg === 'string' ? error.detail?.[0]?.msg : GENERIC_ERROR_MESSAGE; if (statusCode) { throw new Error( `❗ Something didn't go quite right. The API returned a non-ok status code ${statusCode} with output: ${errorMessage}` ); } else { throw new Error( `❗ Something didn't go quite right. ${errorMessage}` ); } } else { throw new Error(GENERIC_ERROR_MESSAGE); } } else { throw new Error(GENERIC_ERROR_MESSAGE); } } }