Skip to content

Commit d2ac536

Browse files
authored
Merge pull request #605 from docker/vllm-metal
feat: add vllm-metal
2 parents a77d682 + d3bb1e8 commit d2ac536

10 files changed

Lines changed: 682 additions & 79 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@ vendor/
1111
llamacpp/build
1212
llamacpp/install
1313

14+
# vllm-metal build artifacts
15+
.vllm-metal/
16+
vllm-metal-macos-arm64-*.tar.gz
17+
1418
.DS_Store

Makefile

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ DOCKER_BUILD_ARGS := \
2626
BUILD_DMR ?= 1
2727

2828
# Main targets
29-
.PHONY: build run clean test integration-tests test-docker-ce-installation docker-build docker-build-multiplatform docker-run docker-build-vllm docker-run-vllm docker-build-sglang docker-run-sglang docker-run-impl help validate lint docker-build-diffusers docker-run-diffusers
29+
.PHONY: build run clean test integration-tests test-docker-ce-installation docker-build docker-build-multiplatform docker-run docker-build-vllm docker-run-vllm docker-build-sglang docker-run-sglang docker-run-impl help validate lint docker-build-diffusers docker-run-diffusers vllm-metal-build vllm-metal-install vllm-metal-dev vllm-metal-clean
3030
# Default target
3131
.DEFAULT_GOAL := build
3232

@@ -144,7 +144,78 @@ docker-run-impl:
144144
DEBUG="${DEBUG}" \
145145
scripts/docker-run.sh
146146

