Skip to content
Merged
1 change: 1 addition & 0 deletions changes/12445.enhance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Represent model service start commands as single command strings internally while preserving deprecated list-form inputs.
14 changes: 10 additions & 4 deletions docs/manager/graphql-reference/supergraph.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -10395,7 +10395,9 @@ type ModelServiceConfig
"""
startCommand: [String!] @deprecated(reason: "Use `command` instead.")

"""Shell configured for the model service."""
"""
Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.
"""
shell: String

"""Port number for the model service."""
Expand Down Expand Up @@ -10424,7 +10426,9 @@ input ModelServiceConfigInput
"""
startCommand: [String!] = null @deprecated(reason: "Use `command` instead.")

"""Shell configured for the model service."""
"""
Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.
"""
shell: String = null

"""Port number for the model service."""
Expand Down Expand Up @@ -12936,8 +12940,10 @@ input PresetModelServiceConfigInput
"""
startCommand: [String!] = null @deprecated(reason: "Use `command` instead.")

"""Shell configured for the model service."""
shell: String! = "/bin/bash"
"""
Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.
"""
shell: String = "/bin/bash"

"""Port number for the model service. Must be greater than 1."""
port: Int!
Expand Down
14 changes: 10 additions & 4 deletions docs/manager/graphql-reference/v2-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7092,7 +7092,9 @@ type ModelServiceConfig {
"""
startCommand: [String!] @deprecated(reason: "Use `command` instead.")

"""Shell configured for the model service."""
"""
Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.
"""
shell: String

