From 29cdc815db49c52125b4a9f026cbc3e829485fbb Mon Sep 17 00:00:00 2001 From: EgerDev <285544328+EgerDev@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:28:28 -0700 Subject: [PATCH 1/2] feat(compose): support AI models in Compose specification --- .../__test__/compose/model/model.test.ts | 164 ++++++++++++++++++ .../dokploy/components/shared/code-editor.tsx | 6 + .../components/shared/compose-spec.json | 51 ++++++ packages/server/src/utils/docker/types.ts | 36 ++++ 4 files changed, 257 insertions(+) create mode 100644 apps/dokploy/__test__/compose/model/model.test.ts diff --git a/apps/dokploy/__test__/compose/model/model.test.ts b/apps/dokploy/__test__/compose/model/model.test.ts new file mode 100644 index 000000000..e8fe6f3eb --- /dev/null +++ b/apps/dokploy/__test__/compose/model/model.test.ts @@ -0,0 +1,164 @@ +import type { Compose, ComposeSpecification } from "@dokploy/server"; +import { + addAppNameToPreventCollision, + addSuffixToAllProperties, +} from "@dokploy/server"; +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"; + +vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@dokploy/server/utils/process/execAsync") + >()), + 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"], + }, + }); + expect(updated.volumes).toEqual({ + "web_data-testhash": null, + }); +}); + +test("suffixing does not rename model identifiers or drop model config", () => { + const updated = addSuffixToAllProperties( + parse(modelsComposeFile) as ComposeSpecification, + "testhash", + ); + + assertModelsPreserved(updated, { + chat: "chat-testhash", + worker: "worker-testhash", + }); + expect(updated.services).not.toHaveProperty("chat"); + expect(updated.models).not.toHaveProperty("llm-testhash"); +}); + +test("isolated deployment preserves model identifiers", () => { + assertModelsPreserved( + addAppNameToPreventCollision( + parse(modelsComposeFile) as ComposeSpecification, + "chat-app", + false, + ), + ); +}); + +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" }), + [], + ); + + 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(); +}); diff --git a/apps/dokploy/components/shared/code-editor.tsx b/apps/dokploy/components/shared/code-editor.tsx index 9640e3428..47b52b2ec 100644 --- a/apps/dokploy/components/shared/code-editor.tsx +++ b/apps/dokploy/components/shared/code-editor.tsx @@ -30,6 +30,7 @@ const dockerComposeServices = [ { label: "networks", type: "keyword", info: "Define networks" }, { label: "configs", type: "keyword", info: "Define configuration files" }, { label: "secrets", type: "keyword", info: "Define secrets" }, + { label: "models", type: "keyword", info: "Define AI models" }, ].map((opt) => ({ ...opt, apply: ( @@ -83,6 +84,11 @@ const dockerComposeServiceOptions = [ }, { label: "restart", type: "keyword", info: "Restart policy" }, { label: "networks", type: "keyword", info: "Networks to join" }, + { + label: "models", + type: "keyword", + info: "AI models to use, referencing top-level models", + }, ].map((opt) => ({ ...opt, apply: ( diff --git a/apps/dokploy/components/shared/compose-spec.json b/apps/dokploy/components/shared/compose-spec.json index 92036daa5..e5bd752a9 100644 --- a/apps/dokploy/components/shared/compose-spec.json +++ b/apps/dokploy/components/shared/compose-spec.json @@ -35,6 +35,16 @@ "additionalProperties": false }, + "models": { + "id": "#/properties/models", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/model" + } + } + }, + "networks": { "id": "#/properties/networks", "type": "object", @@ -335,6 +345,30 @@ "mem_swappiness": { "type": ["integer", "string"] }, "memswap_limit": { "type": ["number", "string"] }, "network_mode": { "type": "string" }, + "models": { + "oneOf": [ + { "$ref": "#/definitions/list_of_strings" }, + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9._-]+$": { + "oneOf": [ + { + "type": "object", + "properties": { + "endpoint_var": { "type": "string" }, + "model_var": { "type": "string" } + }, + "additionalProperties": false, + "patternProperties": { "^x-": {} } + }, + { "type": "null" } + ] + } + } + } + ] + }, "networks": { "oneOf": [ { "$ref": "#/definitions/list_of_strings" }, @@ -890,6 +924,23 @@ "patternProperties": { "^x-": {} } }, + "model": { + "id": "#/definitions/model", + "type": "object", + "properties": { + "name": { "type": "string" }, + "model": { "type": "string" }, + "context_size": { "type": "integer" }, + "runtime_flags": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["model"], + "additionalProperties": false, + "patternProperties": { "^x-": {} } + }, + "command": { "oneOf": [ { "type": "null" }, diff --git a/packages/server/src/utils/docker/types.ts b/packages/server/src/utils/docker/types.ts index 8f93f9cf5..03eb8bbff 100644 --- a/packages/server/src/utils/docker/types.ts +++ b/packages/server/src/utils/docker/types.ts @@ -497,6 +497,7 @@ export interface ComposeSpecification { */ include?: DefinitionsInclude[]; services?: PropertiesServices; + models?: PropertiesModels; networks?: PropertiesNetworks; volumes?: PropertiesVolumes; secrets?: PropertiesSecrets; @@ -648,6 +649,23 @@ export interface DefinitionsService { mem_swappiness?: number; memswap_limit?: number | string; network_mode?: string; + 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; + } | null; + }; networks?: | ListOfStrings | { @@ -877,3 +895,21 @@ export interface DefinitionsConfig { */ [k: string]: unknown; } +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; +} From 45dcdfc5be28317cc5e954f56911953acf65e5e7 Mon Sep 17 00:00:00 2001 From: EgerDev <285544328+EgerDev@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:11:40 -0700 Subject: [PATCH 2/2] test(compose): tighten models types and regression tests --- .../__test__/compose/model/model.test.ts | 199 +++++++----------- packages/server/src/utils/docker/types.ts | 20 +- 2 files changed, 76 insertions(+), 143 deletions(-) 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; }