Skip to content

Commit d86dfb4

Browse files
CasLubbersmerllsvcAPLBot
authored
feat: get ai models api (#800)
* feat: added API spec for agent inference platform * feat: add agent crud api and fix comments * feat: add ai model get api * feat: add authz tests --------- Co-authored-by: Matthias Erll <merll@akamai.com> Co-authored-by: svcAPLBot <174728082+svcAPLBot@users.noreply.github.com>
1 parent 1eea743 commit d86dfb4

File tree

8 files changed

+144
-10
lines changed

8 files changed

+144
-10
lines changed

src/ai/aiModelHandler.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { V1Deployment } from '@kubernetes/client-node'
2+
import { AplAIModelResponse } from 'src/otomi-models'
3+
import { getDeploymentsWithAIModelLabels } from './k8s'
4+
5+
function getConditions(deployment: V1Deployment) {
6+
return (deployment.status?.conditions || []).map((condition) => ({
7+
lastTransitionTime: condition.lastTransitionTime?.toISOString(),
8+
message: condition.message,
9+
reason: condition.reason,
10+
status: condition.status === 'True',
11+
type: condition.type,
12+
}))
13+
}
14+
15+
export function transformK8sDeploymentToAplAIModel(deployment: V1Deployment): AplAIModelResponse {
16+
const labels = deployment.metadata?.labels || {}
17+
const modelName = deployment.metadata?.name || labels.modelName
18+
19+
// Convert K8s deployment conditions to schema format
20+
const conditions = getConditions(deployment)
21+
22+
return {
23+
kind: 'AplAIModel',
24+
metadata: {
25+
name: modelName,
26+
},
27+
spec: {
28+
displayName: modelName,
29+
modelEndpoint: `http://${deployment.metadata?.name}.${deployment.metadata?.namespace}.svc.cluster.local`,
30+
modelType: labels.modelType as 'foundation' | 'embedding',
31+
...(labels.modelDimension && { modelDimension: parseInt(labels.modelDimension, 10) }),
32+
},
33+
status: {
34+
conditions,
35+
phase: deployment.status?.readyReplicas && deployment.status.readyReplicas > 0 ? 'Ready' : 'NotReady',
36+
},
37+
}
38+
}
39+
40+
export async function getAIModels(): Promise<AplAIModelResponse[]> {
41+
const deployments = await getDeploymentsWithAIModelLabels()
42+
return deployments.map(transformK8sDeploymentToAplAIModel)
43+
}

src/ai/k8s.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { AppsV1Api, KubeConfig, V1Deployment } from '@kubernetes/client-node'
2+
import Debug from 'debug'
3+
4+
const debug = Debug('otomi:ai:k8s')
5+
6+
let appsApiClient: AppsV1Api | undefined
7+
8+
function getAppsApiClient(): AppsV1Api {
9+
if (appsApiClient) return appsApiClient
10+
const kc = new KubeConfig()
11+
kc.loadFromDefault()
12+
appsApiClient = kc.makeApiClient(AppsV1Api)
13+
return appsApiClient
14+
}
15+
16+
export async function getDeploymentsWithAIModelLabels(): Promise<V1Deployment[]> {
17+
const appsApi = getAppsApiClient()
18+
19+
try {
20+
const labelSelector = 'modelType,modelName'
21+
const result = await appsApi.listDeploymentForAllNamespaces({ labelSelector })
22+
23+
debug(`Found ${result.items.length} AI model deployments`)
24+
return result.items
25+
} catch (e) {
26+
debug('Error fetching deployments from Kubernetes:', e)
27+
return []
28+
}
29+
}

src/api.authz.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,4 +769,37 @@ describe('API authz tests', () => {
769769
.expect('Content-Type', /json/)
770770
})
771771
})
772+
773+
describe('AI Models endpoint tests', () => {
774+
test('platform admin can get AI models', async () => {
775+
jest.spyOn(otomiStack, 'getAllAIModels').mockResolvedValue([])
776+
await agent
777+
.get('/alpha/ai/models')
778+
.set('Authorization', `Bearer ${platformAdminToken}`)
779+
.expect(200)
780+
.expect('Content-Type', /json/)
781+
})
782+
783+
test('team admin can get AI models', async () => {
784+
jest.spyOn(otomiStack, 'getAllAIModels').mockResolvedValue([])
785+
await agent
786+
.get('/alpha/ai/models')
787+
.set('Authorization', `Bearer ${teamAdminToken}`)
788+
.expect(200)
789+
.expect('Content-Type', /json/)
790+
})
791+
792+
test('team member can get AI models', async () => {
793+
jest.spyOn(otomiStack, 'getAllAIModels').mockResolvedValue([])
794+
await agent
795+
.get('/alpha/ai/models')
796+
.set('Authorization', `Bearer ${teamMemberToken}`)
797+
.expect(200)
798+
.expect('Content-Type', /json/)
799+
})
800+
801+
test('anonymous user cannot get AI models', async () => {
802+
await agent.get('/alpha/ai/models').expect(401)
803+
})
804+
})
772805
})

