Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/cli/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ func NewRootCmd(cli *command.DockerCli) *cobra.Command {
newStopRunner(),
newRestartRunner(),
newReinstallRunner(),
newSearchCmd(),
)

// Commands that require a running model runner. These are wrapped to ensure the standalone runner is available.
Expand Down
127 changes: 127 additions & 0 deletions cmd/cli/commands/search.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package commands

import (
"bytes"
"fmt"

"github.com/docker/model-runner/cmd/cli/commands/formatter"
"github.com/docker/model-runner/cmd/cli/search"
"github.com/spf13/cobra"
)

func newSearchCmd() *cobra.Command {
var (
limit int
source string
jsonFormat bool
)

c := &cobra.Command{
Use: "search [OPTIONS] [TERM]",
Short: "Search for models on Docker Hub and HuggingFace",
Long: `Search for models from Docker Hub (ai/ namespace) and HuggingFace.

When no search term is provided, lists all available models.
When a search term is provided, filters models by name/description.

Examples:
docker model search # List available models from Docker Hub
docker model search llama # Search for models containing "llama"
docker model search --source=all # Search both Docker Hub and HuggingFace
docker model search --source=huggingface # Only search HuggingFace
docker model search --limit=50 phi # Search with custom limit
docker model search --json llama # Output as JSON`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Parse the source
sourceType, err := search.ParseSource(source)
if err != nil {
return err
}

// Get the search query
var query string
if len(args) > 0 {
query = args[0]
}

// Create the search client
client := search.NewAggregatedClient(sourceType, cmd.ErrOrStderr())

// Perform the search
opts := search.SearchOptions{
Query: query,
Limit: limit,
}

results, err := client.Search(cmd.Context(), opts)
if err != nil {
return fmt.Errorf("search failed: %w", err)
}

if len(results) == 0 {
if query != "" {
fmt.Fprintf(cmd.OutOrStdout(), "No models found matching %q\n", query)
} else {
fmt.Fprintln(cmd.OutOrStdout(), "No models found")
}
return nil
}

// Output results
if jsonFormat {
output, err := formatter.ToStandardJSON(results)
if err != nil {
return err
}
fmt.Fprint(cmd.OutOrStdout(), output)
return nil
}

fmt.Fprint(cmd.OutOrStdout(), prettyPrintSearchResults(results))
return nil
},
}

c.Flags().IntVarP(&limit, "limit", "n", 32, "Maximum number of results to show")
c.Flags().StringVar(&source, "source", "all", "Source to search: all, dockerhub, huggingface")
c.Flags().BoolVar(&jsonFormat, "json", false, "Output results as JSON")

return c
}

// prettyPrintSearchResults formats search results as a table
func prettyPrintSearchResults(results []search.SearchResult) string {
var buf bytes.Buffer
table := newTable(&buf)
table.Header([]string{"NAME", "DESCRIPTION", "BACKEND", "DOWNLOADS", "STARS", "SOURCE"})

for _, r := range results {
name := r.Name
if r.Source == search.HuggingFaceSourceName {
name = "hf.co/" + r.Name
}
table.Append([]string{
name,
r.Description,
r.Backend,
formatCount(r.Downloads),
formatCount(r.Stars),
r.Source,
})
}

table.Render()
return buf.String()
}

// formatCount formats a number in a human-readable way (e.g., 1.2M, 45K)
func formatCount(n int64) string {
if n >= 1_000_000 {
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
}
if n >= 1_000 {
return fmt.Sprintf("%.1fK", float64(n)/1_000)
}
return fmt.Sprintf("%d", n)
}
2 changes: 2 additions & 0 deletions cmd/cli/docs/reference/docker_model.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ cname:
- docker model restart-runner
- docker model rm
- docker model run
- docker model search
- docker model start-runner
- docker model status
- docker model stop-runner
Expand All @@ -46,6 +47,7 @@ clink:
- docker_model_restart-runner.yaml
- docker_model_rm.yaml
- docker_model_run.yaml
- docker_model_search.yaml
- docker_model_start-runner.yaml
- docker_model_status.yaml
- docker_model_stop-runner.yaml
Expand Down
57 changes: 57 additions & 0 deletions cmd/cli/docs/reference/docker_model_search.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
command: docker model search
short: Search for models on Docker Hub and HuggingFace
long: |-
Search for models from Docker Hub (ai/ namespace) and HuggingFace.

When no search term is provided, lists all available models.
When a search term is provided, filters models by name/description.

Examples:
docker model search # List available models from Docker Hub
docker model search llama # Search for models containing "llama"
docker model search --source=all # Search both Docker Hub and HuggingFace
docker model search --source=huggingface # Only search HuggingFace
docker model search --limit=50 phi # Search with custom limit
docker model search --json llama # Output as JSON
usage: docker model search [OPTIONS] [TERM]
pname: docker model
plink: docker_model.yaml
options:
- option: json
value_type: bool
default_value: "false"
description: Output results as JSON
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: limit
shorthand: "n"
value_type: int
default_value: "32"
description: Maximum number of results to show
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
- option: source
value_type: string
default_value: all
description: 'Source to search: all, dockerhub, huggingface'
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false
deprecated: false
hidden: false
experimental: false
experimentalcli: false
kubernetes: false
swarm: false

