Skip to content

Commit c10cd56

Browse files
authored
feat: start a recipe with Llama Stack backend (#3180)
* feat: start a recipe with Llama Stack backend Signed-off-by: Philippe Martin <[email protected]> * fix: add a llamaStack dependency to pullRecipe and related Signed-off-by: Philippe Martin <[email protected]>
1 parent d400fab commit c10cd56

File tree

9 files changed

+127
-24
lines changed

9 files changed

+127
-24
lines changed

packages/backend/src/managers/application/applicationManager.spec.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { VMType } from '@shared/models/IPodman';
3131
import { POD_LABEL_MODEL_ID, POD_LABEL_RECIPE_ID } from '../../utils/RecipeConstants';
3232
import type { InferenceServer } from '@shared/models/IInference';
3333
import type { RpcExtension } from '@shared/messages/MessageProxy';
34+
import type { LlamaStackManager } from '../llama-stack/llamaStackManager';
3435
import type { ApplicationOptions } from '../../models/ApplicationOptions';
3536

3637
const taskRegistryMock = {
@@ -76,6 +77,10 @@ const recipeManager = {
7677
buildRecipe: vi.fn(),
7778
} as unknown as RecipeManager;
7879

80+
const llamaStackManager = {
81+
getLlamaStackContainer: vi.fn(),
82+
} as unknown as LlamaStackManager;
83+
7984
vi.mock('@podman-desktop/api', () => ({
8085
window: {
8186
withProgress: vi.fn(),
@@ -140,6 +145,11 @@ beforeEach(() => {
140145
id: 'fake-task',
141146
}));
142147
vi.mocked(modelsManagerMock.uploadModelToPodmanMachine).mockResolvedValue('downloaded-model-path');
148+
vi.mocked(llamaStackManager.getLlamaStackContainer).mockResolvedValue({
149+
containerId: 'container1',
150+
port: 10001,
151+
playgroundPort: 10002,
152+
});
143153
});
144154

145155
function getInitializedApplicationManager(): ApplicationManager {
@@ -152,6 +162,7 @@ function getInitializedApplicationManager(): ApplicationManager {
152162
telemetryMock,
153163
podManager,
154164
recipeManager,
165+
llamaStackManager,
155166
);
156167

157168
manager.init();
@@ -303,6 +314,9 @@ describe.each([true, false])('pullApplication, with model is %o', withModel => {
303314
: {
304315
connection: connectionMock,
305316
recipe: recipeMock,
317+
dependencies: {
318+
llamaStack: true,
319+
},
306320
};
307321
});
308322

@@ -336,6 +350,7 @@ describe.each([true, false])('pullApplication, with model is %o', withModel => {
336350
connection: connectionMock,
337351
recipe: recipeMock,
338352
model: withModel ? remoteModelMock : undefined,
353+
dependencies: applicationOptions.dependencies,
339354
},
340355
{
341356
'test-label': 'test-value',
@@ -364,7 +379,7 @@ describe.each([true, false])('pullApplication, with model is %o', withModel => {
364379
expect(containerEngine.createContainer).toHaveBeenCalledWith('test-engine-id', {
365380
Image: recipeImageInfoMock.id,
366381
name: expect.any(String),
367-
Env: [],
382+
Env: withModel ? [] : ['MODEL_ENDPOINT=http://host.containers.internal:10001'],
368383
HealthCheck: undefined,
369384
HostConfig: undefined,
370385
Detach: true,
@@ -411,6 +426,7 @@ describe.each([true, false])('pullApplication, with model is %o', withModel => {
411426
connection: connectionMock,
412427
recipe: recipeMock,
413428
model: withModel ? remoteModelMock : undefined,
429+
dependencies: applicationOptions.dependencies,
414430
},
415431
{
416432
'test-label': 'test-value',
@@ -439,7 +455,9 @@ describe.each([true, false])('pullApplication, with model is %o', withModel => {
439455
expect(containerEngine.createContainer).toHaveBeenCalledWith('test-engine-id', {
440456
Image: recipeImageInfoMock.id,
441457
name: expect.any(String),
442-
Env: withModel ? ['MODEL_ENDPOINT=http://host.containers.internal:56001'] : [],
458+
Env: withModel
459+
? ['MODEL_ENDPOINT=http://host.containers.internal:56001']
460+
: ['MODEL_ENDPOINT=http://host.containers.internal:10001'],
443461
HealthCheck: undefined,
444462
HostConfig: undefined,
445463
Detach: true,

packages/backend/src/managers/application/applicationManager.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { RECIPE_START_ROUTE } from '../../registries/NavigationRegistry';
5454
import type { RpcExtension } from '@shared/messages/MessageProxy';
5555
import { TaskRunner } from '../TaskRunner';
5656
import { getInferenceType } from '../../utils/inferenceUtils';
57+
import type { LlamaStackManager } from '../llama-stack/llamaStackManager';
5758
import { isApplicationOptionsWithModelInference, type ApplicationOptions } from '../../models/ApplicationOptions';
5859

5960
export class ApplicationManager extends Publisher<ApplicationState[]> implements Disposable {
@@ -71,6 +72,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
7172
private telemetry: TelemetryLogger,
7273
private podManager: PodManager,
7374
private recipeManager: RecipeManager,
75+
private llamaStackManager: LlamaStackManager,
7476
) {
7577
super(rpcExtension, MSG_APPLICATIONS_STATE_UPDATE, () => this.getApplicationsState());
7678
this.#applications = new ApplicationRegistry<ApplicationState>();
@@ -282,7 +284,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
282284
...labels,
283285
'pod-id': podInfo.Id,
284286
}));
285-
await this.createContainerAndAttachToPod(options, podInfo, components, modelPath);
287+
await this.createContainerAndAttachToPod(options, podInfo, components, modelPath, labels);
286288
return podInfo;
287289
},
288290
);
@@ -293,6 +295,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
293295
podInfo: PodInfo,
294296
components: RecipeComponents,
295297
modelPath: string | undefined,
298+
labels?: { [key: string]: string },
296299
): Promise<void> {
297300
const vmType = options.connection.vmType ?? VMType.UNKNOWN;
298301
// temporary check to set Z flag or not - to be removed when switching to podman 5
@@ -327,6 +330,15 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
327330
envs = [`MODEL_ENDPOINT=${endPoint}`];
328331
}
329332
}
333+
} else if (options.dependencies?.llamaStack) {
334+
let stack = await this.llamaStackManager.getLlamaStackContainer();
335+
if (!stack) {
336+
await this.llamaStackManager.createLlamaStackContainer(options.connection, labels ?? {});
337+
stack = await this.llamaStackManager.getLlamaStackContainer();
338+
}
339+
if (stack) {
340+
envs = [`MODEL_ENDPOINT=http://host.containers.internal:${stack.port}`];
341+
}
330342
}
331343
if (image.ports.length > 0) {
332344
healthcheck = {

packages/backend/src/models/ApplicationOptions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@
1818

1919
import type { ContainerProviderConnection } from '@podman-desktop/api';
2020
import type { ModelInfo } from '@shared/models/IModelInfo';
21-
import type { Recipe } from '@shared/models/IRecipe';
21+
import type { Recipe, RecipeDependencies } from '@shared/models/IRecipe';
2222

2323
export type ApplicationOptions = ApplicationOptionsDefault | ApplicationOptionsWithModelInference;
2424

2525
export interface ApplicationOptionsDefault {
2626
connection: ContainerProviderConnection;
2727
recipe: Recipe;
28+
dependencies?: RecipeDependencies;
2829
}
2930

3031
export type ApplicationOptionsWithModelInference = ApplicationOptionsDefault & {

packages/backend/src/studio-api-impl.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,12 +246,14 @@ export class StudioApiImpl implements StudioAPI {
246246
opts = {
247247
connection,
248248
recipe,
249+
dependencies: options.dependencies,
249250
model,
250251
};
251252
} else {
252253
opts = {
253254
connection,
254255
recipe,
256+
dependencies: options.dependencies,
255257
};
256258
}
257259

packages/backend/src/studio.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ export class Studio {
358358
this.#telemetry,
359359
this.#podManager,
360360
this.#recipeManager,
361+
this.#llamaStackManager,
361362
);
362363
this.#applicationManager.init();
363364
this.#extensionContext.subscriptions.push(this.#applicationManager);

packages/frontend/src/lib/RecipeCardTags.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { gte } from 'semver';
2222
const USE_CASES = ['natural-language-processing', 'audio', 'computer-vision'];
2323
const LANGUAGES = ['java', 'javascript', 'python'];
2424
export const FRAMEWORKS = ['langchain', 'langchain4j', 'quarkus', 'react', 'streamlit', 'vectordb'];
25-
export const TOOLS = ['none', 'llama-cpp', 'whisper-cpp'];
25+
export const TOOLS = ['none', 'llama-cpp', 'whisper-cpp', 'llama-stack'];
2626

2727
// Defaulting to Podman Desktop min version we need to run
2828
let version: string = '1.8.0';

packages/frontend/src/pages/StartRecipe.spec.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ const fakeRecipe: Recipe = {
6969
categories: [],
7070
} as unknown as Recipe;
7171

72+
const fakeLlamaStackRecipe: Recipe = {
73+
id: 'dummy-llama-stack-recipe-id',
74+
backend: 'llama-stack',
75+
name: 'Dummy Llama Stack Recipe',
76+
description: 'Dummy description',
77+
categories: [],
78+
} as unknown as Recipe;
79+
7280
const fakeRecommendedModel: ModelInfo = {
7381
id: 'dummy-model-1',
7482
backend: InferenceType.LLAMA_CPP,
@@ -100,7 +108,7 @@ beforeEach(() => {
100108
router.location.query.clear();
101109

102110
vi.mocked(CatalogStore).catalog = readable<ApplicationCatalog>({
103-
recipes: [fakeRecipe],
111+
recipes: [fakeRecipe, fakeLlamaStackRecipe],
104112
models: [],
105113
categories: [],
106114
version: '',
@@ -147,7 +155,7 @@ test('Recipe Local Repository should be visible when defined', async () => {
147155
expect(span.textContent).toBe('dummy-recipe-path');
148156
});
149157

150-
test('Submit button should be disabled when no model is selected', async () => {
158+
test('Submit button should be disabled when model is required and no model is selected', async () => {
151159
vi.mocked(ModelsInfoStore).modelsInfo = readable([]);
152160

153161
render(StartRecipe, {
@@ -159,6 +167,18 @@ test('Submit button should be disabled when no model is selected', async () => {
159167
expect(button).toBeDisabled();
160168
});
161169

170+
test('Submit button should be enabled when model is not required', async () => {
171+
vi.mocked(ModelsInfoStore).modelsInfo = readable([]);
172+
173+
render(StartRecipe, {
174+
recipeId: 'dummy-llama-stack-recipe-id',
175+
});
176+
177+
const button = screen.getByTitle(`Start ${fakeLlamaStackRecipe.name} recipe`);
178+
expect(button).toBeDefined();
179+
expect(button).toBeEnabled();
180+
});
181+
162182
test('First recommended model should be selected as default model', async () => {
163183
const { container } = render(StartRecipe, {
164184
recipeId: 'dummy-recipe-id',
@@ -265,6 +285,29 @@ test('Submit button should call requestPullApplication with proper arguments', a
265285
connection: containerProviderConnection,
266286
recipeId: fakeRecipe.id,
267287
modelId: fakeRecommendedModel.id,
288+
dependencies: {
289+
llamaStack: false,
290+
},
291+
});
292+
});
293+
});
294+
295+
test('Submit button should call requestPullApplication with proper arguments for llama-stack recipe', async () => {
296+
render(StartRecipe, {
297+
recipeId: 'dummy-llama-stack-recipe-id',
298+
});
299+
300+
const button = screen.getByTitle(`Start ${fakeLlamaStackRecipe.name} recipe`);
301+
expect(button).toBeEnabled();
302+
await fireEvent.click(button);
303+
304+
await vi.waitFor(() => {
305+
expect(studioClient.requestPullApplication).toHaveBeenCalledWith({
306+
connection: containerProviderConnection,
307+
recipeId: fakeLlamaStackRecipe.id,
308+
dependencies: {
309+
llamaStack: true,
310+
},
268311
});
269312
});
270313
});

packages/frontend/src/pages/StartRecipe.svelte

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { faFolder, faRocket, faUpRightFromSquare, faWarning } from '@fortawesome/free-solid-svg-icons';
33
import { catalog } from '/@/stores/catalog';
44
import Fa from 'svelte-fa';
5-
import type { Recipe } from '@shared/models/IRecipe';
5+
import type { Recipe, RecipePullOptions, RecipePullOptionsWithModelInference } from '@shared/models/IRecipe';
66
import type { LocalRepository } from '@shared/models/ILocalRepository';
77
import { findLocalRepositoryByRecipeId } from '/@/utils/localRepositoriesUtils';
88
import { localRepositories } from '/@/stores/localRepositories';
@@ -53,6 +53,16 @@ let completed: boolean = $state(false);
5353
5454
let errorMsg: string | undefined = $state(undefined);
5555
56+
let formValid = $derived.by<boolean>((): boolean => {
57+
if (!recipe) {
58+
return false;
59+
}
60+
if (!isModelNeeded(recipe)) {
61+
return true;
62+
}
63+
return !!model;
64+
});
65+
5666
$effect(() => {
5767
// Select default connection
5868
if (!containerProviderConnection && startedContainerProviderConnectionInfo.length > 0) {
@@ -100,16 +110,22 @@ function populateModelFromTasks(trackedTasks: Task[]): void {
100110
}
101111
102112
async function submit(): Promise<void> {
103-
if (!recipe || !model) return;
113+
if (!recipe || !formValid) return;
104114
105115
errorMsg = undefined;
106116
107117
try {
108-
const trackingId = await studioClient.requestPullApplication({
118+
const options: RecipePullOptions = {
109119
recipeId: $state.snapshot(recipe.id),
110-
modelId: $state.snapshot(model.id),
111120
connection: $state.snapshot(containerProviderConnection),
112-
});
121+
dependencies: {
122+
llamaStack: recipe.backend === 'llama-stack',
123+
},
124+
};
125+
if (model) {
126+
(options as RecipePullOptionsWithModelInference).modelId = $state.snapshot(model.id);
127+
}
128+
const trackingId = await studioClient.requestPullApplication(options);
113129
router.location.query.set('trackingId', trackingId);
114130
} catch (err: unknown) {
115131
console.error('Something wrong while trying to create the inference server.', err);
@@ -124,6 +140,10 @@ export function goToUpPage(): void {
124140
function handleOnClick(): void {
125141
router.goto(`/recipe/${recipeId}/running`);
126142
}
143+
144+
function isModelNeeded(recipe: Recipe): boolean {
145+
return recipe.backend !== 'llama-stack';
146+
}
127147
</script>
128148

129149
<FormPage
@@ -183,17 +203,18 @@ function handleOnClick(): void {
183203
bind:value={containerProviderConnection}
184204
containerProviderConnections={startedContainerProviderConnectionInfo} />
185205
{/if}
186-
187-
<!-- model form -->
188-
<label for="select-model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]"
189-
>Model</label>
190-
<ModelSelect bind:value={model} disabled={loading} recommended={recipe.recommended} models={models} />
191-
{#if model && model.file === undefined}
192-
<div class="text-gray-800 text-sm flex items-center">
193-
<Fa class="mr-2" icon={faWarning} />
194-
<span role="alert"
195-
>The selected model will be downloaded. This action can take some time depending on your connection</span>
196-
</div>
206+
{#if isModelNeeded(recipe)}
207+
<!-- model form -->
208+
<label for="select-model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]"
209+
>Model</label>
210+
<ModelSelect bind:value={model} disabled={loading} recommended={recipe.recommended} models={models} />
211+
{#if model && model.file === undefined}
212+
<div class="text-gray-800 text-sm flex items-center">
213+
<Fa class="mr-2" icon={faWarning} />
214+
<span role="alert"
215+
>The selected model will be downloaded. This action can take some time depending on your connection</span>
216+
</div>
217+
{/if}
197218
{/if}
198219
</div>
199220

@@ -209,7 +230,7 @@ function handleOnClick(): void {
209230
title="Start {recipe.name} recipe"
210231
inProgress={loading}
211232
on:click={submit}
212-
disabled={!model || loading || !containerProviderConnection}
233+
disabled={!formValid || loading || !containerProviderConnection}
213234
icon={faRocket}>
214235
Start {recipe.name} recipe
215236
</Button>

packages/shared/src/models/IRecipe.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,17 @@ export type RecipePullOptions = RecipePullOptionsDefault | RecipePullOptionsWith
2424
export interface RecipePullOptionsDefault {
2525
connection?: ContainerProviderConnectionInfo;
2626
recipeId: string;
27+
dependencies?: RecipeDependencies;
2728
}
2829

2930
export type RecipePullOptionsWithModelInference = RecipePullOptionsDefault & {
3031
modelId: string;
3132
};
3233

34+
export interface RecipeDependencies {
35+
llamaStack?: boolean;
36+
}
37+
3338
export function isRecipePullOptionsWithModelInference(
3439
options: RecipePullOptions,
3540
): options is RecipePullOptionsWithModelInference {

0 commit comments

Comments
 (0)