feat(compose): support AI models in Compose specification

This commit is contained in:
EgerDev 2026-09-10 20:28:28 -07:00
parent 9fa98de9c1
commit 29cdc815db
4 changed files with 257 additions and 0 deletions

View File

@ -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<string, unknown>) =>
({
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();
});

View File

@ -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: (

View File

@ -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" },

View File

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