src/api/alpha/ai/models.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Debug from 'debug'
2+
import { Operation, OperationHandlerArray } from 'express-openapi'
3+
import { OpenApiRequestExt } from 'src/otomi-models'
4+
5+
const debug = Debug('otomi:api:alpha:ai:models')
6+
7+
export default function (): OperationHandlerArray {
8+
const get: Operation = [
9+
/* business middleware not expressible by OpenAPI documentation goes here */
10+
async ({ otomi }: OpenApiRequestExt, res): Promise<void> => {
11+
debug('getAllAIModels')
12+
const v = await otomi.getAllAIModels()
13+
res.json(v)
14+
},
15+
]
16+
const api = {
17+
get,
18+
}
19+
return api
20+
}

src/openapi/aiModel.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
AplAIModel:
1+
AIModel:
2+
type: object
23
x-acl:
3-
platformAdmin:
4-
- read-any
5-
teamAdmin:
6-
- read
7-
teamMember:
8-
- read
4+
platformAdmin: [read-any]
5+
teamAdmin: [read-any]
6+
teamMember: [read-any]
7+
properties: {}
98

109
AplAIModelSpec:
1110
x-acl:
@@ -38,4 +37,5 @@ AplAIModelSpec:
3837
example: 4096
3938
required:
4039
- modelEndpoint
40+
- modelType
4141
type: object

src/openapi/api.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2475,7 +2475,7 @@ paths:
24752475
get:
24762476
operationId: getAIModels
24772477
description: Get available shared AI models (foundation or embedding)
2478-
x-aclSchema: AplAIModel
2478+
x-aclSchema: AIModel
24792479
responses:
24802480
'200':
24812481
description: Successfully obtained shared AI models
@@ -2827,7 +2827,7 @@ components:
28272827
properties:
28282828
kind:
28292829
type: string
2830-
enum: [AplKnowledgeBase]
2830+
enum: [AplAIModel]
28312831
spec:
28322832
$ref: 'aiModel.yaml#/AplAIModelSpec'
28332833
required:
@@ -3127,6 +3127,8 @@ components:
31273127
$ref: 'testrepoconnect.yaml#/TestRepoConnect'
31283128
InternalRepoUrls:
31293129
$ref: 'internalRepoUrls.yaml#/InternalRepoUrls'
3130+
AIModel:
3131+
$ref: 'aiModel.yaml#/AIModel'
31303132
Team:
31313133
$ref: 'team.yaml#/Team'
31323134
TeamAuthz:

src/otomi-models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type AppList = components['schemas']['AppList']
88
export type Backup = components['schemas']['Backup']
99
export type AplBackupRequest = components['schemas']['AplBackupRequest']
1010
export type AplBackupResponse = components['schemas']['AplBackupResponse']
11+
export type AplAIModelResponse = components['schemas']['AplAIModelResponse']
1112
export type Kubecfg = components['schemas']['Kubecfg']
1213
export type K8sService = components['schemas']['K8sService']
1314
export type Netpol = components['schemas']['Netpol']

src/otomi-stack.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node'
1+
import { CoreV1Api, KubeConfig, User as k8sUser, V1ObjectReference } from '@kubernetes/client-node'
22
import Debug from 'debug'
33

44
import { getRegions, ObjectStorageKeyRegions } from '@linode/api-v4'
@@ -12,6 +12,7 @@ import { AlreadyExists, ForbiddenError, HttpError, OtomiError, PublicUrlExists,
1212
import getRepo, { getWorktreeRepo, Git } from 'src/git'
1313
import { cleanSession, getSessionStack } from 'src/middleware'
1414
import {
15+
AplAIModelResponse,
1516
AplBackupRequest,
1617
AplBackupResponse,
1718
AplBuildRequest,
@@ -114,6 +115,7 @@ import { getSealedSecretsPEM, sealedSecretManifest, SealedSecretManifestType } f
114115
import { getKeycloakUsers, isValidUsername } from './utils/userUtils'
115116
import { ObjectStorageClient } from './utils/wizardUtils'
116117
import { fetchChartYaml, fetchWorkloadCatalog, NewHelmChartValues, sparseCloneChart } from './utils/workloadUtils'
118+
import { getAIModels } from './ai/aiModelHandler'
117119

118120
interface ExcludedApp extends App {
119121
managed: boolean
@@ -2112,6 +2114,10 @@ export default class OtomiStack {
21122114
return names
21132115
}
21142116

2117+
async getAllAIModels(): Promise<AplAIModelResponse[]> {
2118+
return getAIModels()
2119+
}
2120+
21152121
async getK8sServices(teamId: string): Promise<Array<K8sService>> {
21162122
if (env.isDev) return []
21172123
// const teams = user.teams.map((name) => {

0 commit comments

Comments
 (0)