147-
# Show help
147+
# vllm-metal (macOS ARM64 only)
148+
# The tarball is self-contained: includes a standalone Python 3.12 + all packages.
149+
VLLM_METAL_RELEASE ?= v0.1.0-20260126-121650
150+
VLLM_METAL_INSTALL_DIR := $(HOME)/.docker/model-runner/vllm-metal
151+
VLLM_METAL_TARBALL := vllm-metal-macos-arm64-$(VLLM_METAL_RELEASE).tar.gz
152+
153+
vllm-metal-build:
154+
@if [ -f "$(VLLM_METAL_TARBALL)" ]; then \
155+
echo "Tarball already exists: $(VLLM_METAL_TARBALL)"; \
156+
else \
157+
echo "Building vllm-metal tarball..."; \
158+
scripts/build-vllm-metal-tarball.sh $(VLLM_METAL_RELEASE) $(VLLM_METAL_TARBALL); \
159+
echo "Tarball created: $(VLLM_METAL_TARBALL)"; \
160+
fi
161+
162+
vllm-metal-install:
163+
@VERSION_FILE="$(VLLM_METAL_INSTALL_DIR)/.vllm-metal-version"; \
164+
if [ -f "$$VERSION_FILE" ] && [ "$$(cat "$$VERSION_FILE")" = "$(VLLM_METAL_RELEASE)" ]; then \
165+
echo "vllm-metal $(VLLM_METAL_RELEASE) already installed"; \
166+
exit 0; \
167+
fi; \
168+
if [ ! -f "$(VLLM_METAL_TARBALL)" ]; then \
169+
echo "Error: $(VLLM_METAL_TARBALL) not found. Run 'make vllm-metal-build' first."; \
170+
exit 1; \
171+
fi; \
172+
echo "Installing vllm-metal to $(VLLM_METAL_INSTALL_DIR)..."; \
173+
rm -rf "$(VLLM_METAL_INSTALL_DIR)"; \
174+
mkdir -p "$(VLLM_METAL_INSTALL_DIR)"; \
175+
tar -xzf "$(VLLM_METAL_TARBALL)" -C "$(VLLM_METAL_INSTALL_DIR)"; \
176+
echo "$(VLLM_METAL_RELEASE)" > "$$VERSION_FILE"; \
177+
echo "vllm-metal $(VLLM_METAL_RELEASE) installed successfully!"
178+
179+
vllm-metal-dev:
180+
@if [ -z "$(VLLM_METAL_PATH)" ]; then \
181+
echo "Usage: make vllm-metal-dev VLLM_METAL_PATH=../vllm-metal"; \
182+
exit 1; \
183+
fi
184+
@PYTHON_BIN=""; \
185+
if command -v python3.12 >/dev/null 2>&1; then \
186+
PYTHON_BIN="python3.12"; \
187+
elif command -v python3 >/dev/null 2>&1; then \
188+
version=$$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+'); \
189+
if [ "$$version" = "3.12" ]; then \
190+
PYTHON_BIN="python3"; \
191+
fi; \
192+
fi; \
193+
if [ -z "$$PYTHON_BIN" ]; then \
194+
echo "Error: Python 3.12 required"; \
195+
echo "Install with: brew install python@3.12"; \
196+
exit 1; \
197+
fi; \
198+
echo "Installing vllm-metal from $(VLLM_METAL_PATH)..."; \
199+
rm -rf "$(VLLM_METAL_INSTALL_DIR)"; \
200+
$$PYTHON_BIN -m venv "$(VLLM_METAL_INSTALL_DIR)"; \
201+
. "$(VLLM_METAL_INSTALL_DIR)/bin/activate" && \
202+
VLLM_VERSION="0.13.0" && \
203+
WORK_DIR=$$(mktemp -d) && \
204+
curl -fsSL -o "$$WORK_DIR/vllm.tar.gz" "https://github.com/vllm-project/vllm/releases/download/v$$VLLM_VERSION/vllm-$$VLLM_VERSION.tar.gz" && \
205+
tar -xzf "$$WORK_DIR/vllm.tar.gz" -C "$$WORK_DIR" && \
206+
pip install -r "$$WORK_DIR/vllm-$$VLLM_VERSION/requirements/cpu.txt" && \
207+
pip install -e "$(VLLM_METAL_PATH)" && \
208+
pip install -r "$$WORK_DIR/vllm-$$VLLM_VERSION/requirements/common.txt" && \
209+
rm -rf "$$WORK_DIR" && \
210+
echo "dev" > "$(VLLM_METAL_INSTALL_DIR)/.vllm-metal-version"; \
211+
echo "vllm-metal dev installed from $(VLLM_METAL_PATH)"
212+
213+
vllm-metal-clean:
214+
@echo "Removing vllm-metal installation and build artifacts..."
215+
rm -rf "$(VLLM_METAL_INSTALL_DIR)"
216+
rm -f $(VLLM_METAL_TARBALL)
217+
@echo "vllm-metal cleaned!"
218+
148219
help:
149220
@echo "Available targets:"
150221
@echo " build - Build the Go application"
@@ -164,6 +235,10 @@ help:
164235
@echo " docker-run-sglang - Run SGLang Docker container"
165236
@echo " docker-build-diffusers - Build Diffusers Docker image"
166237
@echo " docker-run-diffusers - Run Diffusers Docker container"
238+
@echo " vllm-metal-build - Build vllm-metal tarball locally (macOS ARM64)"
239+
@echo " vllm-metal-install - Install vllm-metal from local tarball"
240+
@echo " vllm-metal-dev - Install vllm-metal from local source (editable)"
241+
@echo " vllm-metal-clean - Clean vllm-metal installation and tarball"
167242
@echo " help - Show this help message"
168243
@echo ""
169244
@echo "Backend configuration options:"
@@ -174,3 +249,11 @@ help:
174249
@echo " make run LLAMA_ARGS=\"--verbose --jinja -ngl 999 --ctx-size 2048\""
175250
@echo " make run LOCAL_LLAMA=1"
176251
@echo " make docker-run LLAMA_ARGS=\"--verbose --jinja -ngl 999 --threads 4 --ctx-size 2048\""
252+
@echo ""
253+
@echo "vllm-metal (macOS ARM64 only):"
254+
@echo " 1. Auto-pull from Docker Hub (clean dev installs first: make vllm-metal-clean):"
255+
@echo " make run"
256+
@echo " 2. Build and install from tarball:"
257+
@echo " make vllm-metal-build && make vllm-metal-install && make run"
258+
@echo " 3. Install from local source (for development, requires Python 3.12):"
259+
@echo " make vllm-metal-dev VLLM_METAL_PATH=../vllm-metal && make run"

cmd/cli/desktop/api.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ type OpenAIChatResponse struct {
3434
Role string `json:"role,omitempty"`
3535
ReasoningContent string `json:"reasoning_content,omitempty"`
3636
} `json:"delta"`
37+
Message struct {
38+
Content string `json:"content"`
39+
Role string `json:"role,omitempty"`
40+
} `json:"message"`
3741
Index int `json:"index"`
3842
FinishReason string `json:"finish_reason"`
3943
} `json:"choices"`

