Skip to content

Commit 8c1533a

Browse files
committed
feat: add 'docker model launch' cmd
1 parent 6aca887 commit 8c1533a

7 files changed

Lines changed: 740 additions & 0 deletions

File tree

cmd/cli/commands/launch.go

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

0 commit comments

Comments
 (0)