Skip to content
Closed
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
66 changes: 64 additions & 2 deletions internal/config/models.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package config

import "strings"
import (
"strings"
"time"
)

type ModelInfo struct {
ID string `json:"id"`
Expand All @@ -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
Expand All @@ -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"},
Expand Down Expand Up @@ -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 {
Comment on lines +281 to +282
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize models/-prefixed IDs before resolving

/api/show forwards payload.model directly into ResolveModel, which only accepts canonical model names or configured aliases. Inputs like "models/deepseek-v4-pro" therefore resolve to not found and return 404, even though they refer to supported models (this exact form is exercised in the added route tests). Any Copilot/Ollama client that sends prefixed model IDs will fail model capability discovery.

Useful? React with 👍 / 👎.

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 {
Expand All @@ -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))
Expand Down
53 changes: 53 additions & 0 deletions internal/httpapi/ollama/handler_routes.go
Original file line number Diff line number Diff line change
@@ -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)
}
122 changes: 122 additions & 0 deletions internal/httpapi/ollama/handler_routes_test.go
Original file line number Diff line number Diff line change
@@ -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) {

Check failure on line 16 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Go Unit (windows-latest)

expected declaration, found GetVersion

Check failure on line 16 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Lint and Refactor Gate

expected declaration, found GetVersion (typecheck)

Check failure on line 16 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Lint and Refactor Gate

syntax error: non-declaration statement outside function body

Check failure on line 16 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Unit Gates (Go + Node)

expected declaration, found GetVersion

Check failure on line 16 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Go Unit (macos-latest)

expected declaration, found GetVersion
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

Check failure on line 24 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Lint and Refactor Gate

syntax error: unexpected newline, expected (

func registerOllamaTestRoutes(r chi.Router, h *ollamaTestSurface) {

Check failure on line 26 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Lint and Refactor Gate

syntax error: unexpected name registerOllamaTestRoutes, expected (
r.Get("/api/version", h.handler().GetVersion)

Check failure on line 27 in internal/httpapi/ollama/handler_routes_test.go

View workflow job for this annotation

GitHub Actions / Lint and Refactor Gate

syntax error: unexpected . after top level declaration (typecheck)
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())
}
}
5 changes: 5 additions & 0 deletions internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
})
Expand Down
Loading