diff --git a/apps/dokploy/__test__/compose/model/model.test.ts b/apps/dokploy/__test__/compose/model/model.test.ts index e8fe6f3eb..d808dcc00 100644 --- a/apps/dokploy/__test__/compose/model/model.test.ts +++ b/apps/dokploy/__test__/compose/model/model.test.ts @@ -6,7 +6,8 @@ import { import { addDomainToCompose } from "@dokploy/server/utils/docker/domain"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { expect, test, vi } from "vitest"; -import { parse, stringify } from "yaml"; +import { stringify } from "yaml"; +import composeSpec from "../../../components/shared/compose-spec.json"; vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ ...(await importOriginal< @@ -15,150 +16,98 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ execAsyncRemote: vi.fn(), })); -const modelsComposeFile = ` -services: - chat: - image: my-chat-app - models: - - llm - worker: - image: my-worker - models: - embed: - endpoint_var: EMBED_URL - model_var: EMBED_MODEL -models: - llm: - model: ai/smollm2 - context_size: 2048 - runtime_flags: - - "--verbose" - embed: - model: ai/all-minilm - name: embeddings -`; - -const expectedModels = { - llm: { - model: "ai/smollm2", - context_size: 2048, - runtime_flags: ["--verbose"], - }, - embed: { - model: "ai/all-minilm", - name: "embeddings", - }, -}; - -const expectedChatModels = ["llm"]; -const expectedWorkerModels = { - embed: { - endpoint_var: "EMBED_URL", - model_var: "EMBED_MODEL", - }, -}; - -const assertModelsPreserved = ( - spec: ComposeSpecification | null, - serviceNames: { chat: string; worker: string } = { - chat: "chat", - worker: "worker", - }, -) => { - expect(spec?.models).toEqual(expectedModels); - expect(spec?.services?.[serviceNames.chat]?.models).toEqual( - expectedChatModels, - ); - expect(spec?.services?.[serviceNames.worker]?.models).toEqual( - expectedWorkerModels, - ); -}; - -const rawCompose = (overrides?: Record) => - ({ - appName: "chat-app", - composeFile: modelsComposeFile, - composePath: "./docker-compose.yml", - composeType: "docker-compose", - isolatedDeployment: false, - isolatedDeploymentsVolume: false, - randomize: false, - serverId: null, - sourceType: "raw", - suffix: "", - ...overrides, - }) as unknown as Compose; - -test("compose without models is unchanged besides existing suffix behavior", () => { - const composeData = parse(` -services: - web: - image: nginx:latest - volumes: - - web_data:/data -volumes: - web_data: -`) as ComposeSpecification; - const updated = addSuffixToAllProperties(composeData, "testhash"); - - expect(updated.models).toBeUndefined(); - expect(updated.services).toEqual({ - "web-testhash": { - image: "nginx:latest", - volumes: ["web_data-testhash:/data"], +const modelCompose = { + services: { + app: { + image: "my-chat-app", + models: ["llm"], }, - }); - expect(updated.volumes).toEqual({ - "web_data-testhash": null, - }); + worker: { + image: "my-worker", + models: { + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }, + }, + }, + models: { + llm: { + model: "ai/smollm2", + context_size: 2048, + runtime_flags: ["--verbose"], + }, + }, +} satisfies ComposeSpecification; + +test("bundled compose schema declares models", () => { + expect(composeSpec.properties).toHaveProperty("models"); + expect(composeSpec.definitions.model.required).toEqual(["model"]); + expect(composeSpec.definitions.service.properties).toHaveProperty("models"); }); test("suffixing does not rename model identifiers or drop model config", () => { const updated = addSuffixToAllProperties( - parse(modelsComposeFile) as ComposeSpecification, + structuredClone(modelCompose), "testhash", ); - assertModelsPreserved(updated, { - chat: "chat-testhash", - worker: "worker-testhash", + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.models?.llm?.context_size).toBe(2048); + expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]); + expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]); + expect(updated.services?.["worker-testhash"]?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, }); - expect(updated.services).not.toHaveProperty("chat"); + expect(updated.services).not.toHaveProperty("app"); expect(updated.models).not.toHaveProperty("llm-testhash"); }); test("isolated deployment preserves model identifiers", () => { - assertModelsPreserved( - addAppNameToPreventCollision( - parse(modelsComposeFile) as ComposeSpecification, - "chat-app", - false, - ), + const updated = addAppNameToPreventCollision( + structuredClone(modelCompose), + "chat-app", + false, ); + + expect(updated.models?.llm?.model).toBe("ai/smollm2"); + expect(updated.services?.app?.models).toEqual(["llm"]); + expect(updated.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); }); test("raw remote compose conversion preserves models", async () => { - vi.mocked(execAsyncRemote).mockResolvedValue({ - stdout: "services:\n dropped:\n image: alpine:latest\n", - stderr: "", - }); - const converted = await addDomainToCompose( - rawCompose({ serverId: "remote-server" }), + { + appName: "chat-app", + composeFile: stringify(modelCompose, { lineWidth: 1000 }), + composePath: "./docker-compose.yml", + composeType: "docker-compose", + isolatedDeployment: false, + isolatedDeploymentsVolume: false, + randomize: false, + serverId: "remote-server", + sourceType: "raw", + suffix: "", + } as unknown as Compose, [], ); - assertModelsPreserved(converted); expect(execAsyncRemote).not.toHaveBeenCalled(); - - const written = parse( - stringify(converted, { lineWidth: 1000 }), - ) as ComposeSpecification; - assertModelsPreserved(written); -}); - -test("invalid YAML still fails at parse", async () => { - await expect( - addDomainToCompose(rawCompose({ composeFile: "services: [" }), []), - ).rejects.toThrow(); + expect(converted?.models?.llm?.model).toBe("ai/smollm2"); + expect(converted?.services?.app?.models).toEqual(["llm"]); + expect(converted?.services?.worker?.models).toEqual({ + llm: { + endpoint_var: "LLM_URL", + model_var: "LLM_MODEL", + }, + }); }); diff --git a/packages/server/src/utils/docker/types.ts b/packages/server/src/utils/docker/types.ts index 03eb8bbff..9abd8a108 100644 --- a/packages/server/src/utils/docker/types.ts +++ b/packages/server/src/utils/docker/types.ts @@ -652,18 +652,10 @@ export interface DefinitionsService { models?: | ListOfStrings | { - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^[a-zA-Z0-9._-]+$". - */ [k: string]: { endpoint_var?: string; model_var?: string; - /** - * This interface was referenced by `undefined`'s JSON-Schema definition - * via the `patternProperty` "^x-". - */ - [k: string]: unknown; + [key: `x-${string}`]: unknown; } | null; }; networks?: @@ -898,18 +890,10 @@ export interface DefinitionsConfig { export interface PropertiesModels { [k: string]: DefinitionsModel; } -/** - * This interface was referenced by `PropertiesModels`'s JSON-Schema definition - * via the `patternProperty` "^[a-zA-Z0-9._-]+$". - */ export interface DefinitionsModel { name?: string; model: string; context_size?: number; runtime_flags?: string[]; - /** - * This interface was referenced by `DefinitionsModel`'s JSON-Schema definition - * via the `patternProperty` "^x-". - */ - [k: string]: unknown; + [key: `x-${string}`]: unknown; }