You've already forked agentic-coding-workflow
817 lines
21 KiB
Go
817 lines
21 KiB
Go
// handler.go exposes the chat module's HTTP endpoints.
|
|
// Routes follow the workspace/handler.go pattern: a single Handler holds all
|
|
// service dependencies, and Mount registers sub-routes with Auth middleware.
|
|
//
|
|
// Auth strategy:
|
|
// - All routes require a valid session (Auth middleware, 401 on failure).
|
|
// - Admin-only routes are wrapped by adminGuard, which checks IsAdmin and
|
|
// returns 403 for non-admin callers.
|
|
//
|
|
// Error format: httpx.WriteError (Google API style {code, message, request_id}).
|
|
package chat
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
|
httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http"
|
|
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
|
)
|
|
|
|
// AdminLookup resolves whether a user is an admin.
|
|
// Using a narrow interface (rather than importing the user package directly)
|
|
// mirrors the workspace handler pattern and makes the dependency testable.
|
|
type AdminLookup interface {
|
|
IsAdmin(ctx context.Context, id uuid.UUID) (bool, error)
|
|
}
|
|
|
|
// Handler holds all chat service dependencies and implements chi.Router mounting.
|
|
type Handler struct {
|
|
convSvc ConversationService
|
|
msgSvc MessageService
|
|
tplSvc TemplateService
|
|
epSvc EndpointService
|
|
usageSvc UsageService
|
|
upload *Uploader
|
|
resolver middleware.SessionResolver
|
|
users AdminLookup
|
|
}
|
|
|
|
// NewHandler constructs a Handler.
|
|
func NewHandler(
|
|
conv ConversationService,
|
|
msg MessageService,
|
|
tpl TemplateService,
|
|
ep EndpointService,
|
|
usage UsageService,
|
|
upload *Uploader,
|
|
resolver middleware.SessionResolver,
|
|
users AdminLookup,
|
|
) *Handler {
|
|
return &Handler{
|
|
convSvc: conv,
|
|
msgSvc: msg,
|
|
tplSvc: tpl,
|
|
epSvc: ep,
|
|
usageSvc: usage,
|
|
upload: upload,
|
|
resolver: resolver,
|
|
users: users,
|
|
}
|
|
}
|
|
|
|
// Mount registers all chat routes under /api/v1 on r.
|
|
func (h *Handler) Mount(r chi.Router) {
|
|
r.Route("/api/v1", func(r chi.Router) {
|
|
r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{}))
|
|
|
|
// User-scoped conversation routes.
|
|
r.Get("/conversations", h.listConversations)
|
|
r.Post("/conversations", h.createConversation)
|
|
r.Get("/conversations/{id}", h.getConversation)
|
|
r.Patch("/conversations/{id}", h.updateConversationTitle)
|
|
r.Delete("/conversations/{id}", h.deleteConversation)
|
|
r.Post("/conversations/{id}/messages", h.sendMessage)
|
|
r.Get("/conversations/{id}/stream", h.streamMessages) // T18
|
|
r.Get("/conversations/{id}/messages", h.listMessages)
|
|
|
|
// User-scoped message routes.
|
|
r.Post("/messages/{id}/cancel", h.cancelMessage)
|
|
r.Post("/messages/{id}/retry", h.retryMessage)
|
|
|
|
// Upload endpoint (T18).
|
|
r.Post("/uploads", h.upload.Handle)
|
|
|
|
// User-scoped template routes.
|
|
r.Get("/prompt-templates", h.listTemplates)
|
|
r.Post("/prompt-templates", h.createTemplate)
|
|
r.Patch("/prompt-templates/{id}", h.updateTemplate)
|
|
r.Delete("/prompt-templates/{id}", h.deleteTemplate)
|
|
|
|
// User usage.
|
|
r.Get("/users/me/usage", h.userDailyUsage)
|
|
|
|
// Public (non-admin) models list for chat composers.
|
|
r.Get("/models", h.listModelsPublic)
|
|
|
|
// Admin-only routes.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(h.adminGuard)
|
|
r.Get("/admin/llm-endpoints", h.listEndpoints)
|
|
r.Post("/admin/llm-endpoints", h.createEndpoint)
|
|
r.Patch("/admin/llm-endpoints/{id}", h.updateEndpoint)
|
|
r.Delete("/admin/llm-endpoints/{id}", h.deleteEndpoint)
|
|
r.Post("/admin/llm-endpoints/{id}/test", h.testEndpoint)
|
|
r.Get("/admin/llm-models", h.listModels)
|
|
r.Post("/admin/llm-models", h.createModel)
|
|
r.Patch("/admin/llm-models/{id}", h.updateModel)
|
|
r.Delete("/admin/llm-models/{id}", h.deleteModel)
|
|
r.Get("/admin/usage", h.adminUsage)
|
|
})
|
|
})
|
|
}
|
|
|
|
// adminGuard is middleware that enforces admin access for the wrapped routes.
|
|
func (h *Handler) adminGuard(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
uid, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized"))
|
|
return
|
|
}
|
|
isAdmin, err := h.users.IsAdmin(r.Context(), uid)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if !isAdmin {
|
|
writeErr(w, r, errs.New(errs.CodeForbidden, "admin only"))
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// userID returns the authenticated user's ID from the request context.
|
|
// Returns CodeUnauthorized if the Auth middleware did not run or yielded no ID.
|
|
func (h *Handler) userID(r *http.Request) (uuid.UUID, error) {
|
|
uid, ok := middleware.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
return uuid.Nil, errs.New(errs.CodeUnauthorized, "unauthorized")
|
|
}
|
|
return uid, nil
|
|
}
|
|
|
|
// ===== Transport helpers =====
|
|
|
|
func writeErr(w http.ResponseWriter, r *http.Request, err error) {
|
|
httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
httpx.WriteJSON(w, status, body)
|
|
}
|
|
|
|
func parseUUID(s string) (uuid.UUID, error) {
|
|
id, err := uuid.Parse(s)
|
|
if err != nil {
|
|
return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid uuid")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// ===== Conversation handlers =====
|
|
|
|
func (h *Handler) listConversations(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
f := ConversationFilter{
|
|
TitleQuery: q.Get("q"),
|
|
}
|
|
if v := q.Get("limit"); v != "" {
|
|
if n, e := strconv.Atoi(v); e == nil {
|
|
f.Limit = n
|
|
}
|
|
}
|
|
if v := q.Get("project_id"); v != "" {
|
|
if id, e := parseUUID(v); e == nil {
|
|
f.ProjectID = &id
|
|
}
|
|
}
|
|
if v := q.Get("workspace_id"); v != "" {
|
|
if id, e := parseUUID(v); e == nil {
|
|
f.WorkspaceID = &id
|
|
}
|
|
}
|
|
if v := q.Get("issue_id"); v != "" {
|
|
if id, e := parseUUID(v); e == nil {
|
|
f.IssueID = &id
|
|
}
|
|
}
|
|
convs, err := h.convSvc.List(r.Context(), uid, f)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]ConversationDTO, 0, len(convs))
|
|
for i := range convs {
|
|
out = append(out, toConversationDTO(&convs[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) createConversation(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req CreateConversationReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
conv, err := h.convSvc.Create(r.Context(), uid, CreateConversationInput{
|
|
ModelID: req.ModelID,
|
|
TemplateID: req.TemplateID,
|
|
SystemPrompt: req.SystemPrompt,
|
|
ProjectID: req.ProjectID,
|
|
WorkspaceID: req.WorkspaceID,
|
|
IssueID: req.IssueID,
|
|
})
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toConversationDTO(conv))
|
|
}
|
|
|
|
func (h *Handler) getConversation(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
limit := 50
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, e := strconv.Atoi(v); e == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
conv, msgs, err := h.convSvc.Get(r.Context(), uid, id, limit)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
msgDTOs := make([]MessageDTO, 0, len(msgs))
|
|
for i := range msgs {
|
|
msgDTOs = append(msgDTOs, toMessageDTO(&msgs[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"conversation": toConversationDTO(conv),
|
|
"messages": msgDTOs,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) updateConversationTitle(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req UpdateConversationTitleReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
if err := h.convSvc.UpdateTitle(r.Context(), uid, id, req.Title); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) deleteConversation(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if err := h.convSvc.SoftDelete(r.Context(), uid, id); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ===== Message handlers =====
|
|
|
|
func (h *Handler) sendMessage(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
convID, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req SendMessageReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
userMsgID, assistantMsgID, err := h.msgSvc.Send(r.Context(), uid, convID, req.Content, req.AttachmentIDs)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, SendMessageResp{
|
|
UserMessageID: userMsgID,
|
|
AssistantMessageID: assistantMsgID,
|
|
StreamURL: fmt.Sprintf("/api/v1/conversations/%s/stream", convID),
|
|
})
|
|
}
|
|
|
|
func (h *Handler) listMessages(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
convID, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
var beforeID *int64
|
|
if v := q.Get("before_id"); v != "" {
|
|
n, e := strconv.ParseInt(v, 10, 64)
|
|
if e == nil {
|
|
beforeID = &n
|
|
}
|
|
}
|
|
limit := 50
|
|
if v := q.Get("limit"); v != "" {
|
|
if n, e := strconv.Atoi(v); e == nil && n > 0 {
|
|
limit = n
|
|
}
|
|
}
|
|
msgs, err := h.msgSvc.ListMessages(r.Context(), uid, convID, beforeID, limit)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]MessageDTO, 0, len(msgs))
|
|
for i := range msgs {
|
|
out = append(out, toMessageDTO(&msgs[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) cancelMessage(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
msgID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id"))
|
|
return
|
|
}
|
|
if err := h.msgSvc.Cancel(r.Context(), uid, msgID); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) retryMessage(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
msgID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "invalid message id"))
|
|
return
|
|
}
|
|
newID, convID, err := h.msgSvc.Retry(r.Context(), uid, msgID)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"assistant_message_id": newID,
|
|
"stream_url": fmt.Sprintf("/api/v1/conversations/%s/stream", convID),
|
|
})
|
|
}
|
|
|
|
// ===== Template handlers =====
|
|
|
|
func (h *Handler) listTemplates(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
tpls, err := h.tplSvc.List(r.Context(), uid)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]TemplateDTO, 0, len(tpls))
|
|
for i := range tpls {
|
|
out = append(out, toTemplateDTO(&tpls[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) createTemplate(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req CreateTemplateReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
scope := TemplateScope(req.Scope)
|
|
// Creating a system-scoped template requires admin privileges.
|
|
if scope == TemplateScopeSystem {
|
|
isAdmin, e := h.users.IsAdmin(r.Context(), uid)
|
|
if e != nil {
|
|
writeErr(w, r, e)
|
|
return
|
|
}
|
|
if !isAdmin {
|
|
writeErr(w, r, errs.New(errs.CodeForbidden, "system scope requires admin"))
|
|
return
|
|
}
|
|
}
|
|
tpl, err := h.tplSvc.Create(r.Context(), uid, req.Name, req.Content, scope)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toTemplateDTO(tpl))
|
|
}
|
|
|
|
func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req UpdateTemplateReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
if err := h.tplSvc.Update(r.Context(), uid, id, req.Name, req.Content); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) deleteTemplate(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if err := h.tplSvc.Delete(r.Context(), uid, id); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ===== Usage handlers =====
|
|
|
|
func (h *Handler) userDailyUsage(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
to := time.Now().UTC()
|
|
from := to.AddDate(0, 0, -30)
|
|
if v := q.Get("from"); v != "" {
|
|
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
|
from = t
|
|
}
|
|
}
|
|
if v := q.Get("to"); v != "" {
|
|
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
|
to = t
|
|
}
|
|
}
|
|
rows, err := h.usageSvc.UserDaily(r.Context(), uid, from, to)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]DayUsageDTO, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, DayUsageDTO{
|
|
Day: row.Day,
|
|
PromptTokens: row.PromptTokens,
|
|
CompletionTokens: row.CompletionTokens,
|
|
ThinkingTokens: row.ThinkingTokens,
|
|
CostUSD: row.CostUSD,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// ===== Admin: Endpoint handlers =====
|
|
|
|
func (h *Handler) listEndpoints(w http.ResponseWriter, r *http.Request) {
|
|
eps, err := h.epSvc.ListEndpoints(r.Context())
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]EndpointDTO, 0, len(eps))
|
|
for i := range eps {
|
|
out = append(out, toEndpointDTO(&eps[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) createEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
uid, err := h.userID(r)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req CreateEndpointReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
ep, err := h.epSvc.CreateEndpoint(r.Context(), uid, CreateEndpointInput{
|
|
Provider: LLMProvider(req.Provider),
|
|
DisplayName: req.DisplayName,
|
|
BaseURL: req.BaseURL,
|
|
APIKey: req.APIKey,
|
|
})
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toEndpointDTO(ep))
|
|
}
|
|
|
|
func (h *Handler) updateEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req UpdateEndpointReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
if err := h.epSvc.UpdateEndpoint(r.Context(), id, UpdateEndpointInput{
|
|
DisplayName: req.DisplayName,
|
|
BaseURL: req.BaseURL,
|
|
APIKey: req.APIKey,
|
|
}); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) deleteEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if err := h.epSvc.DeleteEndpoint(r.Context(), id); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) testEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if err := h.epSvc.TestEndpoint(r.Context(), id); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ===== Admin: Model handlers =====
|
|
|
|
func (h *Handler) listModels(w http.ResponseWriter, r *http.Request) {
|
|
onlyEnabled := r.URL.Query().Get("only_enabled") == "true"
|
|
models, err := h.epSvc.ListModels(r.Context(), onlyEnabled)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]ModelDTO, 0, len(models))
|
|
for i := range models {
|
|
out = append(out, toModelDTO(&models[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// listModelsPublic mirrors listModels but is reachable by any authenticated
|
|
// user (non-admin), so chat composers can list available models.
|
|
func (h *Handler) listModelsPublic(w http.ResponseWriter, r *http.Request) {
|
|
onlyEnabled := r.URL.Query().Get("only_enabled") == "true"
|
|
models, err := h.epSvc.ListModels(r.Context(), onlyEnabled)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
out := make([]ModelDTO, 0, len(models))
|
|
for i := range models {
|
|
out = append(out, toModelDTO(&models[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *Handler) createModel(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateModelReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
m, err := h.epSvc.CreateModel(r.Context(), CreateModelInput{
|
|
EndpointID: req.EndpointID,
|
|
ModelID: req.ModelID,
|
|
DisplayName: req.DisplayName,
|
|
Capabilities: req.Capabilities,
|
|
ContextWindow: req.ContextWindow,
|
|
MaxOutputTokens: req.MaxOutputTokens,
|
|
PromptPrice: req.PromptPricePerMillionUSD,
|
|
CompletionPrice: req.CompletionPricePerMillionUSD,
|
|
ThinkingPrice: req.ThinkingPricePerMillionUSD,
|
|
IsTitleGenerator: req.IsTitleGenerator,
|
|
Enabled: req.Enabled,
|
|
SortOrder: req.SortOrder,
|
|
})
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, toModelDTO(m))
|
|
}
|
|
|
|
func (h *Handler) updateModel(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
var req UpdateModelReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body"))
|
|
return
|
|
}
|
|
if err := h.epSvc.UpdateModel(r.Context(), id, UpdateModelInput{
|
|
DisplayName: req.DisplayName,
|
|
Capabilities: req.Capabilities,
|
|
ContextWindow: req.ContextWindow,
|
|
MaxOutputTokens: req.MaxOutputTokens,
|
|
PromptPrice: req.PromptPricePerMillionUSD,
|
|
CompletionPrice: req.CompletionPricePerMillionUSD,
|
|
ThinkingPrice: req.ThinkingPricePerMillionUSD,
|
|
IsTitleGenerator: req.IsTitleGenerator,
|
|
Enabled: req.Enabled,
|
|
SortOrder: req.SortOrder,
|
|
}); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *Handler) deleteModel(w http.ResponseWriter, r *http.Request) {
|
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
if err := h.epSvc.DeleteModel(r.Context(), id); err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// ===== Admin: Usage handler =====
|
|
|
|
func (h *Handler) adminUsage(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
to := time.Now().UTC()
|
|
from := to.AddDate(0, 0, -30)
|
|
if v := q.Get("from"); v != "" {
|
|
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
|
from = t
|
|
}
|
|
}
|
|
if v := q.Get("to"); v != "" {
|
|
if t, e := time.Parse(time.RFC3339, v); e == nil {
|
|
to = t
|
|
}
|
|
}
|
|
|
|
groupBy := q.Get("group_by")
|
|
if groupBy == "" {
|
|
groupBy = "day"
|
|
}
|
|
|
|
items := make([]AdminUsageItem, 0)
|
|
switch groupBy {
|
|
case "user":
|
|
rows, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
items = append(items, AdminUsageItem{
|
|
Key: row.UserID.String(),
|
|
PromptTokens: row.PromptTokens,
|
|
CompletionTokens: row.CompletionTokens,
|
|
ThinkingTokens: row.ThinkingTokens,
|
|
CostUSD: row.CostUSD,
|
|
})
|
|
}
|
|
case "model":
|
|
rows, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
items = append(items, AdminUsageItem{
|
|
Key: row.ModelID.String(),
|
|
PromptTokens: row.PromptTokens,
|
|
CompletionTokens: row.CompletionTokens,
|
|
ThinkingTokens: row.ThinkingTokens,
|
|
CostUSD: row.CostUSD,
|
|
})
|
|
}
|
|
case "day":
|
|
rows, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
|
|
if err != nil {
|
|
writeErr(w, r, err)
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
items = append(items, AdminUsageItem{
|
|
Key: row.Day.Format(time.RFC3339),
|
|
PromptTokens: row.PromptTokens,
|
|
CompletionTokens: row.CompletionTokens,
|
|
ThinkingTokens: row.ThinkingTokens,
|
|
CostUSD: row.CostUSD,
|
|
})
|
|
}
|
|
default:
|
|
writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group_by"))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, AdminUsageResp{Items: items})
|
|
}
|