Compare commits

...

5 Commits

Author SHA1 Message Date
Michael Yang
d896e53c08 remove 2025-10-28 21:58:10 -07:00
Michael Yang
9bee2450e9 pull 2025-10-28 21:58:10 -07:00
Michael Yang
929542140f list 2025-10-28 21:58:10 -07:00
Michael Yang
a3df958032 version 2025-10-28 21:58:10 -07:00
Michael Yang
ec8a9f11ae iter client
add an iterator-based http client. this enables a cleaner interface for
consuming streamed responses. this client also merges common features of
tthe streaming and non-streaming request handlers
2025-10-28 21:58:09 -07:00
7 changed files with 343 additions and 217 deletions

View File

@@ -294,25 +294,6 @@ func (c *Client) Chat(ctx context.Context, req *ChatRequest, fn ChatResponseFunc
})
}
// PullProgressFunc is a function that [Client.Pull] invokes every time there
// is progress with a "pull" request sent to the service. If this function
// returns an error, [Client.Pull] will stop the process and return this error.
type PullProgressFunc func(ProgressResponse) error
// Pull downloads a model from the ollama library. fn is called each time
// progress is made on the request and can be used to display a progress bar,
// etc.
func (c *Client) Pull(ctx context.Context, req *PullRequest, fn PullProgressFunc) error {
return c.stream(ctx, http.MethodPost, "/api/pull", req, func(bts []byte) error {
var resp ProgressResponse
if err := json.Unmarshal(bts, &resp); err != nil {
return err
}
return fn(resp)
})
}
// PushProgressFunc is a function that [Client.Push] invokes when progress is
// made.
// It's similar to other progress function types like [PullProgressFunc].
@@ -352,15 +333,6 @@ func (c *Client) Create(ctx context.Context, req *CreateRequest, fn CreateProgre
})
}
// List lists models that are available locally.
func (c *Client) List(ctx context.Context) (*ListResponse, error) {
var lr ListResponse
if err := c.do(ctx, http.MethodGet, "/api/tags", nil, &lr); err != nil {
return nil, err
}
return &lr, nil
}
// ListRunning lists running models.
func (c *Client) ListRunning(ctx context.Context) (*ProcessResponse, error) {
var lr ProcessResponse
@@ -379,14 +351,6 @@ func (c *Client) Copy(ctx context.Context, req *CopyRequest) error {
return nil
}
// Delete deletes a model and its data.
func (c *Client) Delete(ctx context.Context, req *DeleteRequest) error {
if err := c.do(ctx, http.MethodDelete, "/api/delete", req, nil); err != nil {
return err
}
return nil
}
// Show obtains model information, including details, modelfile, license etc.
func (c *Client) Show(ctx context.Context, req *ShowRequest) (*ShowResponse, error) {
var resp ShowResponse
@@ -429,19 +393,6 @@ func (c *Client) CreateBlob(ctx context.Context, digest string, r io.Reader) err
return c.do(ctx, http.MethodPost, fmt.Sprintf("/api/blobs/%s", digest), r, nil)
}
// Version returns the Ollama server version as a string.
func (c *Client) Version(ctx context.Context) (string, error) {
var version struct {
Version string `json:"version"`
}
if err := c.do(ctx, http.MethodGet, "/api/version", nil, &version); err != nil {
return "", err
}
return version.Version, nil
}
// Signout will signout a client for a local ollama server.
func (c *Client) Signout(ctx context.Context) error {
return c.do(ctx, http.MethodPost, "/api/signout", nil, nil)

188
client/client.go Normal file
View File

@@ -0,0 +1,188 @@
package client
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"iter"
"maps"
"net/http"
"net/url"
"runtime"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/version"
)
type Error struct {
Status string
StatusCode int
}
func (e Error) Error() string {
return e.Status
}
type Client struct {
baseURL *url.URL
header http.Header
}
// WithBaseURL sets the base URL. It panics if it is invalid.
func WithBaseURL(s string) func(*Client) {
return func(c *Client) {
parsed, err := url.Parse(s)
if err != nil {
panic(err)
}
c.baseURL = parsed
}
}
// WithHeader sets custom HTTP headers.
func WithHeader(h http.Header) func(*Client) {
return func(c *Client) {
c.header = h
}
}
func New(opts ...func(*Client) error) *Client {
c := Client{
baseURL: envconfig.Host(),
header: http.Header{
"Content-Type": {"application/json"},
"User-Agent": {userAgent},
},
}
for _, opt := range opts {
opt(&c)
}
return &c
}
func (c *Client) Ping(ctx context.Context) error {
_, err := do[struct{}](c, ctx, http.MethodHead, "/", nil)
return err
}
func (c *Client) Chat(ctx context.Context, r api.ChatRequest) (iter.Seq2[api.ChatResponse, error], error) {
return doSeq[api.ChatResponse](c, ctx, http.MethodPost, "/api/chat", r)
}
// Pull downloads a model from a remote repository to the Ollama server.
func (c *Client) Pull(ctx context.Context, r api.PullRequest) (iter.Seq2[api.ProgressResponse, error], error) {
return doSeq[api.ProgressResponse](c, ctx, http.MethodPost, "/api/pull", r)
}
// Push uploads a model from the Ollama server to a remote repository.
func (c *Client) Push(ctx context.Context, r api.PushRequest) (iter.Seq2[api.ProgressResponse, error], error) {
return doSeq[api.ProgressResponse](c, ctx, http.MethodPost, "/api/push", r)
}
// Create builds a new model on the Ollama server.
func (c *Client) Create(ctx context.Context, r api.CreateRequest) (iter.Seq2[api.ProgressResponse, error], error) {
return doSeq[api.ProgressResponse](c, ctx, http.MethodPost, "/api/create", r)
}
// List returns the list of models from the Ollama server.
func (c *Client) List(ctx context.Context) (api.ListResponse, error) {
return do[api.ListResponse](c, ctx, http.MethodGet, "/api/tags", nil)
}
// Delete removes a model from the Ollama server.
func (c *Client) Delete(ctx context.Context, r api.DeleteRequest) error {
_, err := do[struct{}](c, ctx, http.MethodDelete, "/api/delete", r)
return err
}
// Version returns the Ollama server version.
func (c *Client) Version(ctx context.Context) (string, error) {
version, err := do[struct {
Version string `json:"version"`
}](c, ctx, "GET", "/api/version", nil)
if err != nil {
return "", err
}
return version.Version, nil
}
var userAgent = fmt.Sprintf("ollama/%s (%s %s) Go/%s", version.Version, runtime.GOARCH, runtime.GOOS, runtime.Version())
// do sends the specified HTTP request and returns the raw HTTP response. header are merged with the client's default headers.
func (c *Client) do(ctx context.Context, method, path string, body any, header http.Header) (*http.Response, error) {
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(body); err != nil {
return nil, err
}
r, err := http.NewRequestWithContext(ctx, method, c.baseURL.JoinPath(path).String(), &b)
if err != nil {
return nil, err
}
// copy headers into the request in order. later headers override earlier ones.
for _, header := range []http.Header{c.header, header} {
maps.Copy(r.Header, header)
}
w, err := http.DefaultClient.Do(r)
if err != nil {
return nil, err
}
if w.StatusCode >= 400 {
return nil, Error{
Status: w.Status,
StatusCode: w.StatusCode,
}
}
return w, nil
}
// do sends the specified HTTP request and returns the JSON response as type T
func do[T any](c *Client, ctx context.Context, method, path string, body any) (t T, err error) {
w, err := c.do(ctx, method, path, body, http.Header{"Accept": {"application/json"}})
if err != nil {
return t, err
}
defer w.Body.Close()
if w.ContentLength > 0 && method != http.MethodHead {
if err := json.NewDecoder(w.Body).Decode(&t); err != nil {
return t, err
}
}
return t, nil
}
// doSeq sends the specified HTTP request and returns an iterator that yields the JSON response chunks as type T
func doSeq[T any](c *Client, ctx context.Context, method, path string, body any) (iter.Seq2[T, error], error) {
w, err := c.do(ctx, method, path, body, http.Header{"Accept": {"application/jsonl", "application/x-ndjson"}})
if err != nil {
return nil, err
}
return func(yield func(T, error) bool) {
defer w.Body.Close()
bts := make([]byte, 0, 512<<10)
s := bufio.NewScanner(w.Body)
s.Buffer(bts, len(bts))
for s.Scan() {
var t T
if err := json.Unmarshal(s.Bytes(), &t); err != nil {
yield(t, err)
break
}
if !yield(t, nil) {
break
}
}
}, nil
}

View File

@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"log"
"math"
"net"
@@ -35,6 +36,7 @@ import (
"golang.org/x/term"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/client"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/format"
"github.com/ollama/ollama/parser"
@@ -412,7 +414,7 @@ func RunHandler(cmd *cobra.Command, args []string) error {
info, err := client.Show(cmd.Context(), showReq)
var se api.StatusError
if errors.As(err, &se) && se.StatusCode == http.StatusNotFound {
if err := PullHandler(cmd, []string{name}); err != nil {
if err := pullHandler(cmd, []string{name}); err != nil {
return nil, err
}
return client.Show(cmd.Context(), &api.ShowRequest{Name: name})
@@ -619,46 +621,6 @@ func PushHandler(cmd *cobra.Command, args []string) error {
return nil
}
func ListHandler(cmd *cobra.Command, args []string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
models, err := client.List(cmd.Context())
if err != nil {
return err
}
var data [][]string
for _, m := range models.Models {
if len(args) == 0 || strings.HasPrefix(strings.ToLower(m.Name), strings.ToLower(args[0])) {
var size string
if m.RemoteModel != "" {
size = "-"
} else {
size = format.HumanBytes(m.Size)
}
data = append(data, []string{m.Name, m.Digest[:12], size, format.HumanTime(m.ModifiedAt, "Never")})
}
}
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"NAME", "ID", "SIZE", "MODIFIED"})
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetNoWhiteSpace(true)
table.SetTablePadding(" ")
table.AppendBulk(data)
table.Render()
return nil
}
func ListRunningHandler(cmd *cobra.Command, args []string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
@@ -714,33 +676,6 @@ func ListRunningHandler(cmd *cobra.Command, args []string) error {
return nil
}
func DeleteHandler(cmd *cobra.Command, args []string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
// Unload the model if it's running before deletion
opts := &runOptions{
Model: args[0],
KeepAlive: &api.Duration{Duration: 0},
}
if err := loadOrUnloadModel(cmd, opts); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "not found") {
fmt.Fprintf(os.Stderr, "Warning: unable to stop model '%s'\n", args[0])
}
}
for _, name := range args {
req := api.DeleteRequest{Name: name}
if err := client.Delete(cmd.Context(), &req); err != nil {
return err
}
fmt.Printf("deleted '%s'\n", name)
}
return nil
}
func ShowHandler(cmd *cobra.Command, args []string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
@@ -1024,79 +959,38 @@ func CopyHandler(cmd *cobra.Command, args []string) error {
return nil
}
func PullHandler(cmd *cobra.Command, args []string) error {
insecure, err := cmd.Flags().GetBool("insecure")
func must[T any](v T, err error) T {
if err != nil {
return err
panic(err)
}
return v
}
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
p := progress.NewProgress(os.Stderr)
defer p.Stop()
bars := make(map[string]*progress.Bar)
func progressHandler(p *progress.Progress, w iter.Seq2[api.ProgressResponse, error]) error {
var status string
var spinner *progress.Spinner
fn := func(resp api.ProgressResponse) error {
if resp.Digest != "" {
if resp.Completed == 0 {
// This is the initial status update for the
// layer, which the server sends before
// beginning the download, for clients to
// compute total size and prepare for
// downloads, if needed.
//
// Skipping this here to avoid showing a 0%
// progress bar, which *should* clue the user
// into the fact that many things are being
// downloaded and that the current active
// download is not that last. However, in rare
// cases it seems to be triggering to some, and
// it isn't worth explaining, so just ignore
// and regress to the old UI that keeps giving
// you the "But wait, there is more!" after
// each "100% done" bar, which is "better."
return nil
var state progress.State
for c := range w {
if c.Status != status {
if s, ok := state.(*progress.Spinner); ok {
s.Stop()
}
if spinner != nil {
spinner.Stop()
status = c.Status
if c.Digest != "" {
state = progress.NewBar(status, c.Total, c.Completed)
} else {
state = progress.NewSpinner(status)
}
bar, ok := bars[resp.Digest]
if !ok {
name, isDigest := strings.CutPrefix(resp.Digest, "sha256:")
name = strings.TrimSpace(name)
if isDigest {
name = name[:min(12, len(name))]
}
bar = progress.NewBar(fmt.Sprintf("pulling %s:", name), resp.Total, resp.Completed)
bars[resp.Digest] = bar
p.Add(resp.Digest, bar)
}
bar.Set(resp.Completed)
} else if status != resp.Status {
if spinner != nil {
spinner.Stop()
}
status = resp.Status
spinner = progress.NewSpinner(status)
p.Add(status, spinner)
p.Add(status, state)
}
return nil
if b, ok := state.(*progress.Bar); ok {
b.Set(c.Completed)
}
}
request := api.PullRequest{Name: args[0], Insecure: insecure}
return client.Pull(cmd.Context(), &request, fn)
return nil
}
type generateContextKey string
@@ -1575,12 +1469,7 @@ func checkServerHeartbeat(cmd *cobra.Command, _ []string) error {
}
func versionHandler(cmd *cobra.Command, _ []string) {
client, err := api.ClientFromEnvironment()
if err != nil {
return
}
serverVersion, err := client.Version(cmd.Context())
serverVersion, err := client.New().Version(cmd.Context())
if err != nil {
fmt.Println("Warning: could not connect to a running Ollama instance")
}
@@ -1696,16 +1585,6 @@ func NewCLI() *cobra.Command {
RunE: RunServer,
}
pullCmd := &cobra.Command{
Use: "pull MODEL",
Short: "Pull a model from a registry",
Args: cobra.ExactArgs(1),
PreRunE: checkServerHeartbeat,
RunE: PullHandler,
}
pullCmd.Flags().Bool("insecure", false, "Use an insecure registry")
pushCmd := &cobra.Command{
Use: "push MODEL",
Short: "Push a model to a registry",
@@ -1732,14 +1611,6 @@ func NewCLI() *cobra.Command {
RunE: SignoutHandler,
}
listCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List models",
PreRunE: checkServerHeartbeat,
RunE: ListHandler,
}
psCmd := &cobra.Command{
Use: "ps",
Short: "List running models",
@@ -1754,14 +1625,6 @@ func NewCLI() *cobra.Command {
RunE: CopyHandler,
}
deleteCmd := &cobra.Command{
Use: "rm MODEL [MODEL...]",
Short: "Remove a model",
Args: cobra.MinimumNArgs(1),
PreRunE: checkServerHeartbeat,
RunE: DeleteHandler,
}
runnerCmd := &cobra.Command{
Use: "runner",
Hidden: true,
@@ -1783,12 +1646,9 @@ func NewCLI() *cobra.Command {
showCmd,
runCmd,
stopCmd,
pullCmd,
pushCmd,
listCmd,
psCmd,
copyCmd,
deleteCmd,
serveCmd,
} {
switch cmd {
@@ -1824,14 +1684,14 @@ func NewCLI() *cobra.Command {
showCmd,
runCmd,
stopCmd,
pullCmd,
cmdPull(),
pushCmd,
signinCmd,
signoutCmd,
listCmd,
cmdList(),
psCmd,
copyCmd,
deleteCmd,
cmdRemove(),
runnerCmd,
)

View File

@@ -186,7 +186,7 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
continue
case strings.HasPrefix(line, "/list"):
args := strings.Fields(line)
if err := ListHandler(cmd, args[1:]); err != nil {
if err := listHandler(cmd, args[1:]); err != nil {
return err
}
case strings.HasPrefix(line, "/load"):

58
cmd/list.go Normal file
View File

@@ -0,0 +1,58 @@
package cmd
import (
"os"
"strings"
"github.com/olekukonko/tablewriter"
"github.com/ollama/ollama/client"
"github.com/ollama/ollama/format"
"github.com/spf13/cobra"
)
func cmdList() *cobra.Command {
return &cobra.Command{
Use: "list [pattern]",
Aliases: []string{"ls"},
Short: "List available models in the local repository",
Args: cobra.MaximumNArgs(1),
PreRunE: checkServerHeartbeat,
RunE: listHandler,
}
}
func listHandler(cmd *cobra.Command, args []string) error {
c := client.New()
w, err := c.List(cmd.Context())
if err != nil {
return err
}
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"NAME", "ID", "SIZE", "MODIFIED"})
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetNoWhiteSpace(true)
table.SetTablePadding(" ")
for _, m := range w.Models {
if len(args) == 0 || strings.HasPrefix(strings.ToLower(m.Name), strings.ToLower(args[0])) {
size := format.HumanBytes(m.Size)
if m.RemoteModel != "" {
size = "-"
}
table.Append([]string{
m.Model,
m.Digest[:12],
size,
format.HumanTime(m.ModifiedAt, "Never"),
})
}
}
table.Render()
return nil
}

37
cmd/pull.go Normal file
View File

@@ -0,0 +1,37 @@
package cmd
import (
"os"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/client"
"github.com/ollama/ollama/progress"
"github.com/spf13/cobra"
)
func cmdPull() *cobra.Command {
cmd := cobra.Command{
Use: "pull [model]",
Short: "Pull a model from a remote repository",
Args: cobra.ExactArgs(1),
PreRunE: checkServerHeartbeat,
RunE: pullHandler,
}
cmd.Flags().Bool("insecure", false, "Allow insecure server connections when pulling models")
return &cmd
}
func pullHandler(cmd *cobra.Command, args []string) error {
c := client.New()
w, err := c.Pull(cmd.Context(), api.PullRequest{
Name: args[0],
Insecure: must(cmd.Flags().GetBool("insecure")),
})
if err != nil {
return err
}
p := progress.NewProgress(os.Stderr)
defer p.Stop()
return progressHandler(p, w)
}

32
cmd/remove.go Normal file
View File

@@ -0,0 +1,32 @@
package cmd
import (
"fmt"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/client"
"github.com/spf13/cobra"
)
func cmdRemove() *cobra.Command {
return &cobra.Command{
Use: "remove [model]...",
Aliases: []string{"rm"},
Short: "Remove one or more models from the local repository",
Args: cobra.MinimumNArgs(1),
PreRunE: checkServerHeartbeat,
RunE: removeHandler,
}
}
func removeHandler(cmd *cobra.Command, args []string) error {
c := client.New()
for _, arg := range args {
// TODO: stop model if it's running; skip if model is cloud
if err := c.Delete(cmd.Context(), api.DeleteRequest{Model: arg}); err != nil {
return err
}
fmt.Println("deleted", arg)
}
return nil
}