"""Port number for the model service."""
Expand All @@ -7119,7 +7121,9 @@ input ModelServiceConfigInput {
"""
startCommand: [String!] = null @deprecated(reason: "Use `command` instead.")

"""Shell configured for the model service."""
"""
Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.
"""
shell: String = null

"""Port number for the model service."""
Expand Down Expand Up @@ -8639,8 +8643,10 @@ input PresetModelServiceConfigInput {
"""
startCommand: [String!] = null @deprecated(reason: "Use `command` instead.")

"""Shell configured for the model service."""
shell: String! = "/bin/bash"
"""
Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.
"""
shell: String = "/bin/bash"

"""Port number for the model service. Must be greater than 1."""
port: Int!
Expand Down
50 changes: 26 additions & 24 deletions docs/manager/rest-reference/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3462,6 +3462,7 @@
}
],
"default": null,
"description": "Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.",
"title": "Shell"
},
"port": {
Expand Down Expand Up @@ -7113,10 +7114,17 @@
"title": "Start Command"
},
"shell": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": "/bin/bash",
"description": "Shell configured for the model service.",
"title": "Shell",
"type": "string"
"description": "Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.",
"title": "Shell"
},
"port": {
"description": "Port number for the model service. Must be greater than 1.",
Expand Down Expand Up @@ -7670,38 +7678,35 @@
"start-command": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "Argv list to start the model service. ``{model_path}`` in any token is replaced per-token with the resolved ``model_path`` before launch. ``None`` falls back to the image's default CMD.",
"description": "Command string to start the model service. ``{model_path}`` is replaced with the resolved ``model_path`` before launch. ``None`` falls back to the image's default CMD.",
"examples": [
[
"python",
"service.py"
],
[
"vllm",
"serve",
"{model_path}"
]
"python service.py",
"vllm serve {model_path}"
],
"title": "Start-Command"
},
"shell": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"default": "/bin/bash",
"description": "Shell configured for the model service.",
"description": "Shell used to run the command. If set, the kernel runs `[shell, '-c', command]`; null or empty disables shell wrapping.",
"examples": [
"/bin/bash"
],
"title": "Shell",
"type": "string"
"title": "Shell"
},
"port": {
"description": "Port number for the model service. Must be greater than 1.",
Expand Down Expand Up @@ -36424,10 +36429,7 @@
"start-command": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
"type": "string"
},
{
"type": "null"
Expand Down
5 changes: 3 additions & 2 deletions src/ai/backend/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import logging
import pickle
import re
import shlex
import signal
import sys
import time
Expand Down Expand Up @@ -2505,7 +2506,7 @@ async def _apply_image_cmd_fallback(
image_command_loaded = True
if not image_command:
continue
model.service.start_command = list(image_command)
model.service.start_command = shlex.join(image_command)
return models

def _append_legacy_inference_env_args(
Expand All @@ -2521,7 +2522,7 @@ def _append_legacy_inference_env_args(
service = model.service
if service is None or not service.start_command:
continue
service.start_command = [*service.start_command, *extra_args]
service.start_command = f"{service.start_command} {shlex.join(extra_args)}"
return models

async def create_kernel(
Expand Down
61 changes: 25 additions & 36 deletions src/ai/backend/common/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import os
import shlex
import sys
from collections.abc import Mapping, MutableMapping
from pathlib import Path
Expand All @@ -19,6 +20,7 @@
from . import validators as tx
from .etcd import AsyncEtcd, ConfigScopes
from .exception import BackendAIError, ConfigurationError, ModelDefinitionValidationError
from .model_service_start_command_compat import resolve_model_service_start_command
from .types import BackendAISchema, RedisHelperConfig, SchemaValidationFailureInfo

__all__ = (
Expand Down Expand Up @@ -145,23 +147,6 @@ def snake_to_kebab_case(string: str) -> str:
DEFAULT_SHELL = "/bin/bash"


def _wrap_str_start_command_into_argv(service: Any) -> Any:
if not isinstance(service, dict):
return service
# FIXME: temporary bridge — fold the single-string `command` into the str `start_command`
# so the ModelServiceConfigDraft validator wraps it.
command = service.get("command")
service = {k: v for k, v in service.items() if k != "command"}
# Override `start_command` with `command` if both are present as start_command is deprecated.
sc = command if command is not None else service.get("start_command")
if not isinstance(sc, str):
return service
shell = service.get("shell")
if shell:
return {**service, "start_command": [shell, "-c", sc]}
return {**service, "start_command": [sc]}


class PreStartAction(BaseConfigModel):
action: str = Field(
description="The name of the pre-start action to execute.",
Expand Down Expand Up @@ -254,18 +239,21 @@ class ModelServiceConfig(BaseConfigModel):
default_factory=list,
description="List of pre-start actions to execute before starting the model service.",
)
start_command: list[str] | None = Field(
start_command: str | None = Field(
default=None,
description=(
"Argv list to start the model service. ``{model_path}`` in any "
"token is replaced per-token with the resolved ``model_path`` "
"before launch. ``None`` falls back to the image's default CMD."
"Command string to start the model service. ``{model_path}`` is "
"replaced with the resolved ``model_path`` before launch. "
"``None`` falls back to the image's default CMD."
),
examples=[["python", "service.py"], ["vllm", "serve", "{model_path}"]],
examples=["python service.py", "vllm serve {model_path}"],
)
shell: str = Field(
shell: str | None = Field(
default=DEFAULT_SHELL,
description="Shell configured for the model service.",
description=(
"Shell used to run the command. If set, the kernel runs "
"`[shell, '-c', command]`; null or empty disables shell wrapping."
),
examples=[DEFAULT_SHELL],
)
port: int = Field(
Expand All @@ -280,8 +268,8 @@ class ModelServiceConfig(BaseConfigModel):

@model_validator(mode="before")
@classmethod
def _wrap_str_start_command(cls, data: Any) -> Any:
return _wrap_str_start_command_into_argv(data)
def _resolve_start_command(cls, data: Any) -> Any:
return resolve_model_service_start_command(data)
Comment on lines 269 to +272

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about changing this model_validator mode to after? then we don't need all data here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this also includes the process of converting currently deprecated input aliases (command, start-command, and list-type start_command) to start_command: str, it seems correct for it to run as a "before" action.



class ModelMetadata(BaseConfigModel):
Expand Down Expand Up @@ -517,8 +505,7 @@ def health_check_setting(self) -> ModelHealthCheck | None:
return None

def with_args_appended(self, args: list[str]) -> ModelDefinition:
"""Return a copy with ``args`` appended to each model's
``service.start_command`` as separate argv tokens.
"""Return a copy with ``args`` appended to each model's command string.

Models with ``service is None`` are passed through unchanged;
a model whose ``start_command`` is ``None`` receives ``args``
Expand All @@ -532,9 +519,11 @@ def with_args_appended(self, args: list[str]) -> ModelDefinition:
if model.service is None:
new_models.append(model)
continue
existing = model.service.start_command or []
suffix = shlex.join(args)
existing = model.service.start_command
start_command = f"{existing} {suffix}" if existing else suffix
new_service = model.service.model_copy(
update={"start_command": existing + args},
update={"start_command": start_command},
)
new_models.append(model.model_copy(update={"service": new_service}))
return self.model_copy(update={"models": new_models})
Expand Down Expand Up @@ -567,15 +556,15 @@ def to_resolved(self) -> ModelHealthCheck:

class ModelServiceConfigDraft(BaseConfigModel):
pre_start_actions: list[PreStartAction] | None = None
start_command: list[str] | None = None
start_command: str | None = None
shell: str | None = None
port: int | None = None
health_check: ModelHealthCheckDraft | None = None

@model_validator(mode="before")
@classmethod
def _wrap_str_start_command(cls, data: Any) -> Any:
return _wrap_str_start_command_into_argv(data)
def _resolve_start_command(cls, data: Any) -> Any:
return resolve_model_service_start_command(data)

def to_resolved(self) -> ModelServiceConfig:
# Drop unset (None) scalars so the strict type's ``Field(default=...)``
Expand All @@ -584,6 +573,8 @@ def to_resolved(self) -> ModelServiceConfig:
# required fields (e.g. ``port``) surface as
# ``BackendAISchemaValidationFailed``.
payload = self.model_dump(exclude_none=True, exclude={"health_check"})
if "shell" in self.model_fields_set and self.shell is None:
payload["shell"] = None
payload["health_check"] = self.health_check.to_resolved() if self.health_check else None
return ModelServiceConfig.model_validate(payload)

Expand All @@ -601,9 +592,7 @@ def to_resolved(self) -> ModelConfig:
# ``start_command`` are resolved here, at the same moment the
# draft becomes a strict ``ModelConfig`` and ``model_path`` is
# finalized. Placeholders therefore never propagate downstream.
service.start_command = [
token.replace("{model_path}", self.model_path) for token in service.start_command
]
service.start_command = service.start_command.replace("{model_path}", self.model_path)
payload = self.model_dump(exclude_none=True, exclude={"service"})
payload["service"] = service
return ModelConfig.model_validate(payload)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,13 @@ class ModelServiceConfigInput(BaseRequestModel):
pre_start_actions: list[PreStartAction] | None = None
command: str | None = None
start_command: list[str] | None = None
shell: str | None = None
shell: str | None = Field(
default=None,
description=(
"Shell used to run the command. If set, the kernel runs "
"`[shell, '-c', command]`; null or empty disables shell wrapping."
),
)
port: int | None = None
health_check: ModelHealthCheckInput | None = None

Expand Down
5 changes: 4 additions & 1 deletion src/ai/backend/common/dto/manager/v2/deployment/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,10 @@ class ModelServiceConfigInfoDTO(BaseResponseModel):
)
shell: str | None = Field(
default="/bin/bash",
description="Shell configured for the model service.",
description=(
"Shell used to run the command. If set, the kernel runs "
"`[shell, '-c', command]`; null or empty disables shell wrapping."
),
)
port: int = Field(description="Port number for the model service.")
health_check: ModelHealthCheckInfoDTO | None = Field(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@ class PresetModelServiceConfigInput(BaseRequestModel):
default=None,
description="Deprecated. Command to start the model service. Use `command` instead.",
)
shell: str = Field(default=DEFAULT_SHELL, description="Shell configured for the model service.")
shell: str | None = Field(
default=DEFAULT_SHELL,
description=(
"Shell used to run the command. If set, the kernel runs "
"`[shell, '-c', command]`; null or empty disables shell wrapping."
),
)
port: int = Field(
gt=1, description="Port number for the model service. Must be greater than 1."
)
Expand Down
Loading
Loading