You've already forked agentic-coding-workflow
feat(chat): http handler + DTO + admin guard for endpoints/models/usage
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ===== Request DTOs =====
|
||||
|
||||
// CreateConversationReq is the HTTP request body for POST /conversations.
|
||||
type CreateConversationReq struct {
|
||||
ModelID uuid.UUID `json:"model_id"`
|
||||
TemplateID *uuid.UUID `json:"template_id,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
|
||||
IssueID *uuid.UUID `json:"issue_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateConversationTitleReq is the HTTP request body for PATCH /conversations/{id}.
|
||||
type UpdateConversationTitleReq struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// SendMessageReq is the HTTP request body for POST /conversations/{id}/messages.
|
||||
type SendMessageReq struct {
|
||||
Content string `json:"content"`
|
||||
AttachmentIDs []uuid.UUID `json:"attachment_ids,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTemplateReq is the HTTP request body for POST /prompt-templates.
|
||||
type CreateTemplateReq struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"` // "user" | "system"
|
||||
}
|
||||
|
||||
// UpdateTemplateReq is the HTTP request body for PATCH /prompt-templates/{id}.
|
||||
type UpdateTemplateReq struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CreateEndpointReq is the HTTP request body for POST /admin/llm-endpoints.
|
||||
type CreateEndpointReq struct {
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// UpdateEndpointReq is the HTTP request body for PATCH /admin/llm-endpoints/{id}.
|
||||
type UpdateEndpointReq struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
BaseURL *string `json:"base_url,omitempty"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
// CreateModelReq is the HTTP request body for POST /admin/llm-models.
|
||||
type CreateModelReq struct {
|
||||
EndpointID uuid.UUID `json:"endpoint_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Capabilities ModelCapabilities `json:"capabilities"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
PromptPricePerMillionUSD float64 `json:"prompt_price_per_million_usd"`
|
||||
CompletionPricePerMillionUSD float64 `json:"completion_price_per_million_usd"`
|
||||
ThinkingPricePerMillionUSD float64 `json:"thinking_price_per_million_usd"`
|
||||
IsTitleGenerator bool `json:"is_title_generator"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// UpdateModelReq is the HTTP request body for PATCH /admin/llm-models/{id}.
|
||||
type UpdateModelReq struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Capabilities *ModelCapabilities `json:"capabilities,omitempty"`
|
||||
ContextWindow *int `json:"context_window,omitempty"`
|
||||
MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
|
||||
PromptPricePerMillionUSD *float64 `json:"prompt_price_per_million_usd,omitempty"`
|
||||
CompletionPricePerMillionUSD *float64 `json:"completion_price_per_million_usd,omitempty"`
|
||||
ThinkingPricePerMillionUSD *float64 `json:"thinking_price_per_million_usd,omitempty"`
|
||||
IsTitleGenerator *bool `json:"is_title_generator,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
}
|
||||
|
||||
// ===== Response DTOs =====
|
||||
|
||||
// ConversationDTO is the JSON representation of a Conversation.
|
||||
type ConversationDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
ModelID uuid.UUID `json:"model_id"`
|
||||
SystemPrompt string `json:"system_prompt"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
WorkspaceID *uuid.UUID `json:"workspace_id,omitempty"`
|
||||
IssueID *uuid.UUID `json:"issue_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MessageDTO is the JSON representation of a Message.
|
||||
type MessageDTO struct {
|
||||
ID int64 `json:"id"`
|
||||
ConversationID uuid.UUID `json:"conversation_id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Thinking *string `json:"thinking,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage *string `json:"error_message,omitempty"`
|
||||
PromptTokens *int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens *int `json:"completion_tokens,omitempty"`
|
||||
ThinkingTokens *int `json:"thinking_tokens,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AttachmentDTO is the JSON representation of an Attachment.
|
||||
type AttachmentDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SendMessageResp is the response body for POST /conversations/{id}/messages.
|
||||
type SendMessageResp struct {
|
||||
UserMessageID int64 `json:"user_message_id"`
|
||||
AssistantMessageID int64 `json:"assistant_message_id"`
|
||||
StreamURL string `json:"stream_url"`
|
||||
}
|
||||
|
||||
// TemplateDTO is the JSON representation of a PromptTemplate.
|
||||
type TemplateDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Scope string `json:"scope"`
|
||||
OwnerID *uuid.UUID `json:"owner_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// EndpointDTO is the JSON representation of a LLMEndpoint.
|
||||
// APIKeyEncrypted is intentionally excluded to prevent leaking secrets.
|
||||
type EndpointDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ModelDTO is the JSON representation of a LLMModel.
|
||||
type ModelDTO struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
EndpointID uuid.UUID `json:"endpoint_id"`
|
||||
ModelID string `json:"model_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Capabilities ModelCapabilities `json:"capabilities"`
|
||||
ContextWindow int `json:"context_window"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
PromptPricePerMillionUSD float64 `json:"prompt_price_per_million_usd"`
|
||||
CompletionPricePerMillionUSD float64 `json:"completion_price_per_million_usd"`
|
||||
ThinkingPricePerMillionUSD float64 `json:"thinking_price_per_million_usd"`
|
||||
IsTitleGenerator bool `json:"is_title_generator"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DayUsageDTO is the per-day usage row returned to both regular users and admins.
|
||||
type DayUsageDTO struct {
|
||||
Day time.Time `json:"day"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
ThinkingTokens int `json:"thinking_tokens"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
}
|
||||
|
||||
// UserUsageDTO is the per-user aggregate usage row returned to admins.
|
||||
type UserUsageDTO struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
ThinkingTokens int `json:"thinking_tokens"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
}
|
||||
|
||||
// ModelUsageDTO is the per-model aggregate usage row returned to admins.
|
||||
type ModelUsageDTO struct {
|
||||
ModelID uuid.UUID `json:"model_id"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
ThinkingTokens int `json:"thinking_tokens"`
|
||||
CostUSD float64 `json:"cost_usd"`
|
||||
}
|
||||
|
||||
// AdminUsageResp is the response body for GET /admin/usage.
|
||||
type AdminUsageResp struct {
|
||||
ByUser []UserUsageDTO `json:"by_user"`
|
||||
ByModel []ModelUsageDTO `json:"by_model"`
|
||||
ByDay []DayUsageDTO `json:"by_day"`
|
||||
}
|
||||
|
||||
// ===== Conversion helpers =====
|
||||
|
||||
func toConversationDTO(c *Conversation) ConversationDTO {
|
||||
return ConversationDTO{
|
||||
ID: c.ID,
|
||||
UserID: c.UserID,
|
||||
ModelID: c.ModelID,
|
||||
SystemPrompt: c.SystemPrompt,
|
||||
Title: c.Title,
|
||||
ProjectID: c.ProjectID,
|
||||
WorkspaceID: c.WorkspaceID,
|
||||
IssueID: c.IssueID,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toMessageDTO(m *Message) MessageDTO {
|
||||
return MessageDTO{
|
||||
ID: m.ID,
|
||||
ConversationID: m.ConversationID,
|
||||
Role: string(m.Role),
|
||||
Content: m.Content,
|
||||
Thinking: m.Thinking,
|
||||
Status: string(m.Status),
|
||||
ErrorMessage: m.ErrorMessage,
|
||||
PromptTokens: m.PromptTokens,
|
||||
CompletionTokens: m.CompletionTokens,
|
||||
ThinkingTokens: m.ThinkingTokens,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toTemplateDTO(t *PromptTemplate) TemplateDTO {
|
||||
return TemplateDTO{
|
||||
ID: t.ID,
|
||||
Name: t.Name,
|
||||
Content: t.Content,
|
||||
Scope: string(t.Scope),
|
||||
OwnerID: t.OwnerID,
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toEndpointDTO(ep *LLMEndpoint) EndpointDTO {
|
||||
return EndpointDTO{
|
||||
ID: ep.ID,
|
||||
Provider: string(ep.Provider),
|
||||
DisplayName: ep.DisplayName,
|
||||
BaseURL: ep.BaseURL,
|
||||
CreatedBy: ep.CreatedBy,
|
||||
CreatedAt: ep.CreatedAt,
|
||||
UpdatedAt: ep.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toModelDTO(m *LLMModel) ModelDTO {
|
||||
return ModelDTO{
|
||||
ID: m.ID,
|
||||
EndpointID: m.EndpointID,
|
||||
ModelID: m.ModelID,
|
||||
DisplayName: m.DisplayName,
|
||||
Capabilities: m.Capabilities,
|
||||
ContextWindow: m.ContextWindow,
|
||||
MaxOutputTokens: m.MaxOutputTokens,
|
||||
PromptPricePerMillionUSD: m.PromptPricePerMillionUSD,
|
||||
CompletionPricePerMillionUSD: m.CompletionPricePerMillionUSD,
|
||||
ThinkingPricePerMillionUSD: m.ThinkingPricePerMillionUSD,
|
||||
IsTitleGenerator: m.IsTitleGenerator,
|
||||
Enabled: m.Enabled,
|
||||
SortOrder: m.SortOrder,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
}
|
||||
conv, msgs, err := h.convSvc.Get(r.Context(), uid, id)
|
||||
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, err := h.msgSvc.Retry(r.Context(), uid, msgID)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int64{"assistant_message_id": newID})
|
||||
}
|
||||
|
||||
// ===== 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
byUser, err := h.usageSvc.SummarizeByUser(r.Context(), from, to)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
byModel, err := h.usageSvc.SummarizeByModel(r.Context(), from, to)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
byDay, err := h.usageSvc.SummarizeByDay(r.Context(), from, to)
|
||||
if err != nil {
|
||||
writeErr(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
userDTOs := make([]UserUsageDTO, 0, len(byUser))
|
||||
for _, row := range byUser {
|
||||
userDTOs = append(userDTOs, UserUsageDTO{
|
||||
UserID: row.UserID,
|
||||
PromptTokens: row.PromptTokens,
|
||||
CompletionTokens: row.CompletionTokens,
|
||||
ThinkingTokens: row.ThinkingTokens,
|
||||
CostUSD: row.CostUSD,
|
||||
})
|
||||
}
|
||||
modelDTOs := make([]ModelUsageDTO, 0, len(byModel))
|
||||
for _, row := range byModel {
|
||||
modelDTOs = append(modelDTOs, ModelUsageDTO{
|
||||
ModelID: row.ModelID,
|
||||
PromptTokens: row.PromptTokens,
|
||||
CompletionTokens: row.CompletionTokens,
|
||||
ThinkingTokens: row.ThinkingTokens,
|
||||
CostUSD: row.CostUSD,
|
||||
})
|
||||
}
|
||||
dayDTOs := make([]DayUsageDTO, 0, len(byDay))
|
||||
for _, row := range byDay {
|
||||
dayDTOs = append(dayDTOs, DayUsageDTO{
|
||||
Day: row.Day,
|
||||
PromptTokens: row.PromptTokens,
|
||||
CompletionTokens: row.CompletionTokens,
|
||||
ThinkingTokens: row.ThinkingTokens,
|
||||
CostUSD: row.CostUSD,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, AdminUsageResp{
|
||||
ByUser: userDTOs,
|
||||
ByModel: modelDTOs,
|
||||
ByDay: dayDTOs,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||
"github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware"
|
||||
)
|
||||
|
||||
// ===== Fake service implementations for handler tests =====
|
||||
|
||||
// fakeSessionResolver always resolves to fakeUID when a non-empty token is provided.
|
||||
type fakeSessionResolver struct {
|
||||
uid uuid.UUID
|
||||
}
|
||||
|
||||
func (f *fakeSessionResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) {
|
||||
if token == "" {
|
||||
return uuid.Nil, false, nil
|
||||
}
|
||||
return f.uid, true, nil
|
||||
}
|
||||
|
||||
// fakeAdminLookup allows controlling IsAdmin per-user.
|
||||
type fakeAdminLookup struct {
|
||||
adminIDs map[uuid.UUID]bool
|
||||
}
|
||||
|
||||
func newFakeAdminLookup(admins ...uuid.UUID) *fakeAdminLookup {
|
||||
m := make(map[uuid.UUID]bool, len(admins))
|
||||
for _, id := range admins {
|
||||
m[id] = true
|
||||
}
|
||||
return &fakeAdminLookup{adminIDs: m}
|
||||
}
|
||||
|
||||
func (f *fakeAdminLookup) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) {
|
||||
return f.adminIDs[id], nil
|
||||
}
|
||||
|
||||
// ===== Fake ConversationService =====
|
||||
|
||||
type fakeConvSvc struct {
|
||||
createFunc func(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error)
|
||||
listFunc func(ctx context.Context, userID uuid.UUID, f ConversationFilter) ([]Conversation, error)
|
||||
getFunc func(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error)
|
||||
}
|
||||
|
||||
func (f *fakeConvSvc) Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error) {
|
||||
return f.createFunc(ctx, userID, in)
|
||||
}
|
||||
func (f *fakeConvSvc) List(ctx context.Context, userID uuid.UUID, filter ConversationFilter) ([]Conversation, error) {
|
||||
if f.listFunc != nil {
|
||||
return f.listFunc(ctx, userID, filter)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeConvSvc) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) {
|
||||
if f.getFunc != nil {
|
||||
return f.getFunc(ctx, userID, id)
|
||||
}
|
||||
return nil, nil, errs.New(errs.CodeChatConversationNotFound, "not found")
|
||||
}
|
||||
func (f *fakeConvSvc) UpdateTitle(_ context.Context, _, _ uuid.UUID, _ string) error { return nil }
|
||||
func (f *fakeConvSvc) SoftDelete(_ context.Context, _, _ uuid.UUID) error { return nil }
|
||||
|
||||
// ===== Fake MessageService =====
|
||||
|
||||
type fakeMsgSvc struct {
|
||||
sendFunc func(ctx context.Context, userID, convID uuid.UUID, content string, ids []uuid.UUID) (int64, int64, error)
|
||||
retryErr error
|
||||
}
|
||||
|
||||
func (f *fakeMsgSvc) Send(ctx context.Context, userID, convID uuid.UUID, content string, ids []uuid.UUID) (int64, int64, error) {
|
||||
if f.sendFunc != nil {
|
||||
return f.sendFunc(ctx, userID, convID, content, ids)
|
||||
}
|
||||
return 1, 2, nil
|
||||
}
|
||||
func (f *fakeMsgSvc) Stream(_ context.Context, _ uuid.UUID, _ uuid.UUID, _ int64) (<-chan SSEEvent, error) {
|
||||
ch := make(chan SSEEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func (f *fakeMsgSvc) Cancel(_ context.Context, _ uuid.UUID, _ int64) error { return nil }
|
||||
func (f *fakeMsgSvc) Retry(_ context.Context, _ uuid.UUID, msgID int64) (int64, error) {
|
||||
if f.retryErr != nil {
|
||||
return 0, f.retryErr
|
||||
}
|
||||
return msgID + 100, nil
|
||||
}
|
||||
func (f *fakeMsgSvc) ListMessages(_ context.Context, _, _ uuid.UUID, _ *int64, _ int) ([]Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ===== Fake TemplateService =====
|
||||
|
||||
type fakeTplSvc struct {
|
||||
createFunc func(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*PromptTemplate, error)
|
||||
}
|
||||
|
||||
func (f *fakeTplSvc) List(_ context.Context, _ uuid.UUID) ([]PromptTemplate, error) { return nil, nil }
|
||||
func (f *fakeTplSvc) Create(ctx context.Context, userID uuid.UUID, name, content string, scope TemplateScope) (*PromptTemplate, error) {
|
||||
if f.createFunc != nil {
|
||||
return f.createFunc(ctx, userID, name, content, scope)
|
||||
}
|
||||
return &PromptTemplate{ID: uuid.New(), Name: name, Content: content, Scope: scope, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
func (f *fakeTplSvc) Update(_ context.Context, _, _ uuid.UUID, _, _ string) error { return nil }
|
||||
func (f *fakeTplSvc) Delete(_ context.Context, _, _ uuid.UUID) error { return nil }
|
||||
|
||||
// ===== Fake EndpointService =====
|
||||
|
||||
type fakeEpSvc struct {
|
||||
listEndpointsErr error
|
||||
}
|
||||
|
||||
func (f *fakeEpSvc) ListEndpoints(_ context.Context) ([]LLMEndpoint, error) {
|
||||
if f.listEndpointsErr != nil {
|
||||
return nil, f.listEndpointsErr
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeEpSvc) CreateEndpoint(_ context.Context, _ uuid.UUID, in CreateEndpointInput) (*LLMEndpoint, error) {
|
||||
return &LLMEndpoint{ID: uuid.New(), Provider: in.Provider, DisplayName: in.DisplayName, BaseURL: in.BaseURL, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
func (f *fakeEpSvc) UpdateEndpoint(_ context.Context, _ uuid.UUID, _ UpdateEndpointInput) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEpSvc) DeleteEndpoint(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (f *fakeEpSvc) TestEndpoint(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (f *fakeEpSvc) ListModels(_ context.Context, _ bool) ([]LLMModel, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeEpSvc) CreateModel(_ context.Context, in CreateModelInput) (*LLMModel, error) {
|
||||
return &LLMModel{ID: uuid.New(), ModelID: in.ModelID, DisplayName: in.DisplayName, CreatedAt: time.Now(), UpdatedAt: time.Now()}, nil
|
||||
}
|
||||
func (f *fakeEpSvc) UpdateModel(_ context.Context, _ uuid.UUID, _ UpdateModelInput) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEpSvc) DeleteModel(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
|
||||
// ===== Fake UsageService =====
|
||||
|
||||
type fakeUsageSvc struct{}
|
||||
|
||||
func (f *fakeUsageSvc) Insert(_ context.Context, _ UsageRecord) error { return nil }
|
||||
func (f *fakeUsageSvc) SummarizeByUser(_ context.Context, _, _ time.Time) ([]UserUsageRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeUsageSvc) SummarizeByModel(_ context.Context, _, _ time.Time) ([]ModelUsageRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeUsageSvc) SummarizeByDay(_ context.Context, _, _ time.Time) ([]DayUsageRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeUsageSvc) UserDaily(_ context.Context, _ uuid.UUID, _, _ time.Time) ([]DayUsageRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ===== Test helper =====
|
||||
|
||||
// newTestHandler builds a Handler with the given services and mounts it on a chi router.
|
||||
// The resolver resolves any non-empty token to uid; adminIDs are treated as admins.
|
||||
func newTestHandler(
|
||||
convSvc ConversationService,
|
||||
msgSvc MessageService,
|
||||
tplSvc TemplateService,
|
||||
epSvc EndpointService,
|
||||
uid uuid.UUID,
|
||||
adminLookup AdminLookup,
|
||||
) (*Handler, chi.Router) {
|
||||
resolver := &fakeSessionResolver{uid: uid}
|
||||
upload := NewUploader(newFakeRepo(), nil, nil, 1<<20)
|
||||
h := NewHandler(convSvc, msgSvc, tplSvc, epSvc, &fakeUsageSvc{}, upload, resolver, adminLookup)
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
h.Mount(r)
|
||||
return h, r
|
||||
}
|
||||
|
||||
// authRequest creates a request with a Bearer token so Auth middleware passes.
|
||||
func authRequest(method, path string, body []byte) *http.Request {
|
||||
var r *http.Request
|
||||
if body != nil {
|
||||
r = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
r.Header.Set("Authorization", "Bearer test-token")
|
||||
return r
|
||||
}
|
||||
|
||||
// ===== Tests =====
|
||||
|
||||
// TestHandler_SendMessage_Returns200WithStreamURL verifies that sendMessage
|
||||
// calls the service with the right arguments and returns a SendMessageResp
|
||||
// with a correctly formed StreamURL.
|
||||
func TestHandler_SendMessage_Returns200WithStreamURL(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
convID := uuid.New()
|
||||
|
||||
var capturedConvID uuid.UUID
|
||||
var capturedContent string
|
||||
msgSvc := &fakeMsgSvc{
|
||||
sendFunc: func(_ context.Context, _ uuid.UUID, cID uuid.UUID, content string, _ []uuid.UUID) (int64, int64, error) {
|
||||
capturedConvID = cID
|
||||
capturedContent = content
|
||||
return 10, 20, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, r := newTestHandler(&fakeConvSvc{}, msgSvc, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup())
|
||||
|
||||
body, _ := json.Marshal(SendMessageReq{Content: "hello"})
|
||||
req := authRequest(http.MethodPost, "/api/v1/conversations/"+convID.String()+"/messages", body)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var resp SendMessageResp
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&resp))
|
||||
assert.Equal(t, int64(10), resp.UserMessageID)
|
||||
assert.Equal(t, int64(20), resp.AssistantMessageID)
|
||||
assert.Contains(t, resp.StreamURL, convID.String())
|
||||
assert.Equal(t, convID, capturedConvID)
|
||||
assert.Equal(t, "hello", capturedContent)
|
||||
}
|
||||
|
||||
// TestHandler_CreateConversation_LocksTemplateContent verifies that the handler
|
||||
// passes the TemplateID and SystemPrompt fields through to the service unchanged.
|
||||
// The service is responsible for resolving template content; the handler must
|
||||
// not override or ignore these fields.
|
||||
func TestHandler_CreateConversation_LocksTemplateContent(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
templateID := uuid.New()
|
||||
customPrompt := "my custom system prompt"
|
||||
|
||||
var capturedInput CreateConversationInput
|
||||
convSvc := &fakeConvSvc{
|
||||
createFunc: func(_ context.Context, _ uuid.UUID, in CreateConversationInput) (*Conversation, error) {
|
||||
capturedInput = in
|
||||
return &Conversation{
|
||||
ID: uuid.New(),
|
||||
UserID: uid,
|
||||
ModelID: in.ModelID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
_, r := newTestHandler(convSvc, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup())
|
||||
|
||||
modelID := uuid.New()
|
||||
body, _ := json.Marshal(CreateConversationReq{
|
||||
ModelID: modelID,
|
||||
TemplateID: &templateID,
|
||||
SystemPrompt: &customPrompt,
|
||||
})
|
||||
req := authRequest(http.MethodPost, "/api/v1/conversations", body)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
// Handler must forward both TemplateID and SystemPrompt to the service
|
||||
// (service decides precedence, not handler).
|
||||
assert.Equal(t, modelID, capturedInput.ModelID)
|
||||
require.NotNil(t, capturedInput.TemplateID)
|
||||
assert.Equal(t, templateID, *capturedInput.TemplateID)
|
||||
require.NotNil(t, capturedInput.SystemPrompt)
|
||||
assert.Equal(t, customPrompt, *capturedInput.SystemPrompt)
|
||||
}
|
||||
|
||||
// TestHandler_AdminEndpoint_403WhenNotAdmin verifies that a non-admin user
|
||||
// receives 403 when calling an admin-only endpoint.
|
||||
func TestHandler_AdminEndpoint_403WhenNotAdmin(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
// uid is NOT in the admin lookup.
|
||||
_, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup())
|
||||
|
||||
req := authRequest(http.MethodGet, "/api/v1/admin/llm-endpoints", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
|
||||
assert.Equal(t, string(errs.CodeForbidden), body["code"])
|
||||
}
|
||||
|
||||
// TestHandler_RetryMessage_404OnUnknownID verifies that when the message
|
||||
// service returns a not-found error, the handler maps it to 404.
|
||||
func TestHandler_RetryMessage_404OnUnknownID(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
msgSvc := &fakeMsgSvc{
|
||||
retryErr: errs.New(errs.CodeChatMessageNotFound, "message not found"),
|
||||
}
|
||||
|
||||
_, r := newTestHandler(&fakeConvSvc{}, msgSvc, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup())
|
||||
|
||||
req := authRequest(http.MethodPost, "/api/v1/messages/99999/retry", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
var body map[string]any
|
||||
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
|
||||
assert.Equal(t, string(errs.CodeChatMessageNotFound), body["code"])
|
||||
}
|
||||
|
||||
// TestHandler_CreateTemplate_SystemScopeRequiresAdmin verifies that a
|
||||
// non-admin user cannot create a system-scoped template and receives 403.
|
||||
func TestHandler_CreateTemplate_SystemScopeRequiresAdmin(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
// uid is not an admin.
|
||||
_, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup())
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "sys tpl",
|
||||
"content": "You are a system assistant",
|
||||
"scope": "system",
|
||||
})
|
||||
req := authRequest(http.MethodPost, "/api/v1/prompt-templates", body)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
}
|
||||
|
||||
// TestHandler_CreateTemplate_SystemScopeAllowedForAdmin verifies that an
|
||||
// admin user can create a system-scoped template.
|
||||
func TestHandler_CreateTemplate_SystemScopeAllowedForAdmin(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
// uid is an admin.
|
||||
_, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup(uid))
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "sys tpl",
|
||||
"content": "You are a system assistant",
|
||||
"scope": "system",
|
||||
})
|
||||
req := authRequest(http.MethodPost, "/api/v1/prompt-templates", body)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, w.Code)
|
||||
}
|
||||
|
||||
// TestHandler_Unauthenticated_Returns401 verifies that requests without a
|
||||
// Bearer token are rejected by the Auth middleware with 401.
|
||||
func TestHandler_Unauthenticated_Returns401(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
_, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup())
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/conversations", nil)
|
||||
// No Authorization header.
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
// TestHandler_AdminAllowed_ListEndpoints verifies that an admin can call a
|
||||
// admin-only endpoint and receives a 200.
|
||||
func TestHandler_AdminAllowed_ListEndpoints(t *testing.T) {
|
||||
uid := uuid.New()
|
||||
_, r := newTestHandler(&fakeConvSvc{}, &fakeMsgSvc{}, &fakeTplSvc{}, &fakeEpSvc{}, uid, newFakeAdminLookup(uid))
|
||||
|
||||
req := authRequest(http.MethodGet, "/api/v1/admin/llm-endpoints", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
// Response should be a JSON array (empty is fine).
|
||||
assert.True(t, strings.HasPrefix(w.Body.String(), "[") || w.Body.String() == "[]\n")
|
||||
}
|
||||
Reference in New Issue
Block a user