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] 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)