- Add model registry backend that scrapes ollama.com library page - Extract capabilities (vision, tools, thinking, embedding, cloud) from HTML - Store models in SQLite with search, filter by type and capabilities - Add tag sizes fetching from individual model pages - Create Model Browser UI with search, filters, and pagination - Implement streaming model pull with progress bar - Auto-refresh model selector and select new model after pull - Add cloud capability detection (uses different HTML pattern) - Update Go version to 1.24 in Dockerfile 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
404 lines
10 KiB
Go
404 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/ollama/ollama/api"
|
|
)
|
|
|
|
// OllamaService wraps the official Ollama client
|
|
type OllamaService struct {
|
|
client *api.Client
|
|
ollamaURL string
|
|
}
|
|
|
|
// Client returns the underlying Ollama API client
|
|
func (s *OllamaService) Client() *api.Client {
|
|
return s.client
|
|
}
|
|
|
|
// NewOllamaService creates a new Ollama service with the official client
|
|
func NewOllamaService(ollamaURL string) (*OllamaService, error) {
|
|
baseURL, err := url.Parse(ollamaURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid Ollama URL: %w", err)
|
|
}
|
|
|
|
client := api.NewClient(baseURL, http.DefaultClient)
|
|
|
|
return &OllamaService{
|
|
client: client,
|
|
ollamaURL: ollamaURL,
|
|
}, nil
|
|
}
|
|
|
|
// ListModelsHandler returns available models
|
|
func (s *OllamaService) ListModelsHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
resp, err := s.client.List(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to list models: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
// ShowModelHandler returns model details
|
|
func (s *OllamaService) ShowModelHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req api.ShowRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
resp, err := s.client.Show(c.Request.Context(), &req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to show model: " + err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
// ChatHandler handles streaming chat requests
|
|
func (s *OllamaService) ChatHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req api.ChatRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check if streaming is requested (default true for chat)
|
|
streaming := req.Stream == nil || *req.Stream
|
|
|
|
if streaming {
|
|
s.handleStreamingChat(c, &req)
|
|
} else {
|
|
s.handleNonStreamingChat(c, &req)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleStreamingChat handles streaming chat responses
|
|
func (s *OllamaService) handleStreamingChat(c *gin.Context, req *api.ChatRequest) {
|
|
// Set headers for streaming
|
|
c.Header("Content-Type", "application/x-ndjson")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("Transfer-Encoding", "chunked")
|
|
|
|
ctx := c.Request.Context()
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
|
return
|
|
}
|
|
|
|
err := s.client.Chat(ctx, req, func(resp api.ChatResponse) error {
|
|
// Check if context is cancelled
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
// Marshal and write response
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = c.Writer.Write(append(data, '\n'))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
})
|
|
|
|
if err != nil && err != context.Canceled {
|
|
// Write error as final message if we haven't finished
|
|
errResp := gin.H{"error": err.Error()}
|
|
data, _ := json.Marshal(errResp)
|
|
c.Writer.Write(append(data, '\n'))
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
// handleNonStreamingChat handles non-streaming chat responses
|
|
func (s *OllamaService) handleNonStreamingChat(c *gin.Context, req *api.ChatRequest) {
|
|
var finalResp api.ChatResponse
|
|
|
|
err := s.client.Chat(c.Request.Context(), req, func(resp api.ChatResponse) error {
|
|
finalResp = resp
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "chat failed: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, finalResp)
|
|
}
|
|
|
|
// GenerateHandler handles streaming generate requests
|
|
func (s *OllamaService) GenerateHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req api.GenerateRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check if streaming is requested (default true)
|
|
streaming := req.Stream == nil || *req.Stream
|
|
|
|
if streaming {
|
|
s.handleStreamingGenerate(c, &req)
|
|
} else {
|
|
s.handleNonStreamingGenerate(c, &req)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleStreamingGenerate handles streaming generate responses
|
|
func (s *OllamaService) handleStreamingGenerate(c *gin.Context, req *api.GenerateRequest) {
|
|
c.Header("Content-Type", "application/x-ndjson")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
c.Header("Transfer-Encoding", "chunked")
|
|
|
|
ctx := c.Request.Context()
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
|
return
|
|
}
|
|
|
|
err := s.client.Generate(ctx, req, func(resp api.GenerateResponse) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = c.Writer.Write(append(data, '\n'))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
})
|
|
|
|
if err != nil && err != context.Canceled {
|
|
errResp := gin.H{"error": err.Error()}
|
|
data, _ := json.Marshal(errResp)
|
|
c.Writer.Write(append(data, '\n'))
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
// handleNonStreamingGenerate handles non-streaming generate responses
|
|
func (s *OllamaService) handleNonStreamingGenerate(c *gin.Context, req *api.GenerateRequest) {
|
|
var finalResp api.GenerateResponse
|
|
|
|
err := s.client.Generate(c.Request.Context(), req, func(resp api.GenerateResponse) error {
|
|
finalResp = resp
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "generate failed: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, finalResp)
|
|
}
|
|
|
|
// EmbedHandler handles embedding requests
|
|
func (s *OllamaService) EmbedHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req api.EmbedRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
resp, err := s.client.Embed(c.Request.Context(), &req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "embed failed: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, resp)
|
|
}
|
|
}
|
|
|
|
// PullModelHandler handles model pull requests with progress streaming
|
|
func (s *OllamaService) PullModelHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req api.PullRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "application/x-ndjson")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("Connection", "keep-alive")
|
|
|
|
ctx := c.Request.Context()
|
|
flusher, ok := c.Writer.(http.Flusher)
|
|
if !ok {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "streaming not supported"})
|
|
return
|
|
}
|
|
|
|
err := s.client.Pull(ctx, &req, func(resp api.ProgressResponse) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = c.Writer.Write(append(data, '\n'))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
})
|
|
|
|
if err != nil && err != context.Canceled {
|
|
errResp := gin.H{"error": err.Error()}
|
|
data, _ := json.Marshal(errResp)
|
|
c.Writer.Write(append(data, '\n'))
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// DeleteModelHandler handles model deletion
|
|
func (s *OllamaService) DeleteModelHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req api.DeleteRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
err := s.client.Delete(c.Request.Context(), &req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "delete failed: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
}
|
|
}
|
|
|
|
// CopyModelHandler handles model copying
|
|
func (s *OllamaService) CopyModelHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req api.CopyRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
err := s.client.Copy(c.Request.Context(), &req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "copy failed: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "success"})
|
|
}
|
|
}
|
|
|
|
// VersionHandler returns Ollama version
|
|
func (s *OllamaService) VersionHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
version, err := s.client.Version(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to get version: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"version": version})
|
|
}
|
|
}
|
|
|
|
// HeartbeatHandler checks if Ollama is running
|
|
func (s *OllamaService) HeartbeatHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
err := s.client.Heartbeat(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "Ollama not reachable: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
}
|
|
|
|
// ProxyHandler provides a generic proxy for any Ollama endpoint not explicitly handled
|
|
// This is kept for backwards compatibility with frontend direct calls
|
|
func (s *OllamaService) ProxyHandler() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
path := c.Param("path")
|
|
targetURL := s.ollamaURL + path
|
|
|
|
req, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, targetURL, c.Request.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create proxy request"})
|
|
return
|
|
}
|
|
|
|
// Copy headers
|
|
for key, values := range c.Request.Header {
|
|
for _, value := range values {
|
|
req.Header.Add(key, value)
|
|
}
|
|
}
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to reach Ollama: " + err.Error()})
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Copy response headers
|
|
for key, values := range resp.Header {
|
|
for _, value := range values {
|
|
c.Header(key, value)
|
|
}
|
|
}
|
|
|
|
c.Status(resp.StatusCode)
|
|
io.Copy(c.Writer, resp.Body)
|
|
}
|
|
}
|