From a68a79e087db7a97b38e74daeef6e18b4c6eea60 Mon Sep 17 00:00:00 2001 From: Dinh Nguyen Date: Thu, 7 May 2026 09:41:46 +0700 Subject: [PATCH] Add ollama api for copilot support --- internal/config/models.go | 66 +++++++++- internal/httpapi/ollama/handler_routes.go | 53 ++++++++ .../httpapi/ollama/handler_routes_test.go | 122 ++++++++++++++++++ internal/server/router.go | 5 + 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 internal/httpapi/ollama/handler_routes.go create mode 100644 internal/httpapi/ollama/handler_routes_test.go diff --git a/internal/config/models.go b/internal/config/models.go index f583749..fe0b0d6 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -1,6 +1,9 @@ package config -import "strings" +import ( + "strings" + "time" +) type ModelInfo struct { ID string `json:"id"` @@ -9,6 +12,16 @@ type ModelInfo struct { OwnedBy string `json:"owned_by"` Permission []any `json:"permission,omitempty"` } +type OllamaModelInfo struct { + Name string `json:"name"` + Model string `json:"model"` + Size int64 `json:"size"` + ModifiedAt string `json:"modified_at"` +} +type OllamaCapabilitiesModelInfo struct { + ID string + Capabilities []string `json:"capabilities"` +} type ModelAliasReader interface { ModelAliases() map[string]string @@ -24,8 +37,21 @@ var deepSeekBaseModels = []ModelInfo{ {ID: "deepseek-v4-vision", Object: "model", Created: 1677610602, OwnedBy: "deepseek", Permission: []any{}}, } -var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels) +var OllamaCapabilitiesModels = []OllamaCapabilitiesModelInfo{ + {ID: "deepseek-v4-flash", Capabilities: []string{"tools","thinking"}}, + {ID: "deepseek-v4-pro", Capabilities: []string{"tools","thinking"}}, + {ID: "deepseek-v4-flash-search", Capabilities: []string{"tools","thinking"}}, + {ID: "deepseek-v4-pro-search", Capabilities: []string{"tools","thinking"}}, + {ID: "deepseek-v4-vision", Capabilities: []string{"tools","thinking","vision"}}, + {ID: "deepseek-v4-flash-nothinking", Capabilities: []string{"tools"}}, + {ID: "deepseek-v4-pro-nothinking", Capabilities: []string{"tools"}}, + {ID: "deepseek-v4-flash-search-nothinking", Capabilities: []string{"tools"}}, + {ID: "deepseek-v4-pro-search-nothinking", Capabilities: []string{"tools"}}, + {ID: "deepseek-v4-vision-nothinking", Capabilities: []string{"tools","vision"}}, +} +var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels) +var OllamaModels = mapToOllamaModels(DeepSeekModels) var claudeBaseModels = []ModelInfo{ // Current aliases {ID: "claude-opus-4-6", Object: "model", Created: 1715635200, OwnedBy: "anthropic"}, @@ -247,6 +273,23 @@ func OpenAIModelByID(store ModelAliasReader, id string) (ModelInfo, bool) { return ModelInfo{}, false } +func OllamaModelsResponse() map[string]any { + return map[string]any{"models": OllamaModels} +} + +func OllamaModelByID(store ModelAliasReader, id string) (OllamaCapabilitiesModelInfo, bool) { + canonical, ok := ResolveModel(store, id) + if !ok { + return OllamaCapabilitiesModelInfo{}, false + } + for _, model := range OllamaCapabilitiesModels { + if model.ID == canonical { + return model, true + } + } + return OllamaCapabilitiesModelInfo{}, false +} + func ClaudeModelsResponse() map[string]any { resp := map[string]any{"object": "list", "data": ClaudeModels} if len(ClaudeModels) > 0 { @@ -270,6 +313,25 @@ func appendNoThinkingVariants(models []ModelInfo) []ModelInfo { } return out } +func mapToOllamaModels(models []ModelInfo) []OllamaModelInfo { + out := make([]OllamaModelInfo, 0, len(models)) + for _, model := range models { + var modifiedAt string + if model.Created > 0 { + modifiedAt = time.Unix(model.Created, 0).Format(time.RFC3339) + } + ollamaModel := OllamaModelInfo{ + Name: model.ID, + Model: model.ID, + Size: 0, + ModifiedAt: modifiedAt, + } + out = append(out, ollamaModel) + } + return out +} + + func splitNoThinkingModel(model string) (string, bool) { model = lower(strings.TrimSpace(model)) diff --git a/internal/httpapi/ollama/handler_routes.go b/internal/httpapi/ollama/handler_routes.go new file mode 100644 index 0000000..bd2ca71 --- /dev/null +++ b/internal/httpapi/ollama/handler_routes.go @@ -0,0 +1,53 @@ +package ollama + +import ( + "encoding/json" + "net/http" + "github.com/go-chi/chi/v5" + "ds2api/internal/config" + "ds2api/internal/util" +) + +var WriteJSON = util.WriteJSON + +type ConfigReader interface { + ModelAliases() map[string]string +} + +type Handler struct { + Store ConfigReader +} + +type OllamaModelRequest struct { + Model string `json:"model"` +} + +func RegisterRoutes(r chi.Router, h *Handler) { + r.Get("/api/version", h.GetVersion) + r.Get("/api/tags", h.ListOllamaModels) + r.Post("/api/show", h.GetOllamaModel) +} + +func (h *Handler) GetVersion(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"version":"0.23.1"}`)) +} +func (h *Handler) ListOllamaModels(w http.ResponseWriter, r *http.Request) { + WriteJSON(w, http.StatusOK, config.OllamaModelsResponse()) +} +func (h *Handler) GetOllamaModel(w http.ResponseWriter, r *http.Request) { + var payload OllamaModelRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest) + return + } + defer r.Body.Close() + modelID := payload.Model + model, ok := config.OllamaModelByID(h.Store, modelID) + if !ok { + http.Error(w, "Model not found.", http.StatusNotFound) + return + } + WriteJSON(w, http.StatusOK, model) +} diff --git a/internal/httpapi/ollama/handler_routes_test.go b/internal/httpapi/ollama/handler_routes_test.go new file mode 100644 index 0000000..eafed87 --- /dev/null +++ b/internal/httpapi/ollama/handler_routes_test.go @@ -0,0 +1,122 @@ +package ollama + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +type ollamaTestSurface struct { + Store shared.ConfigReader + handler *Handler +} + +GetVersion(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"version":"0.23.1"}`)) +} +func (h *Handler) ListOllamaModels(w http.ResponseWriter, r *http.Request) { + WriteJSON(w, http.StatusOK, config.OllamaModelsResponse()) +} +func (h *Handler) GetOllamaModel + +func registerOllamaTestRoutes(r chi.Router, h *ollamaTestSurface) { + r.Get("/api/version", h.handler().GetVersion) + r.Get("/api/tags", h.modelsHandler().ListOllamaModels) + r.Post("/api/show", h.chatHandler().GetOllamaModel) +} + + +func TestGetOllamaVersionRoute(t *testing.T) { + h := &ollamaTestSurface{} + r := chi.NewRouter() + registerOllamaTestRoutes(r, h) + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } +} + + +func TestGetOllamaModelsRoute(t *testing.T) { + h := &ollamaTestSurface{} + r := chi.NewRouter() + registerOllamaTestRoutes(r, h) + req := httptest.NewRequest(http.MethodGet, "/api/tags", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } +} + + +func TestGetOllamaModelRoute(t *testing.T) { + h := &ollamaTestSurface{} + r := chi.NewRouter() + registerOllamaTestRoutes(r, h) + + t.Run("direct", func(t *testing.T) { + body := `{"model":"deepseek-v4-flash"}` + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + }) + + t.Run("direct_nothinking", func(t *testing.T) { + body := `{"model":"deepseek-v4-flash-nothinking"}` + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + }) + + t.Run("direct_expert", func(t *testing.T) { + body := `{"model":"models/deepseek-v4-pro"}` + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + }) + + t.Run("direct_vision", func(t *testing.T) { + body := `{"model":"deepseek-v4-vision"}` + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + }) +} + +func TestGetOllamaModelRouteNotFound(t *testing.T) { + h := &ollamaTestSurface{} + r := chi.NewRouter() + registerOllamaTestRoutes(r, h) + + body := `{"model":"not-exists"}` + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 7ec7eef..ca883cc 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -22,6 +22,7 @@ import ( "ds2api/internal/httpapi/admin" "ds2api/internal/httpapi/claude" "ds2api/internal/httpapi/gemini" + "ds2api/internal/httpapi/ollama" "ds2api/internal/httpapi/openai/chat" "ds2api/internal/httpapi/openai/embeddings" "ds2api/internal/httpapi/openai/files" @@ -60,6 +61,7 @@ func NewApp() (*App, error) { config.Logger.Warn("[chat_history] unavailable", "path", chatHistoryStore.Path(), "error", err) } + modelsHandler := &shared.ModelsHandler{Store: store} chatHandler := &chat.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} responsesHandler := &responses.Handler{Store: store, Auth: resolver, DS: dsClient, ChatHistory: chatHistoryStore} @@ -68,7 +70,9 @@ func NewApp() (*App, error) { claudeHandler := &claude.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore} geminiHandler := &gemini.Handler{Store: store, Auth: resolver, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore} adminHandler := &admin.Handler{Store: store, Pool: pool, DS: dsClient, OpenAI: chatHandler, ChatHistory: chatHistoryStore} + ollamaHandler := &ollama.Handler{Store: store} webuiHandler := webui.NewHandler() + r := chi.NewRouter() r.Use(middleware.RequestID) @@ -112,6 +116,7 @@ func NewApp() (*App, error) { r.Post("/embeddings", embeddingsHandler.Embeddings) claude.RegisterRoutes(r, claudeHandler) gemini.RegisterRoutes(r, geminiHandler) + ollama.RegisterRoutes(r, ollamaHandler) r.Route("/admin", func(ar chi.Router) { admin.RegisterRoutes(ar, adminHandler) })