1 change: 1 addition & 0 deletions cmd/cli/docs/reference/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Docker Model Runner
| [`restart-runner`](model_restart-runner.md) | Restart Docker Model Runner (Docker Engine only) |
| [`rm`](model_rm.md) | Remove local models downloaded from Docker Hub |
| [`run`](model_run.md) | Run a model and interact with it using a submitted prompt or chat mode |
| [`search`](model_search.md) | Search for models on Docker Hub and HuggingFace |
| [`start-runner`](model_start-runner.md) | Start Docker Model Runner (Docker Engine only) |
| [`status`](model_status.md) | Check if the Docker Model Runner is running |
| [`stop-runner`](model_stop-runner.md) | Stop Docker Model Runner (Docker Engine only) |
Expand Down
27 changes: 27 additions & 0 deletions cmd/cli/docs/reference/model_search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# docker model search

<!---MARKER_GEN_START-->
Search for models from Docker Hub (ai/ namespace) and HuggingFace.

When no search term is provided, lists all available models.
When a search term is provided, filters models by name/description.

Examples:
docker model search # List available models from Docker Hub
docker model search llama # Search for models containing "llama"
docker model search --source=all # Search both Docker Hub and HuggingFace
docker model search --source=huggingface # Only search HuggingFace
docker model search --limit=50 phi # Search with custom limit
docker model search --json llama # Output as JSON

### Options

| Name | Type | Default | Description |
|:----------------|:---------|:--------|:----------------------------------------------|
| `--json` | `bool` | | Output results as JSON |
| `-n`, `--limit` | `int` | `32` | Maximum number of results to show |
| `--source` | `string` | `all` | Source to search: all, dockerhub, huggingface |


<!---MARKER_GEN_END-->

135 changes: 135 additions & 0 deletions cmd/cli/search/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package search

import (
"context"
"fmt"
"io"
"sort"
"sync"
)

// SourceType represents the source to search
type SourceType string

const (
SourceAll SourceType = "all"
SourceDockerHub SourceType = "dockerhub"
SourceHuggingFace SourceType = "huggingface"
)

// AggregatedClient searches multiple sources and merges results
type AggregatedClient struct {
clients []SearchClient
errOut io.Writer
}

// NewAggregatedClient creates a client that searches the specified sources
func NewAggregatedClient(source SourceType, errOut io.Writer) *AggregatedClient {
var clients []SearchClient

switch source {
case SourceDockerHub:
clients = []SearchClient{NewDockerHubClient()}
case SourceHuggingFace:
clients = []SearchClient{NewHuggingFaceClient()}
case SourceAll:
clients = []SearchClient{
NewDockerHubClient(),
NewHuggingFaceClient(),
}
default: // This handles any unexpected values
clients = []SearchClient{
NewDockerHubClient(),
NewHuggingFaceClient(),
}
}

return &AggregatedClient{
clients: clients,
errOut: errOut,
}
}

// searchResult holds results from a single source along with any error
type searchResult struct {
results []SearchResult
err error
source string
}

// Search searches all configured sources and merges results
func (c *AggregatedClient) Search(ctx context.Context, opts SearchOptions) ([]SearchResult, error) {
// Search all sources concurrently
resultsChan := make(chan searchResult, len(c.clients))
var wg sync.WaitGroup

for _, client := range c.clients {
wg.Add(1)
go func(client SearchClient) {
defer wg.Done()
results, err := client.Search(ctx, opts)
resultsChan <- searchResult{
results: results,
err: err,
source: client.Name(),
}
}(client)
}

// Wait for all searches to complete
go func() {
wg.Wait()
close(resultsChan)
}()

// Collect results
var allResults []SearchResult
var errors []error

for result := range resultsChan {
if result.err != nil {
errors = append(errors, fmt.Errorf("%s: %w", result.source, result.err))
if c.errOut != nil {
fmt.Fprintf(c.errOut, "Warning: failed to search %s: %v\n", result.source, result.err)
}
continue
}
allResults = append(allResults, result.results...)
}

// If all sources failed, return the collected errors
if len(allResults) == 0 && len(errors) > 0 {
return nil, fmt.Errorf("all search sources failed: %v", errors)
}

// Sort by source (Docker Hub first), then by downloads within each source
sort.Slice(allResults, func(i, j int) bool {
// Docker Hub comes before HuggingFace
if allResults[i].Source != allResults[j].Source {
return allResults[i].Source == DockerHubSourceName
}
// Within same source, sort by downloads (popularity)
return allResults[i].Downloads > allResults[j].Downloads
})

// Limit total results if needed
if opts.Limit > 0 && len(allResults) > opts.Limit {
allResults = allResults[:opts.Limit]
}

return allResults, nil
}

// ParseSource parses a source string into a SourceType
func ParseSource(s string) (SourceType, error) {
switch s {
case "all", "":
return SourceAll, nil
case "dockerhub", "docker", "hub":
return SourceDockerHub, nil
case "huggingface", "hf":
return SourceHuggingFace, nil
default:
return "", fmt.Errorf("unknown source %q: valid options are 'all', 'dockerhub', 'docker', 'hub', 'huggingface', 'hf'", s)
}
}
Loading
Loading