From 2378f0fbe732bbad149f82e3267457d1aabee548 Mon Sep 17 00:00:00 2001 From: waiwai <511158080@qq.com> Date: Wed, 6 May 2026 11:10:14 +0800 Subject: [PATCH 1/6] fix: auto-detect Vercel for chat history path On Vercel, /var/task is read-only at runtime. ChatHistoryPath() now auto-detects Vercel via IsVercel() and defaults to /tmp/chat_history.json when no explicit DS2API_CHAT_HISTORY_PATH is set. Manual env var still works as explicit override. --- internal/config/paths.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/config/paths.go b/internal/config/paths.go index 7b8f22d..60f2829 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -58,6 +58,11 @@ func RawStreamSampleRoot() string { } func ChatHistoryPath() string { + // On Vercel, /var/task is read-only at runtime. If no explicit path is set, + // default to /tmp/chat_history.json (the only writable directory). + if IsVercel() && strings.TrimSpace(os.Getenv("DS2API_CHAT_HISTORY_PATH")) == "" { + return "/tmp/chat_history.json" + } return ResolvePath("DS2API_CHAT_HISTORY_PATH", "data/chat_history.json") } From c8db66615cf28f51aab21348b5c6b06a283ee814 Mon Sep 17 00:00:00 2001 From: "CJACK." <155826701+CJackHwang@users.noreply.github.com> Date: Wed, 6 May 2026 13:04:16 +0800 Subject: [PATCH 2/6] Update VERSION --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 9e3a933..cbe06cd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.4.3 +4.4.4 From a68a79e087db7a97b38e74daeef6e18b4c6eea60 Mon Sep 17 00:00:00 2001 From: Dinh Nguyen Date: Thu, 7 May 2026 09:41:46 +0700 Subject: [PATCH 3/6] 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) }) From ffef451f7a6eed2d60f8becb869977b090daba88 Mon Sep 17 00:00:00 2001 From: dinhnn Date: Thu, 7 May 2026 13:48:03 +0700 Subject: [PATCH 4/6] Fixbug test typing --- internal/httpapi/ollama/handler_routes_test.go | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/internal/httpapi/ollama/handler_routes_test.go b/internal/httpapi/ollama/handler_routes_test.go index eafed87..54cee70 100644 --- a/internal/httpapi/ollama/handler_routes_test.go +++ b/internal/httpapi/ollama/handler_routes_test.go @@ -4,29 +4,19 @@ import ( "net/http" "net/http/httptest" "testing" - + "strings" "github.com/go-chi/chi/v5" ) type ollamaTestSurface struct { - Store shared.ConfigReader + Store 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) + r.Get("/api/tags", h.handler().ListOllamaModels) + r.Post("/api/show", h.handler().GetOllamaModel) } From d0d61a5d7758634948da66cbb365a7b6f5c09c76 Mon Sep 17 00:00:00 2001 From: Dinh Nguyen Date: Thu, 7 May 2026 14:23:12 +0700 Subject: [PATCH 5/6] Update ollama api test --- .../httpapi/ollama/handler_routes_test.go | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/internal/httpapi/ollama/handler_routes_test.go b/internal/httpapi/ollama/handler_routes_test.go index 54cee70..43ddfa9 100644 --- a/internal/httpapi/ollama/handler_routes_test.go +++ b/internal/httpapi/ollama/handler_routes_test.go @@ -1,22 +1,30 @@ package ollama import ( - "net/http" - "net/http/httptest" - "testing" - "strings" - "github.com/go-chi/chi/v5" + "net/http" + "net/http/httptest" + "testing" + "strings" + "github.com/go-chi/chi/v5" ) type ollamaTestSurface struct { - Store ConfigReader - handler *Handler + 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.handler().GetVersion) - r.Get("/api/tags", h.handler().ListOllamaModels) - r.Post("/api/show", h.handler().GetOllamaModel) + r.Get("/api/version", h.apiHandler().GetVersion) + r.Get("/api/tags", h.apiHandler().ListOllamaModels) + r.Post("/api/show", h.apiHandler().GetOllamaModel) } @@ -74,7 +82,7 @@ func TestGetOllamaModelRoute(t *testing.T) { }) t.Run("direct_expert", func(t *testing.T) { - body := `{"model":"models/deepseek-v4-pro"}` + 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() From 657b9379ede10454bc41dce69b7ad9eb6ef6a31e Mon Sep 17 00:00:00 2001 From: "CJACK." <155826701+CJackHwang@users.noreply.github.com> Date: Fri, 8 May 2026 01:11:35 +0800 Subject: [PATCH 6/6] test(docs): assert ollama show id field and document ollama endpoints --- API.en.md | 18 ++++++ API.md | 18 ++++++ internal/config/models.go | 24 ++++---- internal/httpapi/ollama/handler_routes.go | 27 +++++---- .../httpapi/ollama/handler_routes_test.go | 57 +++++++++++-------- internal/server/router.go | 2 - 6 files changed, 95 insertions(+), 51 deletions(-) diff --git a/API.en.md b/API.en.md index ccb3e74..7c29663 100644 --- a/API.en.md +++ b/API.en.md @@ -18,6 +18,7 @@ Docs: [Overview](README.en.md) / [Architecture](docs/ARCHITECTURE.en.md) / [Depl - [OpenAI-Compatible API](#openai-compatible-api) - [Claude-Compatible API](#claude-compatible-api) - [Gemini-Compatible API](#gemini-compatible-api) +- [Ollama API](#ollama-api) - [Admin API](#admin-api) - [Error Payloads](#error-payloads) - [cURL Examples](#curl-examples) @@ -123,6 +124,9 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | POST | `/v1beta/models/{model}:streamGenerateContent` | Business | Gemini stream | | POST | `/v1/models/{model}:generateContent` | Business | Gemini non-stream compat path | | POST | `/v1/models/{model}:streamGenerateContent` | Business | Gemini stream compat path | +| GET | `/api/version` | None | Ollama version endpoint | +| GET | `/api/tags` | None | Ollama model list | +| POST | `/api/show` | None | Ollama model capability query (returns `id` + `capabilities`) | | POST | `/admin/login` | None | Admin login | | GET | `/admin/verify` | JWT | Verify admin JWT | | GET | `/admin/vercel/config` | Admin | Read preconfigured Vercel creds | @@ -617,6 +621,20 @@ Returns SSE (`text/event-stream`), each chunk as `data: `: --- +## Ollama API + +- `POST /api/show` request body: `{"model":""}`. +- Response uses lowercase `id` (not `ID`) and includes `capabilities` for Ollama-style clients and strict schemas. + +Example response: + +```json +{ + "id": "deepseek-v4-flash", + "capabilities": ["tools", "thinking"] +} +``` + ## Admin API ### `POST /admin/login` diff --git a/API.md b/API.md index bdfe31b..ff5f6c1 100644 --- a/API.md +++ b/API.md @@ -18,6 +18,7 @@ - [OpenAI 兼容接口](#openai-兼容接口) - [Claude 兼容接口](#claude-兼容接口) - [Gemini 兼容接口](#gemini-兼容接口) +- [Ollama 兼容接口](#ollama-兼容接口) - [Admin 接口](#admin-接口) - [错误响应格式](#错误响应格式) - [cURL 示例](#curl-示例) @@ -125,6 +126,9 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | POST | `/v1beta/models/{model}:streamGenerateContent` | 业务 | Gemini 流式 | | POST | `/v1/models/{model}:generateContent` | 业务 | Gemini 非流式兼容路径 | | POST | `/v1/models/{model}:streamGenerateContent` | 业务 | Gemini 流式兼容路径 | +| GET | `/api/version` | 无 | Ollama 版本接口 | +| GET | `/api/tags` | 无 | Ollama 模型列表 | +| POST | `/api/show` | 无 | Ollama 单模型能力查询(返回 `id` 与 `capabilities`) | | POST | `/admin/login` | 无 | 管理登录 | | GET | `/admin/verify` | JWT | 校验管理 JWT | | GET | `/admin/vercel/config` | Admin | 读取 Vercel 预配置 | @@ -628,6 +632,20 @@ data: {"type":"message_stop"} --- +## Ollama 兼容接口 + +- `POST /api/show` 请求体:`{"model":""}`。 +- 响应字段使用小写 `id`(不是 `ID`),并返回 `capabilities` 数组,便于与 Ollama 风格客户端/严格 schema 对齐。 + +示例响应: + +```json +{ + "id": "deepseek-v4-flash", + "capabilities": ["tools", "thinking"] +} +``` + ## Admin 接口 ### `POST /admin/login` diff --git a/internal/config/models.go b/internal/config/models.go index fe0b0d6..a9c22b0 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -19,7 +19,7 @@ type OllamaModelInfo struct { ModifiedAt string `json:"modified_at"` } type OllamaCapabilitiesModelInfo struct { - ID string + ID string `json:"id"` Capabilities []string `json:"capabilities"` } @@ -38,16 +38,16 @@ var deepSeekBaseModels = []ModelInfo{ } 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", 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"}}, + {ID: "deepseek-v4-vision-nothinking", Capabilities: []string{"tools", "vision"}}, } var DeepSeekModels = appendNoThinkingVariants(deepSeekBaseModels) @@ -317,10 +317,10 @@ 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{ + if model.Created > 0 { + modifiedAt = time.Unix(model.Created, 0).Format(time.RFC3339) + } + ollamaModel := OllamaModelInfo{ Name: model.ID, Model: model.ID, Size: 0, @@ -331,8 +331,6 @@ func mapToOllamaModels(models []ModelInfo) []OllamaModelInfo { return out } - - func splitNoThinkingModel(model string) (string, bool) { model = lower(strings.TrimSpace(model)) if strings.HasSuffix(model, noThinkingModelSuffix) { diff --git a/internal/httpapi/ollama/handler_routes.go b/internal/httpapi/ollama/handler_routes.go index bd2ca71..fb64a06 100644 --- a/internal/httpapi/ollama/handler_routes.go +++ b/internal/httpapi/ollama/handler_routes.go @@ -1,11 +1,12 @@ package ollama import ( + "ds2api/internal/config" + "ds2api/internal/util" "encoding/json" - "net/http" "github.com/go-chi/chi/v5" - "ds2api/internal/config" - "ds2api/internal/util" + "log/slog" + "net/http" ) var WriteJSON = util.WriteJSON @@ -15,11 +16,11 @@ type ConfigReader interface { } type Handler struct { - Store ConfigReader + Store ConfigReader } type OllamaModelRequest struct { - Model string `json:"model"` + Model string `json:"model"` } func RegisterRoutes(r chi.Router, h *Handler) { @@ -31,18 +32,22 @@ func RegisterRoutes(r chi.Router, h *Handler) { 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"}`)) + _, _ = 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() + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "Invalid JSON body: "+err.Error(), http.StatusBadRequest) + return + } + defer func() { + if err := r.Body.Close(); err != nil { + slog.Warn("[ollama] failed to close request body", "error", err) + } + }() modelID := payload.Model model, ok := config.OllamaModelByID(h.Store, modelID) if !ok { diff --git a/internal/httpapi/ollama/handler_routes_test.go b/internal/httpapi/ollama/handler_routes_test.go index 43ddfa9..0d22779 100644 --- a/internal/httpapi/ollama/handler_routes_test.go +++ b/internal/httpapi/ollama/handler_routes_test.go @@ -1,38 +1,37 @@ package ollama import ( - "net/http" - "net/http/httptest" - "testing" - "strings" - "github.com/go-chi/chi/v5" + "encoding/json" + "github.com/go-chi/chi/v5" + "net/http" + "net/http/httptest" + "strings" + "testing" ) type ollamaTestSurface struct { - Store ConfigReader - handler *Handler + Store ConfigReader + handler *Handler } func (h *ollamaTestSurface) apiHandler() *Handler { - if h.handler == nil { - h.handler = &Handler{Store: h.Store} - } - return h.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) + 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) + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -40,12 +39,11 @@ func TestGetOllamaVersionRoute(t *testing.T) { } } - func TestGetOllamaModelsRoute(t *testing.T) { h := &ollamaTestSurface{} r := chi.NewRouter() registerOllamaTestRoutes(r, h) - req := httptest.NewRequest(http.MethodGet, "/api/tags", nil) + req := httptest.NewRequest(http.MethodGet, "/api/tags", nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -53,7 +51,6 @@ func TestGetOllamaModelsRoute(t *testing.T) { } } - func TestGetOllamaModelRoute(t *testing.T) { h := &ollamaTestSurface{} r := chi.NewRouter() @@ -61,18 +58,28 @@ func TestGetOllamaModelRoute(t *testing.T) { t.Run("direct", func(t *testing.T) { body := `{"model":"deepseek-v4-flash"}` - req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + 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()) } + var payload map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("expected valid json body, got err=%v body=%s", err, rec.Body.String()) + } + if _, ok := payload["id"]; !ok { + t.Fatalf("expected response has lowercase id field, body=%s", rec.Body.String()) + } + if _, ok := payload["ID"]; ok { + t.Fatalf("expected response does not expose uppercase ID field, body=%s", 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 := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -83,7 +90,7 @@ func TestGetOllamaModelRoute(t *testing.T) { t.Run("direct_expert", func(t *testing.T) { body := `{"model":"deepseek-v4-pro"}` - req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -94,7 +101,7 @@ func TestGetOllamaModelRoute(t *testing.T) { t.Run("direct_vision", func(t *testing.T) { body := `{"model":"deepseek-v4-vision"}` - req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) @@ -110,7 +117,7 @@ func TestGetOllamaModelRouteNotFound(t *testing.T) { registerOllamaTestRoutes(r, h) body := `{"model":"not-exists"}` - req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) diff --git a/internal/server/router.go b/internal/server/router.go index ca883cc..07b5e15 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -61,7 +61,6 @@ 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} @@ -72,7 +71,6 @@ func NewApp() (*App, error) { 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)