Skip to content

Commit 7d4226a

Browse files
authored
feat: implement list models in REST server (#1564)
* feat: implement list models in REST server Signed-off-by: Jeff MAURY <[email protected]> * fix: run generate for typecheck Signed-off-by: Jeff MAURY <[email protected]> --------- Signed-off-by: Jeff MAURY <[email protected]>
1 parent f60bb44 commit 7d4226a

File tree

9 files changed

+291
-12
lines changed

9 files changed

+291
-12
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ build
33
__mocks__
44
coverage
55
packages/backend/media/**
6+
src-generated

api/openapi.yaml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,65 @@ paths:
3737
type: string
3838
required:
3939
- version
40+
/api/tags:
41+
get:
42+
operationId: getModels
43+
tags:
44+
- models
45+
description: List models that are available locally
46+
summary: List models that are available locally
47+
responses:
48+
'200':
49+
description: The models were successfully fetched
50+
content:
51+
application/json:
52+
schema:
53+
$ref: '#/components/schemas/ListResponse'
54+
55+
components:
56+
schemas:
57+
ListResponse:
58+
type: object
59+
description: Response from a list request
60+
properties:
61+
models:
62+
type: array
63+
items:
64+
$ref: '#/components/schemas/ListModelResponse'
65+
66+
ListModelResponse:
67+
type: object
68+
description: Response from a list request
69+
properties:
70+
name:
71+
type: string
72+
model:
73+
type: string
74+
modified_at:
75+
type: string
76+
format: date-time
77+
size:
78+
type: integer
79+
digest:
80+
type: string
81+
details:
82+
$ref: '#/components/schemas/ModelDetails'
83+
84+
ModelDetails:
85+
type: object
86+
description: Details about a model
87+
properties:
88+
parent_model:
89+
type: string
90+
format:
91+
type: string
92+
family:
93+
type: string
94+
families:
95+
type: array
96+
items:
97+
type: string
98+
parameter_size:
99+
type: string
100+
quantization_level:
101+
type: string

clean.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
rm -rf node_modules packages/backend/node_modules packages/frontend/node_modules

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"test:e2e": "cd tests/playwright && npm run test:e2e",
2626
"typecheck:shared": "tsc --noEmit --project packages/shared",
2727
"typecheck:frontend": "tsc --noEmit --project packages/frontend",
28-
"typecheck:backend": "tsc --noEmit --project packages/backend",
28+
"typecheck:backend": "yarn --cwd packages/backend typecheck",
2929
"typecheck": "npm run typecheck:shared && npm run typecheck:frontend && npm run typecheck:backend"
3030
},
3131
"resolutions": {

packages/backend/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,14 @@
5252
}
5353
},
5454
"scripts": {
55-
"build": "vite build",
55+
"generate": "yarn openapi-typescript ../../api/openapi.yaml -o src-generated/openapi.ts",
56+
"build": "yarn generate && vite build",
5657
"test": "vitest run --coverage",
5758
"test:watch": "vitest watch --coverage",
5859
"format:check": "prettier --check \"src/**/*.ts\"",
5960
"format:fix": "prettier --write \"src/**/*.ts\"",
60-
"watch": "vite --mode development build -w"
61+
"watch": "yarn generate && vite --mode development build -w",
62+
"typecheck": "yarn generate && tsc --noEmit"
6163
},
6264
"dependencies": {
6365
"@huggingface/gguf": "^0.1.9",
@@ -79,6 +81,7 @@
7981
"@types/node": "^20",
8082
"@types/postman-collection": "^3.5.10",
8183
"@types/supertest": "^6.0.2",
84+
"openapi-typescript": "^7.3.0",
8285
"supertest": "^7.0.0",
8386
"vitest": "^2.0.5"
8487
}

packages/backend/src/managers/apiServer.spec.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import request from 'supertest';
2222
import type * as podmanDesktopApi from '@podman-desktop/api';
2323
import path from 'path';
2424
import type { Server } from 'http';
25+
import type { ModelsManager } from './modelsManager';
26+
import type { EventEmitter } from 'node:events';
27+
import { once } from 'node:events';
2528