cmd/cli/desktop/desktop.go

Lines changed: 78 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -479,74 +479,103 @@ func (c *Client) ChatWithMessagesContext(ctx context.Context, model string, conv
479479
TotalTokens int `json:"total_tokens"`
480480
}
481481

482-
scanner := bufio.NewScanner(resp.Body)
483-
for scanner.Scan() {
484-
// Check if context was cancelled
485-
select {
486-
case <-ctx.Done():
487-
return assistantResponse.String(), ctx.Err()
488-
default:
489-
}
482+
// Detect streaming vs non-streaming response via Content-Type header
483+
isStreaming := strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream")
490484

491-
line := scanner.Text()
492-
if line == "" {
493-
continue
485+
if !isStreaming {
486+
// Non-streaming JSON response
487+
body, err := io.ReadAll(resp.Body)
488+
if err != nil {
489+
return assistantResponse.String(), fmt.Errorf("error reading response body: %w", err)
494490
}
495491

496-
if !strings.HasPrefix(line, "data: ") {
497-
continue
492+
var nonStreamResp OpenAIChatResponse
493+
if err := json.Unmarshal(body, &nonStreamResp); err != nil {
494+
return assistantResponse.String(), fmt.Errorf("error parsing response: %w", err)
498495
}
499496

500-
data := strings.TrimPrefix(line, "data: ")
501-
502-
if data == "[DONE]" {
503-
break
497+
// Extract content from non-streaming response
498+
if len(nonStreamResp.Choices) > 0 && nonStreamResp.Choices[0].Message.Content != "" {
499+
content := nonStreamResp.Choices[0].Message.Content
500+
outputFunc(content)
501+
assistantResponse.WriteString(content)
504502
}
505503

506-
var streamResp OpenAIChatResponse
507-
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
508-
return assistantResponse.String(), fmt.Errorf("error parsing stream response: %w", err)
504+
if nonStreamResp.Usage != nil {
505+
finalUsage = nonStreamResp.Usage
509506
}
507+
} else {
508+
// SSE streaming response - process line by line
509+
scanner := bufio.NewScanner(resp.Body)
510+
511+
for scanner.Scan() {
512+
// Check if context was cancelled
513+
select {
514+
case <-ctx.Done():
515+
return assistantResponse.String(), ctx.Err()
516+
default:
517+
}
510518

511-
if streamResp.Usage != nil {
512-
finalUsage = streamResp.Usage
513-
}
519+
line := scanner.Text()
520+
if line == "" {
521+
continue
522+
}
514523

515-
if len(streamResp.Choices) > 0 {
516-
if streamResp.Choices[0].Delta.ReasoningContent != "" {
517-
chunk := streamResp.Choices[0].Delta.ReasoningContent
518-
if printerState == chatPrinterContent {
519-
outputFunc("\n\n")
520-
}
521-
if printerState != chatPrinterReasoning {
522-
const thinkingHeader = "Thinking:\n"
524+
if !strings.HasPrefix(line, "data: ") {
525+
continue
526+
}
527+
528+
data := strings.TrimPrefix(line, "data: ")
529+
530+
if data == "[DONE]" {
531+
break
532+
}
533+
534+
var streamResp OpenAIChatResponse
535+
if err := json.Unmarshal([]byte(data), &streamResp); err != nil {
536+
return assistantResponse.String(), fmt.Errorf("error parsing stream response: %w", err)
537+
}
538+
539+
if streamResp.Usage != nil {
540+
finalUsage = streamResp.Usage
541+
}
542+
543+
if len(streamResp.Choices) > 0 {
544+
if streamResp.Choices[0].Delta.ReasoningContent != "" {
545+
chunk := streamResp.Choices[0].Delta.ReasoningContent
546+
if printerState == chatPrinterContent {
547+
outputFunc("\n\n")
548+
}
549+
if printerState != chatPrinterReasoning {
550+
const thinkingHeader = "Thinking:\n"
551+
if reasoningFmt != nil {
552+
reasoningFmt.Print(thinkingHeader)
553+
} else {
554+
outputFunc(thinkingHeader)
555+
}
556+
}
557+
printerState = chatPrinterReasoning
523558
if reasoningFmt != nil {
524-
reasoningFmt.Print(thinkingHeader)
559+
reasoningFmt.Print(chunk)
525560
} else {
526-
outputFunc(thinkingHeader)
561+
outputFunc(chunk)
527562
}
528563
}
529-
printerState = chatPrinterReasoning
530-
if reasoningFmt != nil {
531-
reasoningFmt.Print(chunk)
532-
} else {
564+
if streamResp.Choices[0].Delta.Content != "" {
565+
chunk := streamResp.Choices[0].Delta.Content
566+
if printerState == chatPrinterReasoning {
567+
outputFunc("\n\n--\n\n")
568+
}
569+
printerState = chatPrinterContent
533570
outputFunc(chunk)
571+
assistantResponse.WriteString(chunk)
534572
}
535573
}
536-
if streamResp.Choices[0].Delta.Content != "" {
537-
chunk := streamResp.Choices[0].Delta.Content
538-
if printerState == chatPrinterReasoning {
539-
outputFunc("\n\n--\n\n")
540-
}
541-
printerState = chatPrinterContent
542-
outputFunc(chunk)
543-
assistantResponse.WriteString(chunk)
544-
}
545574
}
546-
}
547575

548-
if err := scanner.Err(); err != nil {
549-
return assistantResponse.String(), fmt.Errorf("error reading response stream: %w", err)
576+
if err := scanner.Err(); err != nil {
577+
return assistantResponse.String(), fmt.Errorf("error reading response stream: %w", err)
578+
}
550579
}
551580

552581
if finalUsage != nil {

main.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import (
1919
"github.com/docker/model-runner/pkg/inference/backends/mlx"
2020
"github.com/docker/model-runner/pkg/inference/backends/sglang"
2121
"github.com/docker/model-runner/pkg/inference/backends/vllm"
22+
"github.com/docker/model-runner/pkg/inference/backends/vllmmetal"
2223
"github.com/docker/model-runner/pkg/inference/config"
2324
"github.com/docker/model-runner/pkg/inference/models"
25+
"github.com/docker/model-runner/pkg/inference/platform"
2426
"github.com/docker/model-runner/pkg/inference/scheduling"
2527
"github.com/docker/model-runner/pkg/metrics"
2628
"github.com/docker/model-runner/pkg/middleware"
@@ -85,6 +87,7 @@ func main() {
8587
sglangServerPath := os.Getenv("SGLANG_SERVER_PATH")
8688
mlxServerPath := os.Getenv("MLX_SERVER_PATH")
8789
diffusersServerPath := os.Getenv("DIFFUSERS_SERVER_PATH")
90+
vllmMetalServerPath := os.Getenv("VLLM_METAL_SERVER_PATH")
8891

8992
// Create a proxy-aware HTTP transport
9093
// Use a safe type assertion with fallback, and explicitly set Proxy to http.ProxyFromEnvironment
@@ -117,6 +120,9 @@ func main() {
117120
if mlxServerPath != "" {
118121
log.Infof("MLX_SERVER_PATH: %s", mlxServerPath)
119122
}
123+
if vllmMetalServerPath != "" {
124+
log.Infof("VLLM_METAL_SERVER_PATH: %s", vllmMetalServerPath)
125+
}
120126

121127
// Create llama.cpp configuration from environment variables
122128
llamaCppConfig := createLlamaCppConfigFromEnv()
@@ -177,6 +183,19 @@ func main() {
177183
log.Fatalf("unable to initialize diffusers backend: %v", err)
178184
}
179185

186+
var vllmMetalBackend inference.Backend
187+
if platform.SupportsVLLMMetal() {
188+
vllmMetalBackend, err = vllmmetal.New(
189+
log,
190+
modelManager,
191+
log.WithFields(logrus.Fields{"component": vllmmetal.Name}),
192+
vllmMetalServerPath,
193+
)
194+
if err != nil {
195+
log.Warnf("Failed to initialize vllm-metal backend: %v", err)
196+
}
197+
}
198+
180199
backends := map[string]inference.Backend{
181200
llamacpp.Name: llamaCppBackend,
182201
mlx.Name: mlxBackend,
@@ -185,6 +204,10 @@ func main() {
185204
}
186205
registerVLLMBackend(backends, vllmBackend)
187206

207+
if vllmMetalBackend != nil {
208+
backends[vllmmetal.Name] = vllmMetalBackend
209+
}
210+
188211
scheduler := scheduling.NewScheduler(
189212
log,
190213
backends,

0 commit comments

Comments
 (0)