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/VERSION b/VERSION index 9e3a933..cbe06cd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.4.3 +4.4.4 diff --git a/internal/config/models.go b/internal/config/models.go index f583749..a9c22b0 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 `json:"id"` + 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,23 @@ 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/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") } diff --git a/internal/httpapi/ollama/handler_routes.go b/internal/httpapi/ollama/handler_routes.go new file mode 100644 index 0000000..fb64a06 --- /dev/null +++ b/internal/httpapi/ollama/handler_routes.go @@ -0,0 +1,58 @@ +package ollama + +import ( + "ds2api/internal/config" + "ds2api/internal/util" + "encoding/json" + "github.com/go-chi/chi/v5" + "log/slog" + "net/http" +) + +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 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 { + 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..0d22779 --- /dev/null +++ b/internal/httpapi/ollama/handler_routes_test.go @@ -0,0 +1,127 @@ +package ollama + +import ( + "encoding/json" + "github.com/go-chi/chi/v5" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +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()) + } + 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.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()) + } +} diff --git a/internal/server/router.go b/internal/server/router.go index 7ec7eef..07b5e15 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" @@ -68,6 +69,7 @@ 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() @@ -112,6 +114,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) })