2629
class TestApiServer extends ApiServer {
2730
public override getListener(): Server | undefined {
@@ -33,16 +36,21 @@ const extensionContext = {} as unknown as podmanDesktopApi.ExtensionContext;
3336

3437
let server: TestApiServer;
3538

39+
const modelsManager = {
40+
getModelsInfo: vi.fn(),
41+
} as unknown as ModelsManager;
42+
3643
beforeEach(async () => {
37-
server = new TestApiServer(extensionContext);
44+
server = new TestApiServer(extensionContext, modelsManager);
3845
vi.spyOn(server, 'displayApiInfo').mockReturnValue();
3946
vi.spyOn(server, 'getSpecFile').mockReturnValue(path.join(__dirname, '../../../../api/openapi.yaml'));
4047
vi.spyOn(server, 'getPackageFile').mockReturnValue(path.join(__dirname, '../../../../package.json'));
4148
await server.init();
4249
});
4350

44-
afterEach(() => {
51+
afterEach(async () => {
4552
server.dispose();
53+
await once(server.getListener() as EventEmitter, 'close');
4654
});
4755

4856
test('/spec endpoint', async () => {
@@ -101,3 +109,21 @@ test('/api/wrongEndpoint', async () => {
101109
expect(server.getListener()).toBeDefined();
102110
await request(server.getListener()!).get('/api/wrongEndpoint').expect(404);
103111
});
112+
113+
test('/', async () => {
114+
expect(server.getListener()).toBeDefined();
115+
await request(server.getListener()!).get('/').expect(200);
116+
});
117+
118+
test('/api/tags', async () => {
119+
expect(server.getListener()).toBeDefined();
120+
vi.mocked(modelsManager.getModelsInfo).mockReturnValue([]);
121+
await request(server.getListener()!).get('/api/tags').expect(200);
122+
});
123+
124+
test('/api/tags returns error', async () => {
125+
expect(server.getListener()).toBeDefined();
126+
vi.mocked(modelsManager.getModelsInfo).mockRejectedValue({});
127+
const res = await request(server.getListener()!).get('/api/tags').expect(500);
128+
expect(res.body.message).toEqual('unable to get models');
129+
});

packages/backend/src/managers/apiServer.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,33 @@ import { existsSync } from 'fs';
2626
import { getFreeRandomPort } from '../utils/ports';
2727
import * as podmanDesktopApi from '@podman-desktop/api';
2828
import { readFile } from 'fs/promises';
29+
import type { ModelsManager } from './modelsManager';
30+
import type { components } from '../../src-generated/openapi';
31+
import type { ModelInfo } from '@shared/src/models/IModelInfo';
2932

3033
const DEFAULT_PORT = 10434;
3134
const SHOW_API_INFO_COMMAND = 'ai-lab.show-api-info';
3235

36+
type ListModelResponse = components['schemas']['ListModelResponse'];
37+
38+
function asListModelResponse(model: ModelInfo): ListModelResponse {
39+
return {
40+
model: model.id,
41+
name: model.name,
42+
digest: model.sha256,
43+
size: model.file?.size,
44+
modified_at: model.file?.creation?.toISOString(),
45+
details: {},
46+
};
47+
}
48+
3349
export class ApiServer implements Disposable {
3450
#listener?: Server;
3551

36-
constructor(private extensionContext: podmanDesktopApi.ExtensionContext) {}
52+
constructor(
53+
private extensionContext: podmanDesktopApi.ExtensionContext,
54+
private modelsManager: ModelsManager,
55+
) {}
3756

3857
protected getListener(): Server | undefined {
3958
return this.#listener;
@@ -47,6 +66,8 @@ export class ApiServer implements Disposable {
4766

4867
// declare routes
4968
router.get('/version', this.getVersion.bind(this));
69+
router.get('/tags', this.getModels.bind(this));
70+
app.get('/', (_res, res) => res.sendStatus(200)); //required for the ollama client to work against us
5071
app.use('/api', router);
5172
app.use('/spec', this.getSpec.bind(this));
5273

@@ -150,4 +171,22 @@ export class ApiServer implements Disposable {
150171
doErr(err);
151172
}
152173
}
174+
175+
getModels(_req: Request, res: Response): void {
176+
const doErr = (err: unknown) => {
177+
res.status(500).json({
178+
message: 'unable to get models',
179+
errors: [err],
180+
});
181+
};
182+
try {
183+
const models = this.modelsManager
184+
.getModelsInfo()
185+
.filter(model => this.modelsManager.isModelOnDisk(model.id))
186+
.map(model => asListModelResponse(model));
187+
res.status(200).json({ models: models });
188+
} catch (err: unknown) {
189+
doErr(err);
190+
}
191+
}
153192
}

packages/backend/src/studio.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ export class Studio {
328328
// Register the instance
329329
this.#rpcExtension.registerInstance<StudioApiImpl>(StudioApiImpl, this.#studioApi);
330330

331-
const apiServer = new ApiServer(this.#extensionContext);
331+
const apiServer = new ApiServer(this.#extensionContext, this.#modelsManager);
332332
await apiServer.init();
333333
this.#extensionContext.subscriptions.push(apiServer);
334334
}

0 commit comments

Comments
 (0)