Skip to content
Merged
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
18 changes: 18 additions & 0 deletions API.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -617,6 +621,20 @@ Returns SSE (`text/event-stream`), each chunk as `data: <json>`:

---

## Ollama API

- `POST /api/show` request body: `{"model":"<model-id>"}`.
- 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`
Expand Down
18 changes: 18 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- [OpenAI 兼容接口](#openai-兼容接口)
- [Claude 兼容接口](#claude-兼容接口)
- [Gemini 兼容接口](#gemini-兼容接口)
- [Ollama 兼容接口](#ollama-兼容接口)
- [Admin 接口](#admin-接口)
- [错误响应格式](#错误响应格式)
- [cURL 示例](#curl-示例)
Expand Down Expand Up @@ -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 预配置 |
Expand Down Expand Up @@ -628,6 +632,20 @@ data: {"type":"message_stop"}

---

## Ollama 兼容接口

- `POST /api/show` 请求体:`{"model":"<model-id>"}`。
- 响应字段使用小写 `id`(不是 `ID`),并返回 `capabilities` 数组,便于与 Ollama 风格客户端/严格 schema 对齐。

示例响应:

```json
{
"id": "deepseek-v4-flash",
"capabilities": ["tools", "thinking"]
}
```

## Admin 接口

### `POST /admin/login`
Expand Down
24 changes: 11 additions & 13 deletions internal/config/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
27 changes: 16 additions & 11 deletions internal/httpapi/ollama/handler_routes.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
57 changes: 32 additions & 25 deletions internal/httpapi/ollama/handler_routes_test.go
Original file line number Diff line number Diff line change
@@ -1,78 +1,85 @@
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 {
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)
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 := 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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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)
Expand Down
Loading