Skip to content

Commit 34b3fdc

Browse files
committed
feat: add 'docker model launch' cmd
1 parent 4d4402b commit 34b3fdc

File tree

3 files changed

+666
-0
lines changed

3 files changed

+666
-0
lines changed

cmd/cli/commands/launch.go

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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+
var ep engineEndpoints
131+
if runner.gatewayIP != "" && runner.gatewayPort != 0 {
132+
port := fmt.Sprintf("%d", runner.gatewayPort)
133+
ep.container = "http://" + net.JoinHostPort(runner.gatewayIP, port)
134+
ep.host = "http://" + net.JoinHostPort("127.0.0.1", port)
135+
} else if runner.hostPort != 0 {
136+
hostPort := fmt.Sprintf("%d", runner.hostPort)
137+
ep.host = "http://" + net.JoinHostPort("127.0.0.1", hostPort)
138+
} else {
139+
return engineEndpoints{}, errors.New("unable to determine standalone runner endpoint")
140+
}
141+
return ep, nil
142+
default:
143+
return engineEndpoints{}, fmt.Errorf("unhandled engine kind: %v", kind)
144+
}
145+
}
146+
147+
// launchContainerApp launches a container-based app via "docker run".
148+
func launchContainerApp(cmd *cobra.Command, ca containerApp, baseURL string, imageOverride string, portOverride int, detach, dryRun bool) error {
149+
img := imageOverride
150+
if img == "" {
151+
img = ca.defaultImage
152+
}
153+
hostPort := portOverride
154+
if hostPort == 0 {
155+
hostPort = ca.defaultHostPort
156+
}
157+
158+
dockerArgs := []string{"run", "--rm"}
159+
if detach {
160+
dockerArgs = append(dockerArgs, "-d")
161+
}
162+
dockerArgs = append(dockerArgs,
163+
"-p", fmt.Sprintf("%d:%d", hostPort, ca.containerPort),
164+
)
165+
if ca.envFn == nil {
166+
return fmt.Errorf("container app requires envFn to be set")
167+
}
168+
for _, e := range ca.envFn(baseURL) {
169+
dockerArgs = append(dockerArgs, "-e", e)
170+
}
171+
dockerArgs = append(dockerArgs, img)
172+
173+
if dryRun {
174+
cmd.Printf("Would run: docker %s\n", strings.Join(dockerArgs, " "))
175+
return nil
176+
}
177+
178+
return runExternal(cmd, nil, "docker", dockerArgs...)
179+
}
180+
181+
// launchHostApp launches a native host app executable.
182+
func launchHostApp(cmd *cobra.Command, bin string, baseURL string, cli hostApp, dryRun bool) error {
183+
if _, err := exec.LookPath(bin); err != nil {
184+
cmd.Printf("%q executable not found in PATH.\n", bin)
185+
if cli.envFn != nil {
186+
cmd.Printf("Configure your app to use:\n")
187+
for _, e := range cli.envFn(baseURL) {
188+
cmd.Printf(" %s\n", e)
189+
}
190+
}
191+
return fmt.Errorf("%s not found; please install it and re-run", bin)
192+
}
193+
194+
if cli.envFn == nil {
195+
return launchUnconfigurableHostApp(cmd, bin, baseURL, dryRun)
196+
}
197+
198+
env := cli.envFn(baseURL)
199+
if dryRun {
200+
cmd.Printf("Would run: %s\n", bin)
201+
for _, e := range env {
202+
cmd.Printf(" %s\n", e)
203+
}
204+
return nil
205+
}
206+
return runExternal(cmd, withEnv(env...), bin)
207+
}
208+
209+
// launchUnconfigurableHostApp handles host apps that need manual config rather than env vars.
210+
func launchUnconfigurableHostApp(cmd *cobra.Command, bin string, baseURL string, dryRun bool) error {
211+
enginesEP := baseURL + openaiPathSuffix
212+
cmd.Printf("Configure %s to use Docker Model Runner:\n", bin)
213+
cmd.Printf(" Base URL: %s\n", enginesEP)
214+
cmd.Printf(" API type: openai-completions\n")
215+
cmd.Printf(" API key: docker-model-runner\n")
216+
if bin == "clawdbot" {
217+
cmd.Printf("\nExample:\n")
218+
cmd.Printf(" clawdbot config set models.providers.docker-model-runner.baseUrl %q\n", enginesEP)
219+
cmd.Printf(" clawdbot config set models.providers.docker-model-runner.api openai-completions\n")
220+
cmd.Printf(" clawdbot config set models.providers.docker-model-runner.apiKey docker-model-runner\n")
221+
}
222+
if dryRun {
223+
return nil
224+
}
225+
return runExternal(cmd, nil, bin)
226+
}
227+
228+
// openaiEnv returns an env builder that sets OpenAI-compatible
229+
// environment variables using the given path suffix.
230+
func openaiEnv(suffix string) func(string) []string {
231+
return func(baseURL string) []string {
232+
ep := baseURL + suffix
233+
return []string{
234+
"OPENAI_API_BASE=" + ep,
235+
"OPENAI_BASE_URL=" + ep,
236+
"OPENAI_API_KEY=docker-model-runner",
237+
}
238+
}
239+
}
240+
241+
// anthropicEnv returns Anthropic-compatible environment variables.
242+
func anthropicEnv(baseURL string) []string {
243+
return []string{
244+
"ANTHROPIC_BASE_URL=" + baseURL + "/anthropic",
245+
"ANTHROPIC_API_KEY=docker-model-runner",
246+
}
247+
}
248+
249+
// withEnv returns the current process environment extended with extra vars.
250+
func withEnv(extra ...string) []string {
251+
return append(os.Environ(), extra...)
252+
}
253+
254+
// runExternal executes a program inheriting stdio.
255+
func runExternal(cmd *cobra.Command, env []string, prog string, progArgs ...string) error {
256+
c := exec.Command(prog, progArgs...)
257+
c.Stdout = cmd.OutOrStdout()
258+
c.Stderr = cmd.ErrOrStderr()
259+
c.Stdin = os.Stdin
260+
if env != nil {
261+
c.Env = env
262+
}
263+
if err := c.Run(); err != nil {
264+
return fmt.Errorf("failed to run %s %s: %w", prog, strings.Join(progArgs, " "), err)
265+
}
266+
return nil
267+
}

0 commit comments

Comments
 (0)