Files
Memoh/internal/handlers/models.go
T
Acbox Liu b88ca96064 refactor: provider & models (#277)
* refactor: move client_type to provider, replace model fields with config JSONB

- Move `client_type` from `models` to `llm_providers` table
- Add `icon` field to `llm_providers`
- Replace `dimensions`, `input_modalities`, `supports_reasoning` on `models`
  with a single `config` JSONB column containing `dimensions`,
  `compatibilities` (vision, tool-call, image-output, reasoning),
  and `context_window`
- Auto-imported models default to vision + tool-call + reasoning
- Update all backend consumers (agent, flow resolver, handlers, memory)
- Regenerate sqlc, swagger, and TypeScript SDK
- Update frontend forms, display, and i18n for new schema

* ui: show provider icon avatar in sidebar and detail header, remove icon input

* feat: add built-in provider registry with YAML definitions and enable toggle

- Add `enable` column to llm_providers (default true, backward-compatible)
- Create internal/registry package to load YAML provider/model definitions
  on startup and upsert into database (new providers disabled by default)
- Add conf/providers/ with OpenAI, Anthropic, Google YAML definitions
- Add RegistryConfig to TOML config (providers_dir, default conf/providers)
- Model listing APIs and conversation flow now filter by enabled providers
- Frontend: enable switch in provider form, green status dot in sidebar,
  enabled providers sorted to top

* fix: make 0041 migration idempotent for fresh databases

Guard data migration steps with column-existence checks so the
migration succeeds on databases created from the updated init schema.
2026-03-22 17:24:45 +08:00

341 lines
11 KiB
Go

package handlers
import (
"errors"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/models"
)
type ModelsHandler struct {
service *models.Service
logger *slog.Logger
}
func NewModelsHandler(log *slog.Logger, service *models.Service) *ModelsHandler {
return &ModelsHandler{
service: service,
logger: log.With(slog.String("handler", "models")),
}
}
func (h *ModelsHandler) Register(e *echo.Echo) {
group := e.Group("/models")
group.POST("", h.Create)
group.GET("", h.List)
group.GET("/:id", h.GetByID)
group.GET("/model/:modelId", h.GetByModelID)
group.PUT("/:id", h.UpdateByID)
group.PUT("/model/:modelId", h.UpdateByModelID)
group.DELETE("/:id", h.DeleteByID)
group.DELETE("/model/:modelId", h.DeleteByModelID)
group.GET("/count", h.Count)
group.POST("/:id/test", h.Test)
}
// Create godoc
// @Summary Create a new model
// @Description Create a new model configuration
// @Tags models
// @Param payload body models.AddRequest true "Model configuration"
// @Success 201 {object} models.AddResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models [post].
func (h *ModelsHandler) Create(c echo.Context) error {
var req models.AddRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
resp, err := h.service.Create(c.Request().Context(), req)
if err != nil {
if errors.Is(err, models.ErrModelIDAlreadyExists) {
return echo.NewHTTPError(http.StatusConflict, "model_id already exists under the selected provider")
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusCreated, resp)
}
// List godoc
// @Summary List all models
// @Description Get a list of all configured models, optionally filtered by type or provider client type
// @Tags models
// @Param type query string false "Model type (chat, embedding)"
// @Param client_type query string false "Provider client type (openai-responses, openai-completions, anthropic-messages, google-generative-ai)"
// @Success 200 {array} models.GetResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models [get].
func (h *ModelsHandler) List(c echo.Context) error {
modelType := c.QueryParam("type")
clientType := c.QueryParam("client_type")
var resp []models.GetResponse
var err error
switch {
case modelType != "":
resp, err = h.service.ListEnabledByType(c.Request().Context(), models.ModelType(modelType))
case clientType != "":
resp, err = h.service.ListEnabledByProviderClientType(c.Request().Context(), models.ClientType(clientType))
default:
resp, err = h.service.ListEnabled(c.Request().Context())
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, resp)
}
// GetByID godoc
// @Summary Get model by internal ID
// @Description Get a model configuration by its internal UUID
// @Tags models
// @Param id path string true "Model internal ID (UUID)"
// @Success 200 {object} models.GetResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/{id} [get].
func (h *ModelsHandler) GetByID(c echo.Context) error {
id := c.Param("id")
if id == "" {
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
}
resp, err := h.service.GetByID(c.Request().Context(), id)
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return c.JSON(http.StatusOK, resp)
}
// GetByModelID godoc
// @Summary Get model by model ID
// @Description Get a model configuration by its model_id field (e.g., gpt-4)
// @Tags models
// @Param modelId path string true "Model ID (e.g., gpt-4)"
// @Success 200 {object} models.GetResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/model/{modelId} [get].
func (h *ModelsHandler) GetByModelID(c echo.Context) error {
modelID := c.Param("modelId")
if modelID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "modelId is required")
}
if decoded, err := url.PathUnescape(modelID); err == nil {
modelID = decoded
} else {
return echo.NewHTTPError(http.StatusBadRequest, "invalid modelId")
}
resp, err := h.service.GetByModelID(c.Request().Context(), modelID)
if err != nil {
if errors.Is(err, models.ErrModelIDAmbiguous) {
return echo.NewHTTPError(http.StatusConflict, "model_id is duplicated across providers; use /models/{id} instead")
}
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return c.JSON(http.StatusOK, resp)
}
// UpdateByID godoc
// @Summary Update model by internal ID
// @Description Update a model configuration by its internal UUID
// @Tags models
// @Param id path string true "Model internal ID (UUID)"
// @Param payload body models.UpdateRequest true "Updated model configuration"
// @Success 200 {object} models.GetResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/{id} [put].
func (h *ModelsHandler) UpdateByID(c echo.Context) error {
id := c.Param("id")
if id == "" {
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
}
var req models.UpdateRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
resp, err := h.service.UpdateByID(c.Request().Context(), id, req)
if err != nil {
if errors.Is(err, models.ErrModelIDAlreadyExists) {
return echo.NewHTTPError(http.StatusConflict, "model_id already exists under the selected provider")
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, resp)
}
// UpdateByModelID godoc
// @Summary Update model by model ID
// @Description Update a model configuration by its model_id field (e.g., gpt-4)
// @Tags models
// @Param modelId path string true "Model ID (e.g., gpt-4)"
// @Param payload body models.UpdateRequest true "Updated model configuration"
// @Success 200 {object} models.GetResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/model/{modelId} [put].
func (h *ModelsHandler) UpdateByModelID(c echo.Context) error {
modelID := c.Param("modelId")
if modelID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "modelId is required")
}
if decoded, err := url.PathUnescape(modelID); err == nil {
modelID = decoded
} else {
return echo.NewHTTPError(http.StatusBadRequest, "invalid modelId")
}
var req models.UpdateRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
resp, err := h.service.UpdateByModelID(c.Request().Context(), modelID, req)
if err != nil {
if errors.Is(err, models.ErrModelIDAlreadyExists) {
return echo.NewHTTPError(http.StatusConflict, "model_id already exists under the selected provider")
}
if errors.Is(err, models.ErrModelIDAmbiguous) {
return echo.NewHTTPError(http.StatusConflict, "model_id is duplicated across providers; use /models/{id} instead")
}
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, resp)
}
// DeleteByID godoc
// @Summary Delete model by internal ID
// @Description Delete a model configuration by its internal UUID
// @Tags models
// @Param id path string true "Model internal ID (UUID)"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/{id} [delete].
func (h *ModelsHandler) DeleteByID(c echo.Context) error {
id := c.Param("id")
if id == "" {
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
}
if err := h.service.DeleteByID(c.Request().Context(), id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.NoContent(http.StatusNoContent)
}
// DeleteByModelID godoc
// @Summary Delete model by model ID
// @Description Delete a model configuration by its model_id field (e.g., gpt-4)
// @Tags models
// @Param modelId path string true "Model ID (e.g., gpt-4)"
// @Success 204 "No Content"
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/model/{modelId} [delete].
func (h *ModelsHandler) DeleteByModelID(c echo.Context) error {
modelID := c.Param("modelId")
if modelID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "modelId is required")
}
if decoded, err := url.PathUnescape(modelID); err == nil {
modelID = decoded
} else {
return echo.NewHTTPError(http.StatusBadRequest, "invalid modelId")
}
if err := h.service.DeleteByModelID(c.Request().Context(), modelID); err != nil {
if errors.Is(err, models.ErrModelIDAmbiguous) {
return echo.NewHTTPError(http.StatusConflict, "model_id is duplicated across providers; use /models/{id} instead")
}
if errors.Is(err, pgx.ErrNoRows) {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.NoContent(http.StatusNoContent)
}
// Test godoc
// @Summary Test model connectivity
// @Description Probe a model's provider endpoint using the model's real model_id and client_type to verify configuration
// @Tags models
// @Accept json
// @Produce json
// @Param id path string true "Model internal ID (UUID)"
// @Success 200 {object} models.TestResponse
// @Failure 400 {object} ErrorResponse
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/{id}/test [post].
func (h *ModelsHandler) Test(c echo.Context) error {
id := c.Param("id")
if id == "" {
return echo.NewHTTPError(http.StatusBadRequest, "id is required")
}
resp, err := h.service.Test(c.Request().Context(), id)
if err != nil {
if strings.Contains(err.Error(), "invalid") {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return c.JSON(http.StatusOK, resp)
}
// Count godoc
// @Summary Get model count
// @Description Get the total count of models, optionally filtered by type
// @Tags models
// @Param type query string false "Model type (chat, embedding)"
// @Success 200 {object} models.CountResponse
// @Failure 400 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /models/count [get].
func (h *ModelsHandler) Count(c echo.Context) error {
modelType := c.QueryParam("type")
var count int64
var err error
if modelType != "" {
count, err = h.service.CountByType(c.Request().Context(), models.ModelType(modelType))
} else {
count, err = h.service.Count(c.Request().Context())
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.JSON(http.StatusOK, models.CountResponse{Count: count})
}