diff --git a/src/ai/AkamaiAgentCR.test.ts b/src/ai/AkamaiAgentCR.test.ts index 633854d66..1da021bdb 100644 --- a/src/ai/AkamaiAgentCR.test.ts +++ b/src/ai/AkamaiAgentCR.test.ts @@ -60,7 +60,6 @@ describe('AkamaiAgentCR', () => { name: 'test-kb', description: 'Search the test-kb knowledge base for relevant information. Use this when you need factual information, documentation, or specific details stored in the knowledge base.', - endpoint: undefined, }, ]) }) @@ -111,7 +110,6 @@ describe('AkamaiAgentCR', () => { type: 'knowledgeBase', name: 'test-kb', description: 'Custom description for the knowledge base', - endpoint: undefined, }, ]) }) @@ -154,7 +152,6 @@ describe('AkamaiAgentCR', () => { name: 'test-kb', description: 'Search the test-kb knowledge base for relevant information. Use this when you need factual information, documentation, or specific details stored in the knowledge base.', - endpoint: undefined, }, ], }, @@ -186,7 +183,7 @@ describe('AkamaiAgentCR', () => { expect(response.spec.tools).toBeUndefined() }) - test('should preserve custom description and endpoint in response', () => { + test('should preserve custom description and apiUrl in response', () => { const requestWithDetails = { ...mockAgentRequest, spec: { @@ -196,7 +193,7 @@ describe('AkamaiAgentCR', () => { type: 'knowledgeBase', name: 'test-kb', description: 'Custom KB description', - endpoint: 'https://api.example.com/kb', + apiUrl: 'https://api.example.com/kb', }, ], }, @@ -210,7 +207,7 @@ describe('AkamaiAgentCR', () => { type: 'knowledgeBase', name: 'test-kb', description: 'Custom KB description', - endpoint: 'https://api.example.com/kb', + apiUrl: 'https://api.example.com/kb', }, ]) }) diff --git a/src/ai/AkamaiAgentCR.ts b/src/ai/AkamaiAgentCR.ts index 38c35b5a6..a98a82808 100644 --- a/src/ai/AkamaiAgentCR.ts +++ b/src/ai/AkamaiAgentCR.ts @@ -18,12 +18,23 @@ export class AkamaiAgentCR { } public spec: { foundationModel: string + foundationModelEndpoint?: string + temperature?: number + topP?: number + maxTokens?: number agentInstructions: string + routes?: Array<{ + agent: string + condition: string + apiUrl: string + apiKey?: string + }> tools?: Array<{ type: string name: string description?: string - endpoint?: string + apiUrl?: string + apiKey?: string }> } @@ -42,17 +53,32 @@ export class AkamaiAgentCR { } this.spec = { foundationModel: request.spec.foundationModel, + ...(request.spec.foundationModelEndpoint && { foundationModelEndpoint: request.spec.foundationModelEndpoint }), + ...(request.spec.temperature && { temperature: request.spec.temperature }), + ...(request.spec.topP && { topP: request.spec.topP }), + ...(request.spec.maxTokens && { maxTokens: request.spec.maxTokens }), agentInstructions: request.spec.agentInstructions, - tools: request.spec.tools?.map((tool) => ({ - type: tool.type, - name: tool.name, - description: - tool.description || - (tool.type === 'knowledgeBase' - ? `Search the ${tool.name} knowledge base for relevant information. Use this when you need factual information, documentation, or specific details stored in the knowledge base.` - : undefined), - endpoint: tool.endpoint, - })), + ...(request.spec.routes && { + routes: request.spec.routes.map((route) => ({ + agent: route.agent, + condition: route.condition, + apiUrl: route.apiUrl, + ...(route.apiKey && { apiKey: route.apiKey }), + })), + }), + ...(request.spec.tools && { + tools: request.spec.tools.map((tool) => ({ + type: tool.type, + name: tool.name, + description: + tool.description || + (tool.type === 'knowledgeBase' + ? `Search the ${tool.name} knowledge base for relevant information. Use this when you need factual information, documentation, or specific details stored in the knowledge base.` + : undefined), + ...(tool.apiUrl && { apiUrl: tool.apiUrl }), + ...(tool.apiKey && { apiKey: tool.apiKey }), + })), + }), } } @@ -67,7 +93,7 @@ export class AkamaiAgentCR { } // Transform to API response format - toApiResponse(teamId: string): AplAgentResponse { + toApiResponse(teamId: string, status?: any): AplAgentResponse { return { kind: 'AkamaiAgent', metadata: { @@ -79,15 +105,30 @@ export class AkamaiAgentCR { }, spec: { foundationModel: this.spec.foundationModel, + ...(this.spec.foundationModelEndpoint && { foundationModelEndpoint: this.spec.foundationModelEndpoint }), + ...(this.spec.temperature && { temperature: this.spec.temperature }), + ...(this.spec.topP && { topP: this.spec.topP }), + ...(this.spec.maxTokens && { maxTokens: this.spec.maxTokens }), agentInstructions: this.spec.agentInstructions, - tools: this.spec.tools?.map((tool) => ({ - type: tool.type, - name: tool.name, - ...(tool.description && { description: tool.description }), - ...(tool.endpoint && { endpoint: tool.endpoint }), - })), + ...(this.spec.routes && { + routes: this.spec.routes.map((route) => ({ + agent: route.agent, + condition: route.condition, + apiUrl: route.apiUrl, + ...(route.apiKey && { apiKey: route.apiKey }), + })), + }), + ...(this.spec.tools && { + tools: this.spec.tools.map((tool) => ({ + type: tool.type, + name: tool.name, + ...(tool.description && { description: tool.description }), + ...(tool.apiUrl && { apiUrl: tool.apiUrl }), + ...(tool.apiKey && { apiKey: tool.apiKey }), + })), + }), }, - status: { + status: status || { conditions: [ { type: 'AgentDeployed', @@ -103,15 +144,24 @@ export class AkamaiAgentCR { // Static factory method static async create(teamId: string, agentName: string, request: AplAgentRequest): Promise { const aiModels = await getAIModels() - const embeddingModel = aiModels.find( + const foundationModel = aiModels.find( (model) => model.metadata.name === request.spec.foundationModel && model.spec.modelType === 'foundation', ) - if (!embeddingModel) { + if (!foundationModel) { throw new K8sResourceNotFound('Foundation model', `Foundation model '${request.spec.foundationModel}' not found`) } - return new AkamaiAgentCR(teamId, agentName, request) + // Create enriched request with foundationModelEndpoint from the model + const enrichedRequest: AplAgentRequest = { + ...request, + spec: { + ...request.spec, + foundationModelEndpoint: foundationModel.spec.modelEndpoint, + }, + } + + return new AkamaiAgentCR(teamId, agentName, enrichedRequest) } // Static method to create from existing CR (for transformation) diff --git a/src/ai/AkamaiKnowledgeBaseCR.ts b/src/ai/AkamaiKnowledgeBaseCR.ts index 85d6d14c6..5f0c646d3 100644 --- a/src/ai/AkamaiKnowledgeBaseCR.ts +++ b/src/ai/AkamaiKnowledgeBaseCR.ts @@ -84,7 +84,7 @@ export class AkamaiKnowledgeBaseCR { } // Transform to API response format - toApiResponse(teamId: string): AplKnowledgeBaseResponse { + toApiResponse(teamId: string, status?: any): AplKnowledgeBaseResponse { return { kind: env.KNOWLEDGE_BASE_KIND as 'AkamaiKnowledgeBase', metadata: { @@ -97,7 +97,7 @@ export class AkamaiKnowledgeBaseCR { modelName: this.spec.pipelineParameters.embedding_model, sourceUrl: this.spec.pipelineParameters.url, }, - status: {}, + status: status || {}, } } diff --git a/src/ai/aiModelHandler.test.ts b/src/ai/aiModelHandler.test.ts index 8e74c9d7f..23bd89ea3 100644 --- a/src/ai/aiModelHandler.test.ts +++ b/src/ai/aiModelHandler.test.ts @@ -1,5 +1,5 @@ import { V1Deployment } from '@kubernetes/client-node' -import { getAIModels, transformK8sDeploymentToAplAIModel } from './aiModelHandler' +import { getAIModels, transformK8sWorkloadToAplAIModel } from './aiModelHandler' import * as k8s from './k8s' // Mock the k8s module @@ -7,6 +7,9 @@ jest.mock('./k8s') const mockedGetDeploymentsWithAIModelLabels = k8s.getDeploymentsWithAIModelLabels as jest.MockedFunction< typeof k8s.getDeploymentsWithAIModelLabels > +const mockedGetStatefulSetsWithAIModelLabels = k8s.getStatefulSetsWithAIModelLabels as jest.MockedFunction< + typeof k8s.getStatefulSetsWithAIModelLabels +> describe('aiModelHandler', () => { const mockDeployment: V1Deployment = { @@ -19,6 +22,7 @@ describe('aiModelHandler', () => { modelNameTitle: 'GPT-4o-mini', modelType: 'foundation', modelDimension: '1536', + 'serving.knative.dev/service': 'gpt-4-deployment', }, }, status: { @@ -47,9 +51,9 @@ describe('aiModelHandler', () => { jest.clearAllMocks() }) - describe('transformK8sDeploymentToAplAIModel', () => { + describe('transformK8sWorkloadToAplAIModel', () => { test('should transform K8s deployment to AplAIModel with all fields', () => { - const result = transformK8sDeploymentToAplAIModel(mockDeployment) + const result = transformK8sWorkloadToAplAIModel(mockDeployment) expect(result).toEqual({ kind: 'AplAIModel', @@ -57,8 +61,8 @@ describe('aiModelHandler', () => { name: 'gpt-4', }, spec: { - displayName: 'GPT-4o-mini', - modelEndpoint: 'http://gpt-4.ai-models.svc.cluster.local/openai/v1', + displayName: 'gpt-4', + modelEndpoint: 'http://gpt-4-deployment.ai-models.svc.cluster.local/openai/v1', modelType: 'foundation', modelDimension: 1536, }, @@ -97,10 +101,10 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(deploymentWithModelName) + const result = transformK8sWorkloadToAplAIModel(deploymentWithModelName) expect(result.metadata.name).toBe('custom-model-name') - expect(result.spec.displayName).toBe('GPT-4o-mini') + expect(result.spec.displayName).toBe('custom-model-name') }) test('should use modelName from labels when deployment name is missing', () => { @@ -116,10 +120,10 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(deploymentWithoutName) + const result = transformK8sWorkloadToAplAIModel(deploymentWithoutName) expect(result.metadata.name).toBe('custom-model-name') - expect(result.spec.displayName).toBe('GPT-4o-mini') + expect(result.spec.displayName).toBe('custom-model-name') }) test('should handle deployment without labels', () => { @@ -131,7 +135,7 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(deploymentWithoutLabels) + const result = transformK8sWorkloadToAplAIModel(deploymentWithoutLabels) expect(result.metadata.name).toBe('test-deployment') expect(result.spec.modelType).toBeUndefined() @@ -150,7 +154,7 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(deploymentWithoutDimension) + const result = transformK8sWorkloadToAplAIModel(deploymentWithoutDimension) expect(result.spec.modelDimension).toBeUndefined() }) @@ -164,9 +168,9 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(deploymentWithoutNamespace) + const result = transformK8sWorkloadToAplAIModel(deploymentWithoutNamespace) - expect(result.spec.modelEndpoint).toBe('http://gpt-4.undefined.svc.cluster.local/openai/v1') + expect(result.spec.modelEndpoint).toBe('http://gpt-4-deployment.undefined.svc.cluster.local/openai/v1') }) test('should handle deployment without status conditions', () => { @@ -178,7 +182,7 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(deploymentWithoutConditions) + const result = transformK8sWorkloadToAplAIModel(deploymentWithoutConditions) expect(result.status.conditions).toEqual([]) expect(result.status.phase).toBe('NotReady') @@ -193,13 +197,13 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(notReadyDeployment) + const result = transformK8sWorkloadToAplAIModel(notReadyDeployment) expect(result.status.phase).toBe('NotReady') }) test('should set phase to Ready when has ready replicas', () => { - const result = transformK8sDeploymentToAplAIModel(mockDeployment) + const result = transformK8sWorkloadToAplAIModel(mockDeployment) expect(result.status.phase).toBe('Ready') }) @@ -221,7 +225,7 @@ describe('aiModelHandler', () => { }, } - const result = transformK8sDeploymentToAplAIModel(deploymentWithFalseCondition) + const result = transformK8sWorkloadToAplAIModel(deploymentWithFalseCondition) expect(result.status.conditions?.[0]?.status).toBe(false) }) @@ -231,16 +235,17 @@ describe('aiModelHandler', () => { status: mockDeployment.status, } as V1Deployment - const result = transformK8sDeploymentToAplAIModel(deploymentWithoutMetadata) + const result = transformK8sWorkloadToAplAIModel(deploymentWithoutMetadata) expect(result.metadata.name).toBe('') - expect(result.spec.modelEndpoint).toBe('http://.undefined.svc.cluster.local/openai/v1') + expect(result.spec.modelEndpoint).toBe('http://.undefined.svc.cluster.local/v1') }) }) describe('getAIModels', () => { test('should return transformed AI models from deployments', async () => { mockedGetDeploymentsWithAIModelLabels.mockResolvedValue([mockDeployment]) + mockedGetStatefulSetsWithAIModelLabels.mockResolvedValue([]) const result = await getAIModels() @@ -248,18 +253,21 @@ describe('aiModelHandler', () => { expect(result[0].kind).toBe('AplAIModel') expect(result[0].metadata.name).toBe('gpt-4') expect(mockedGetDeploymentsWithAIModelLabels).toHaveBeenCalledTimes(1) + expect(mockedGetStatefulSetsWithAIModelLabels).toHaveBeenCalledTimes(1) }) - test('should return empty array when no deployments found', async () => { + test('should return empty array when no deployments or statefulsets found', async () => { mockedGetDeploymentsWithAIModelLabels.mockResolvedValue([]) + mockedGetStatefulSetsWithAIModelLabels.mockResolvedValue([]) const result = await getAIModels() expect(result).toEqual([]) expect(mockedGetDeploymentsWithAIModelLabels).toHaveBeenCalledTimes(1) + expect(mockedGetStatefulSetsWithAIModelLabels).toHaveBeenCalledTimes(1) }) - test('should handle multiple deployments', async () => { + test('should handle multiple deployments and statefulsets', async () => { const secondDeployment = { ...mockDeployment, metadata: { @@ -274,6 +282,7 @@ describe('aiModelHandler', () => { } mockedGetDeploymentsWithAIModelLabels.mockResolvedValue([mockDeployment, secondDeployment]) + mockedGetStatefulSetsWithAIModelLabels.mockResolvedValue([]) const result = await getAIModels() @@ -285,6 +294,7 @@ describe('aiModelHandler', () => { test('should propagate errors from k8s module', async () => { const error = new Error('K8s API error') mockedGetDeploymentsWithAIModelLabels.mockRejectedValue(error) + mockedGetStatefulSetsWithAIModelLabels.mockResolvedValue([]) await expect(getAIModels()).rejects.toThrow('K8s API error') }) diff --git a/src/ai/aiModelHandler.ts b/src/ai/aiModelHandler.ts index 25bc133a5..d5ed7525a 100644 --- a/src/ai/aiModelHandler.ts +++ b/src/ai/aiModelHandler.ts @@ -1,9 +1,11 @@ -import { V1Deployment } from '@kubernetes/client-node' +import { V1Deployment, V1StatefulSet } from '@kubernetes/client-node' import { AplAIModelResponse } from 'src/otomi-models' -import { getDeploymentsWithAIModelLabels } from './k8s' +import { getDeploymentsWithAIModelLabels, getStatefulSetsWithAIModelLabels } from './k8s' -function getConditions(deployment: V1Deployment) { - return (deployment.status?.conditions || []).map((condition) => ({ +type K8sWorkload = V1Deployment | V1StatefulSet + +function getConditions(workload: K8sWorkload) { + return (workload.status?.conditions || []).map((condition) => ({ lastTransitionTime: condition.lastTransitionTime?.toISOString(), message: condition.message, reason: condition.reason, @@ -12,14 +14,16 @@ function getConditions(deployment: V1Deployment) { })) } -export function transformK8sDeploymentToAplAIModel(deployment: V1Deployment): AplAIModelResponse { - const labels = deployment.metadata?.labels || {} - const modelName = labels.modelName || deployment.metadata?.name || '' - const modelNameTitle = labels.modelNameTitle || deployment.metadata?.name || '' - const endpointName = labels.app || deployment.metadata?.name || '' +export function transformK8sWorkloadToAplAIModel(workload: K8sWorkload): AplAIModelResponse { + const labels = workload.metadata?.labels || {} + const modelName = labels.modelName || workload.metadata?.name || '' + const endpointName = labels['serving.knative.dev/service'] || workload.metadata?.name || '' + + // Use /openai/v1 for Knative services, /v1 for regular deployments + const endpointPath = labels['serving.knative.dev/service'] ? '/openai/v1' : '/v1' - // Convert K8s deployment conditions to schema format - const conditions = getConditions(deployment) + // Convert K8s workload conditions to schema format + const conditions = getConditions(workload) return { kind: 'AplAIModel', @@ -27,19 +31,24 @@ export function transformK8sDeploymentToAplAIModel(deployment: V1Deployment): Ap name: modelName, }, spec: { - displayName: modelNameTitle, - modelEndpoint: `http://${endpointName}.${deployment.metadata?.namespace}.svc.cluster.local/openai/v1`, + displayName: modelName, + modelEndpoint: `http://${endpointName}.${workload.metadata?.namespace}.svc.cluster.local${endpointPath}`, modelType: labels.modelType as 'foundation' | 'embedding', ...(labels.modelDimension && { modelDimension: parseInt(labels.modelDimension, 10) }), }, status: { conditions, - phase: deployment.status?.readyReplicas && deployment.status.readyReplicas > 0 ? 'Ready' : 'NotReady', + phase: workload.status?.readyReplicas && workload.status.readyReplicas > 0 ? 'Ready' : 'NotReady', }, } } export async function getAIModels(): Promise { - const deployments = await getDeploymentsWithAIModelLabels() - return deployments.map(transformK8sDeploymentToAplAIModel) + const [deployments, statefulSets] = await Promise.all([ + getDeploymentsWithAIModelLabels(), + getStatefulSetsWithAIModelLabels(), + ]) + + const allWorkloads: K8sWorkload[] = [...deployments, ...statefulSets] + return allWorkloads.map(transformK8sWorkloadToAplAIModel) } diff --git a/src/ai/k8s.ts b/src/ai/k8s.ts index 0a5de010b..d8d2d782a 100644 --- a/src/ai/k8s.ts +++ b/src/ai/k8s.ts @@ -1,4 +1,11 @@ -import { AppsV1Api, CustomObjectsApi, KubeConfig, KubernetesObject, V1Deployment } from '@kubernetes/client-node' +import { + AppsV1Api, + CustomObjectsApi, + KubeConfig, + KubernetesObject, + V1Deployment, + V1StatefulSet, +} from '@kubernetes/client-node' import Debug from 'debug' import { KubernetesListObject } from '@kubernetes/client-node/dist/types' @@ -7,6 +14,12 @@ const debug = Debug('otomi:ai:k8s') let appsApiClient: AppsV1Api | undefined let customObjectsApiClient: CustomObjectsApi | undefined +export interface KubernetesObjectWithStatus extends KubernetesObject { + status: { + [key: string]: any + } +} + // Export function to reset api clients for testing export function resetApiClients(): void { appsApiClient = undefined @@ -50,6 +63,21 @@ export async function getDeploymentsWithAIModelLabels(): Promise } } +export async function getStatefulSetsWithAIModelLabels(): Promise { + const appsApi = getAppsApiClient() + + try { + const labelSelector = 'modelType,modelName' + const result = await appsApi.listStatefulSetForAllNamespaces({ labelSelector }) + + debug(`Found ${result.items.length} AI model statefulsets`) + return result.items + } catch (e) { + debug('Error fetching statefulsets from Kubernetes:', e) + return [] + } +} + export async function getKnowledgeBaseCNPGClusters(): Promise { const customObjectsApi = getCustomObjectsApiClient() @@ -70,3 +98,89 @@ export async function getKnowledgeBaseCNPGClusters(): Promise { + const customObjectsApi = getCustomObjectsApiClient() + + try { + const result = (await customObjectsApi.getNamespacedCustomObject({ + group: 'akamai.io', + version: 'v1alpha1', + namespace, + plural: 'akamaiagents', + name, + })) as KubernetesObject + + debug(`Found AkamaiAgent CR: ${namespace}/${name}`) + return result + } catch (e: any) { + if (e.statusCode === 404) { + debug(`AkamaiAgent CR not found: ${namespace}/${name}`) + return null + } + debug(`Error fetching AkamaiAgent CR ${namespace}/${name}:`, e) + throw e + } +} + +export async function getAkamaiKnowledgeBaseCR(namespace: string, name: string): Promise { + const customObjectsApi = getCustomObjectsApiClient() + + try { + const result = (await customObjectsApi.getNamespacedCustomObject({ + group: 'akamai.io', + version: 'v1alpha1', + namespace, + plural: 'akamaiknowledgebases', + name, + })) as KubernetesObject + + debug(`Found AkamaiKnowledgeBase CR: ${namespace}/${name}`) + return result + } catch (e: any) { + if (e.statusCode === 404) { + debug(`AkamaiKnowledgeBase CR not found: ${namespace}/${name}`) + return null + } + debug(`Error fetching AkamaiKnowledgeBase CR ${namespace}/${name}:`, e) + throw e + } +} + +export async function listAkamaiAgentCRs(namespace: string): Promise { + const customObjectsApi = getCustomObjectsApiClient() + + try { + const result = (await customObjectsApi.listNamespacedCustomObject({ + group: 'akamai.io', + version: 'v1alpha1', + namespace, + plural: 'akamaiagents', + })) as KubernetesListObject + + debug(`Found ${result.items.length} AkamaiAgent CRs in namespace ${namespace}`) + return result.items + } catch (e: any) { + debug(`Error listing AkamaiAgent CRs in ${namespace}:`, e) + return [] + } +} + +export async function listAkamaiKnowledgeBaseCRs(namespace: string): Promise { + const customObjectsApi = getCustomObjectsApiClient() + + try { + const result = (await customObjectsApi.listNamespacedCustomObject({ + group: 'akamai.io', + version: 'v1alpha1', + namespace, + plural: 'akamaiknowledgebases', + })) as KubernetesListObject + + debug(`Found ${result.items.length} AkamaiKnowledgeBase CRs in namespace ${namespace}`) + return result.items + } catch (e: any) { + debug(`Error listing AkamaiKnowledgeBase CRs in ${namespace}:`, e) + return [] + } +} diff --git a/src/api.authz.test.ts b/src/api.authz.test.ts index 5377db62c..c9aa90a18 100644 --- a/src/api.authz.test.ts +++ b/src/api.authz.test.ts @@ -856,7 +856,7 @@ describe('API authz tests', () => { }) test('platform admin can get knowledge bases', async () => { - jest.spyOn(otomiStack, 'getAplKnowledgeBases').mockReturnValue([]) + jest.spyOn(otomiStack, 'getAplKnowledgeBases').mockResolvedValue([]) await agent .get('/alpha/teams/team1/kb') .set('Authorization', `Bearer ${platformAdminToken}`) @@ -865,7 +865,7 @@ describe('API authz tests', () => { }) test('team admin can get knowledge bases', async () => { - jest.spyOn(otomiStack, 'getAplKnowledgeBases').mockReturnValue([]) + jest.spyOn(otomiStack, 'getAplKnowledgeBases').mockResolvedValue([]) await agent .get('/alpha/teams/team1/kb') .set('Authorization', `Bearer ${teamAdminToken}`) @@ -874,7 +874,7 @@ describe('API authz tests', () => { }) test('team member can get knowledge bases', async () => { - jest.spyOn(otomiStack, 'getAplKnowledgeBases').mockReturnValue([]) + jest.spyOn(otomiStack, 'getAplKnowledgeBases').mockResolvedValue([]) await agent .get('/alpha/teams/team1/kb') .set('Authorization', `Bearer ${teamMemberToken}`) @@ -1017,7 +1017,7 @@ describe('API authz tests', () => { }) test('platform admin can get agents', async () => { - jest.spyOn(otomiStack, 'getAplAgents').mockReturnValue([]) + jest.spyOn(otomiStack, 'getAplAgents').mockResolvedValue([]) await agent .get('/alpha/teams/team1/agents') .set('Authorization', `Bearer ${platformAdminToken}`) @@ -1026,7 +1026,7 @@ describe('API authz tests', () => { }) test('team admin can get agents', async () => { - jest.spyOn(otomiStack, 'getAplAgents').mockReturnValue([]) + jest.spyOn(otomiStack, 'getAplAgents').mockResolvedValue([]) await agent .get('/alpha/teams/team1/agents') .set('Authorization', `Bearer ${teamAdminToken}`) @@ -1035,7 +1035,7 @@ describe('API authz tests', () => { }) test('team member can get agents', async () => { - jest.spyOn(otomiStack, 'getAplAgents').mockReturnValue([]) + jest.spyOn(otomiStack, 'getAplAgents').mockResolvedValue([]) await agent .get('/alpha/teams/team1/agents') .set('Authorization', `Bearer ${teamMemberToken}`) diff --git a/src/api/alpha/teams/{teamId}/agents.ts b/src/api/alpha/teams/{teamId}/agents.ts index 48c965d0e..df6c7bcc4 100644 --- a/src/api/alpha/teams/{teamId}/agents.ts +++ b/src/api/alpha/teams/{teamId}/agents.ts @@ -8,10 +8,10 @@ const debug = Debug('otomi:api:alpha:teams:agents') * GET /alpha/teams/{teamId}/agents * Get all agents for a team */ -export const getAplAgents = (req: OpenApiRequestExt, res: Response): void => { +export const getAplAgents = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId } = req.params debug(`getAplAgents(${teamId})`) - const v = req.otomi.getAplAgents(decodeURIComponent(teamId)) + const v = await req.otomi.getAplAgents(decodeURIComponent(teamId)) res.json(v) } diff --git a/src/api/alpha/teams/{teamId}/kb.ts b/src/api/alpha/teams/{teamId}/kb.ts index 3a7cc5aeb..75daa4ded 100644 --- a/src/api/alpha/teams/{teamId}/kb.ts +++ b/src/api/alpha/teams/{teamId}/kb.ts @@ -8,10 +8,10 @@ const debug = Debug('otomi:api:alpha:teams:kb') * GET /alpha/teams/{teamId}/kb * Get all knowledge bases for a team */ -export const getAplKnowledgeBases = (req: OpenApiRequestExt, res: Response): void => { +export const getAplKnowledgeBases = async (req: OpenApiRequestExt, res: Response): Promise => { const { teamId } = req.params debug(`getAplKnowledgeBases(${teamId})`) - const v = req.otomi.getAplKnowledgeBases(decodeURIComponent(teamId)) + const v = await req.otomi.getAplKnowledgeBases(decodeURIComponent(teamId)) res.json(v) } diff --git a/src/openapi/agent.yaml b/src/openapi/agent.yaml index 55e11c97e..8a5ce0c39 100644 --- a/src/openapi/agent.yaml +++ b/src/openapi/agent.yaml @@ -22,11 +22,52 @@ AplAgentSpec: foundationModel: type: string description: Name of the foundation model - example: "meta-llama-3" + example: "llama3-1" + foundationModelEndpoint: + type: string + description: HTTP endpoint URL for the foundation model + example: "http://llama-3-1-8b-predictor.team-admin.svc.cluster.local/openai/v1" + temperature: + type: number + description: Temperature parameter for model inference (controls randomness) + example: 0.7 + topP: + type: number + description: Top-p parameter for model inference (nucleus sampling) + example: 0.9 + maxTokens: + type: number + description: Maximum number of tokens to generate + example: 2048 agentInstructions: type: string description: Custom instructions for the agent - example: "You are a helpful assistant that provides concise answers." + example: "You are a helpful assistant for App Platform for LKE documentation. Give clear answers to the users." + routes: + type: array + description: Routing configuration to other agents based on conditions + items: + type: object + properties: + agent: + type: string + description: Name of the agent to route to + example: "kubernetes-expert" + condition: + type: string + description: Condition that triggers routing to this agent + example: "If the question is about Kubernetes" + apiUrl: + type: string + description: API URL of the target agent + example: "https://my-other-agent.lke496760.akamai-apl.net" + apiKey: + type: string + description: API key for authenticating with the target agent + required: + - agent + - condition + - apiUrl tools: type: array description: Tools available to the agent @@ -35,19 +76,23 @@ AplAgentSpec: properties: type: type: string - description: Type of the tool + description: Type of the tool (knowledgeBase, mcpServer, subWorkflow, function) example: "knowledgeBase" name: type: string description: Name of the tool resource - example: "company-docs" + example: "apl-techdocs" description: type: string description: Description of what the tool does - example: "Search the company-docs knowledge base for relevant information" - endpoint: + example: "Search the apl-techdocs knowledge base for relevant information. Use this when you need factual information, documentation, or specific details stored in the knowledge base." + apiUrl: + type: string + description: API URL for the tool (for mcpServer and subWorkflow types) + example: "https://my-mcp.com" + apiKey: type: string - description: Optional endpoint URL for the tool + description: API key for authenticating with the tool (for mcpServer and subWorkflow types) required: - type - name diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index cfabff407..62c7e85e0 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -133,6 +133,9 @@ import { getSealedSecretsPEM, sealedSecretManifest } from './utils/sealedSecretU import { getKeycloakUsers, isValidUsername } from './utils/userUtils' import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils' import { fetchChartYaml, fetchWorkloadCatalog, NewHelmChartValues, sparseCloneChart } from './utils/workloadUtils' +import { listAkamaiAgentCRs, listAkamaiKnowledgeBaseCRs } from './ai/k8s' +import { AkamaiAgentCR } from './ai/AkamaiAgentCR' +import { AkamaiKnowledgeBaseCR } from './ai/AkamaiKnowledgeBaseCR' interface ExcludedApp extends App { managed: boolean @@ -2234,12 +2237,12 @@ export default class OtomiStack { if (!knowledgeBase) { throw new NotExistError(`Knowledge base ${name} not found in team ${teamId}`) } - return knowledgeBase as AplKnowledgeBaseResponse + return AkamaiKnowledgeBaseCR.fromCR(knowledgeBase).toApiResponse(teamId) } - getAplKnowledgeBases(teamId: string): AplKnowledgeBaseResponse[] { - const files = this.fileStore.getTeamResourcesByKindAndTeamId('AkamaiKnowledgeBase', teamId) - return Array.from(files.values()) as AplKnowledgeBaseResponse[] + async getAplKnowledgeBases(teamId: string): Promise { + const knowledgeBases = await listAkamaiKnowledgeBaseCRs(`team-${teamId}`) + return knowledgeBases.map((kb) => AkamaiKnowledgeBaseCR.fromCR(kb).toApiResponse(teamId, kb.status)) } private async saveTeamKnowledgeBase(aplTeamObject: AplTeamObject): Promise { @@ -2276,7 +2279,12 @@ export default class OtomiStack { ? merge(cloneDeep(existing.spec), data.spec) : { foundationModel: data.spec?.foundationModel ?? existing.spec.foundationModel, + foundationModelEndpoint: data.spec?.foundationModelEndpoint ?? existing.spec.foundationModelEndpoint, + temperature: data.spec?.temperature ?? existing.spec.temperature, + topP: data.spec?.topP ?? existing.spec.topP, + maxTokens: data.spec?.maxTokens ?? existing.spec.maxTokens, agentInstructions: data.spec?.agentInstructions ?? existing.spec.agentInstructions, + routes: (data.spec?.routes ?? existing.spec.routes) as typeof existing.spec.routes, tools: (data.spec?.tools ?? existing.spec.tools) as typeof existing.spec.tools, } @@ -2299,12 +2307,12 @@ export default class OtomiStack { if (!agent) { throw new NotExistError(`Agent ${name} not found in team ${teamId}`) } - return agent as AplAgentResponse + return AkamaiAgentCR.fromCR(agent).toApiResponse(teamId) } - getAplAgents(teamId: string): AplAgentResponse[] { - const files = this.fileStore.getTeamResourcesByKindAndTeamId('AkamaiAgent', teamId) - return Array.from(files.values()) as AplAgentResponse[] + async getAplAgents(teamId: string): Promise { + const agents = await listAkamaiAgentCRs(`team-${teamId}`) + return agents.map((agent) => AkamaiAgentCR.fromCR(agent).toApiResponse(teamId, agent.status)) } getAllAplAgents(): AplAgentResponse[] {