Skip to content

Commit e9d05d6

Browse files
authored
Merge pull request #618 from denyszhak/feature/docker-model-launch-cmd
feat: add 'docker model launch' cmd
2 parents 3245480 + a0ee177 commit e9d05d6

File tree

7 files changed

+817
-0
lines changed

7 files changed

+817
-0
lines changed

cmd/cli/commands/launch.go

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
package commands
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"net"
7+
"os"
8+
"os/exec"
9+
"sort"
10+
"strings"
11+
12+
"github.com/docker/model-runner/cmd/cli/pkg/types"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
// openaiPathSuffix is the path appended to the base URL for OpenAI-compatible endpoints.
17+
const openaiPathSuffix = "/engines/v1"
18+
19+
// dummyAPIKey is a placeholder API key for Docker Model Runner (which doesn't require auth).
20+
const dummyAPIKey = "sk-docker-model-runner" //nolint:gosec // not a real credential
21+
22+
// engineEndpoints holds the resolved base URLs (without path) for both
23+
// client locations.
24+
type engineEndpoints struct {
25+
// base URL reachable from inside a Docker container
26+
// (e.g., http://model-runner.docker.internal).
27+
container string
28+
// base URL reachable from the host machine
29+
// (e.g., http://127.0.0.1:12434).
30+
host string
31+
}
32+
33+
// containerApp describes an app that runs as a Docker container.
34+
type containerApp struct {
35+
defaultImage string
36+
defaultHostPort int
37+
containerPort int
38+
envFn func(baseURL string) []string
39+
extraDockerArgs []string // additional docker run args (e.g., volume mounts)
40+
}
41+
42+
// containerApps are launched via "docker run --rm".
43+
var containerApps = map[string]containerApp{
44+
"anythingllm": {
45+
defaultImage: "mintplexlabs/anythingllm:latest",
46+
defaultHostPort: 3001,
47+
containerPort: 3001,
48+
envFn: anythingllmEnv,
49+
extraDockerArgs: []string{"-v", "anythingllm_storage:/app/server/storage"},
50+
},
51+
"openwebui": {defaultImage: "ghcr.io/open-webui/open-webui:latest", defaultHostPort: 3000, containerPort: 8080, envFn: openaiEnv(openaiPathSuffix)},
52+
}
53+
54+
// hostApp describes a native CLI app launched on the host.
55+
type hostApp struct {
56+
envFn func(baseURL string) []string
57+
configInstructions func(baseURL string) []string // for apps that need manual config
58+
}
59+
60+
// hostApps are launched as native executables on the host.
61+
var hostApps = map[string]hostApp{
62+
"opencode": {envFn: openaiEnv(openaiPathSuffix)},
63+
"codex": {envFn: openaiEnv("/v1")},
64+
"claude": {envFn: anthropicEnv},
65+
"openclaw": {configInstructions: openclawConfigInstructions},
66+
}
67+
68+
// supportedApps is derived from the registries above.
69+
var supportedApps = func() []string {
70+
apps := make([]string, 0, len(containerApps)+len(hostApps))
71+
for name := range containerApps {
72+
apps = append(apps, name)
73+
}
74+
for name := range hostApps {
75+
apps = append(apps, name)
76+
}
77+
sort.Strings(apps)
78+
return apps
79+
}()
80+
81+
func newLaunchCmd() *cobra.Command {
82+
var (
83+
port int
84+
image string
85+
detach bool
86+
dryRun bool
87+
)
88+
c := &cobra.Command{
89+
Use: "launch APP [-- APP_ARGS...]",
90+
Short: "Launch an app configured to use Docker Model Runner",
91+
Long: fmt.Sprintf(`Launch an app configured to use Docker Model Runner.
92+
93+
Supported apps: %s`, strings.Join(supportedApps, ", ")),
94+
Args: cobra.MinimumNArgs(1),
95+
ValidArgs: supportedApps,
96+
RunE: func(cmd *cobra.Command, args []string) error {
97+
app := strings.ToLower(args[0])
98+
appArgs := args[1:]
99+
100+
runner, err := getStandaloneRunner(cmd.Context())
101+
if err != nil {
102+
return fmt.Errorf("unable to determine standalone runner endpoint: %w", err)
103+
}
104+
105+
ep, err := resolveBaseEndpoints(runner)
106+
if err != nil {
107+
return err
108+
}
109+
110+
if ca, ok := containerApps[app]; ok {
111+
return launchContainerApp(cmd, ca, ep.container, image, port, detach, appArgs, dryRun)
112+
}
113+
if cli, ok := hostApps[app]; ok {
114+
return launchHostApp(cmd, app, ep.host, cli, appArgs, dryRun)
115+
}
116+
return fmt.Errorf("unsupported app %q (supported: %s)", app, strings.Join(supportedApps, ", "))
117+
},
118+
}
119+
c.Flags().IntVar(&port, "port", 0, "Host port to expose (web UIs)")
120+
c.Flags().StringVar(&image, "image", "", "Override container image for containerized apps")
121+
c.Flags().BoolVar(&detach, "detach", false, "Run containerized app in background")
122+
c.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be executed without running it")
123+
return c
124+
}
125+
126+
// resolveBaseEndpoints resolves the base URLs (without path) for both
127+
// container and host client locations.
128+
func resolveBaseEndpoints(runner *standaloneRunner) (engineEndpoints, error) {
129+
const (
130+
localhost = "127.0.0.1"
131+
hostDockerInternal = "host.docker.internal"
132+
)
133+
134+
kind := modelRunner.EngineKind()
135+
switch kind {
136+
case types.ModelRunnerEngineKindDesktop:
137+
return engineEndpoints{
138+
container: "http://model-runner.docker.internal",
139+
host: strings.TrimRight(modelRunner.URL(""), "/"),
140+
}, nil
141+
case types.ModelRunnerEngineKindMobyManual:
142+
ep := strings.TrimRight(modelRunner.URL(""), "/")
143+
containerEP := strings.NewReplacer(
144+
"localhost", hostDockerInternal,
145+
localhost, hostDockerInternal,
146+
).Replace(ep)
147+
return engineEndpoints{container: containerEP, host: ep}, nil
148+
case types.ModelRunnerEngineKindCloud, types.ModelRunnerEngineKindMoby:
149+
if runner == nil {
150+
return engineEndpoints{}, errors.New("unable to determine standalone runner endpoint")
151+
}
152+
if runner.gatewayIP != "" && runner.gatewayPort != 0 {
153+
port := fmt.Sprintf("%d", runner.gatewayPort)
154+
return engineEndpoints{
155+
container: "http://" + net.JoinHostPort(runner.gatewayIP, port),
156+
host: "http://" + net.JoinHostPort(localhost, port),
157+
}, nil
158+
}
159+
if runner.hostPort != 0 {
160+
hostPort := fmt.Sprintf("%d", runner.hostPort)
161+
return engineEndpoints{
162+
container: "http://" + net.JoinHostPort(hostDockerInternal, hostPort),
163+
host: "http://" + net.JoinHostPort(localhost, hostPort),
164+
}, nil
165+
}
166+
return engineEndpoints{}, errors.New("unable to determine standalone runner endpoint")
167+
default:
168+
return engineEndpoints{}, fmt.Errorf("unhandled engine kind: %v", kind)
169+
}
170+
}
171+
172+
// launchContainerApp launches a container-based app via "docker run".
173+
func launchContainerApp(cmd *cobra.Command, ca containerApp, baseURL string, imageOverride string, portOverride int, detach bool, appArgs []string, dryRun bool) error {
174+
img := imageOverride
175+
if img == "" {
176+
img = ca.defaultImage
177+
}
178+
hostPort := portOverride
179+
if hostPort == 0 {
180+
hostPort = ca.defaultHostPort
181+
}
182+
183+
dockerArgs := []string{"run", "--rm"}
184+
if detach {
185+
dockerArgs = append(dockerArgs, "-d")
186+
}
187+
dockerArgs = append(dockerArgs,
188+
"-p", fmt.Sprintf("%d:%d", hostPort, ca.containerPort),
189+
)
190+
dockerArgs = append(dockerArgs, ca.extraDockerArgs...)
191+
if ca.envFn == nil {
192+
return fmt.Errorf("container app requires envFn to be set")
193+
}
194+
for _, e := range ca.envFn(baseURL) {
195+
dockerArgs = append(dockerArgs, "-e", e)
196+
}
197+
dockerArgs = append(dockerArgs, img)
198+
dockerArgs = append(dockerArgs, appArgs...)
199+
200+
if dryRun {
201+
cmd.Printf("Would run: docker %s\n", strings.Join(dockerArgs, " "))
202+
return nil
203+
}
204+
205+
return runExternal(cmd, nil, "docker", dockerArgs...)
206+
}
207+
208+
// launchHostApp launches a native host app executable.
209+
func launchHostApp(cmd *cobra.Command, bin string, baseURL string, cli hostApp, appArgs []string, dryRun bool) error {
210+
if !dryRun {
211+
if _, err := exec.LookPath(bin); err != nil {
212+
cmd.PrintErrf("%q executable not found in PATH.\n", bin)
213+
if cli.envFn != nil {
214+
cmd.PrintErrf("Configure your app to use:\n")
215+
for _, e := range cli.envFn(baseURL) {
216+
cmd.PrintErrf(" %s\n", e)
217+
}
218+
}
219+
return fmt.Errorf("%s not found; please install it and re-run", bin)
220+
}
221+
}
222+
223+
if cli.envFn == nil {
224+
return launchUnconfigurableHostApp(cmd, bin, baseURL, cli, appArgs, dryRun)
225+
}
226+
227+
env := cli.envFn(baseURL)
228+
if dryRun {
229+
cmd.Printf("Would run: %s %s\n", bin, strings.Join(appArgs, " "))
230+
for _, e := range env {
231+
cmd.Printf(" %s\n", e)
232+
}
233+
return nil
234+
}
235+
return runExternal(cmd, withEnv(env...), bin, appArgs...)
236+
}
237+
238+
// launchUnconfigurableHostApp handles host apps that need manual config rather than env vars.
239+
func launchUnconfigurableHostApp(cmd *cobra.Command, bin string, baseURL string, cli hostApp, appArgs []string, dryRun bool) error {
240+
enginesEP := baseURL + openaiPathSuffix
241+
cmd.Printf("Configure %s to use Docker Model Runner:\n", bin)
242+
cmd.Printf(" Base URL: %s\n", enginesEP)
243+
cmd.Printf(" API type: openai-completions\n")
244+
cmd.Printf(" API key: %s\n", dummyAPIKey)
245+
246+
if cli.configInstructions != nil {
247+
cmd.Printf("\nExample:\n")
248+
for _, line := range cli.configInstructions(baseURL) {
249+
cmd.Printf(" %s\n", line)
250+
}
251+
}
252+
if dryRun {
253+
cmd.Printf("Would run: %s %s\n", bin, strings.Join(appArgs, " "))
254+
return nil
255+
}
256+
return runExternal(cmd, nil, bin, appArgs...)
257+
}
258+
259+
// openclawConfigInstructions returns configuration commands for openclaw.
260+
func openclawConfigInstructions(baseURL string) []string {
261+
ep := baseURL + openaiPathSuffix
262+
return []string{
263+
fmt.Sprintf("openclaw config set models.providers.docker-model-runner.baseUrl %q", ep),
264+
"openclaw config set models.providers.docker-model-runner.api openai-completions",
265+
fmt.Sprintf("openclaw config set models.providers.docker-model-runner.apiKey %s", dummyAPIKey),
266+
}
267+
}
268+
269+
// openaiEnv returns an env builder that sets OpenAI-compatible
270+
// environment variables using the given path suffix.
271+
func openaiEnv(suffix string) func(string) []string {
272+
return func(baseURL string) []string {
273+
ep := baseURL + suffix
274+
return []string{
275+
"OPENAI_API_BASE=" + ep,
276+
"OPENAI_BASE_URL=" + ep,
277+
"OPENAI_API_BASE_URL=" + ep,
278+
"OPENAI_API_KEY=" + dummyAPIKey,
279+
"OPEN_AI_KEY=" + dummyAPIKey, // AnythingLLM uses this
280+
}
281+
}
282+
}
283+
284+
// anythingllmEnv returns environment variables for AnythingLLM with Docker Model Runner provider.
285+
func anythingllmEnv(baseURL string) []string {
286+
return []string{
287+
"STORAGE_DIR=/app/server/storage",
288+
"LLM_PROVIDER=docker-model-runner",
289+
"DOCKER_MODEL_RUNNER_BASE_PATH=" + baseURL,
290+
}
291+
}
292+
293+
// anthropicEnv returns Anthropic-compatible environment variables.
294+
func anthropicEnv(baseURL string) []string {
295+
return []string{
296+
"ANTHROPIC_BASE_URL=" + baseURL + "/anthropic",
297+
"ANTHROPIC_API_KEY=" + dummyAPIKey,
298+
}
299+
}
300+
301+
// withEnv returns the current process environment extended with extra vars.
302+
func withEnv(extra ...string) []string {
303+
return append(os.Environ(), extra...)
304+
}
305+
306+
// runExternal executes a program inheriting stdio.
307+
// Security: prog and progArgs are either hardcoded values or user-provided
308+
// arguments that the user explicitly intends to pass to the launched app.
309+
func runExternal(cmd *cobra.Command, env []string, prog string, progArgs ...string) error {
310+
c := exec.Command(prog, progArgs...)
311+
c.Stdout = cmd.OutOrStdout()
312+
c.Stderr = cmd.ErrOrStderr()
313+
c.Stdin = os.Stdin
314+
if env != nil {
315+
c.Env = env
316+
}
317+
if err := c.Run(); err != nil {
318+
return fmt.Errorf("failed to run %s %s: %w", prog, strings.Join(progArgs, " "), err)
319+
}
320+
return nil
321+
}

0 commit comments

Comments
 (0)