mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-08 02:15:27 +08:00
Merge pull request #446 from dinhnn/main
feat: add Ollama API endpoints /api/version, /api/tags, /api/show for Copilot integration
This commit is contained in:
@@ -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))
|
||||
|
||||
53
internal/httpapi/ollama/handler_routes.go
Normal file
53
internal/httpapi/ollama/handler_routes.go
Normal file
@@ -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)
|
||||
}
|
||||
120
internal/httpapi/ollama/handler_routes_test.go
Normal file
120
internal/httpapi/ollama/handler_routes_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package ollama
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"strings"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type ollamaTestSurface struct {
|
||||
Store ConfigReader
|
||||
handler *Handler
|
||||
}
|
||||
|
||||
func (h *ollamaTestSurface) apiHandler() *Handler {
|
||||
if h.handler == nil {
|
||||
h.handler = &Handler{Store: h.Store}
|
||||
}
|
||||
return h.handler
|
||||
}
|
||||
|
||||
|
||||
func registerOllamaTestRoutes(r chi.Router, h *ollamaTestSurface) {
|
||||
r.Get("/api/version", h.apiHandler().GetVersion)
|
||||
r.Get("/api/tags", h.apiHandler().ListOllamaModels)
|
||||
r.Post("/api/show", h.apiHandler().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":"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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user