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; +}