146 lines
5.5 KiB
Go
146 lines
5.5 KiB
Go
package server
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"errors"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||
|
|
)
|
||
|
|
|
||
|
|
// apiKeyBody is the PUT /settings/ai/api-key body. An empty api_key removes
|
||
|
|
// the stored key.
|
||
|
|
type apiKeyBody struct {
|
||
|
|
APIKey *string `json:"api_key"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// modelsResponse is the GET /settings/ai/models body.
|
||
|
|
type modelsResponse struct {
|
||
|
|
Models []aisettings.Model `json:"models"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// No response from these handlers holds the API key: the settings carry
|
||
|
|
// only its last four characters, and provider errors and model lists are
|
||
|
|
// redacted by package aisettings.
|
||
|
|
|
||
|
|
func getAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
settings, err := svc.Get(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
logger.ErrorContext(r.Context(), "get AI settings", "error", err)
|
||
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, r, logger, http.StatusOK, settings)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func patchAISettings(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
patch, err := decodePatch[aisettings.Patch](http.MaxBytesReader(w, r.Body, maxAISettingsBody))
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeAISettings(w, r, logger, "update AI settings")(svc.Update(r.Context(), patch))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// putAIKey takes the API key in api_key; the response, like GET's, holds
|
||
|
|
// only its last four characters.
|
||
|
|
func putAIKey(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
body, err := decodePatch[apiKeyBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||
|
|
if err == nil && body.APIKey == nil {
|
||
|
|
err = errors.New("api_key: is required")
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeAISettings(w, r, logger, "store AI API key")(svc.SetAPIKey(r.Context(), *body.APIKey))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func postAIResetPrompt(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
writeAISettings(w, r, logger, "reset AI system prompt")(svc.ResetPrompt(r.Context()))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// postAIVerify checks the stored key with the provider and returns the
|
||
|
|
// settings with key_verified_at, or the failure (see writeAIFailure).
|
||
|
|
func postAIVerify(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
settings, err := svc.Verify(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
writeAIFailure(w, r, logger, "verify AI key", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, r, logger, http.StatusOK, settings)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// getAIModels returns the provider's live model list, or the failure (see
|
||
|
|
// writeAIFailure).
|
||
|
|
func getAIModels(logger *slog.Logger, svc *aisettings.Service) http.HandlerFunc {
|
||
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
models, err := svc.Models(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
writeAIFailure(w, r, logger, "list AI models", err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, r, logger, http.StatusOK, modelsResponse{Models: models})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// writeAISettings returns a func writing a Service result: the settings,
|
||
|
|
// 400 invalid_settings for a *aisettings.ValidationError, or 500.
|
||
|
|
func writeAISettings(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string) func(aisettings.Settings, error) {
|
||
|
|
return func(settings aisettings.Settings, err error) {
|
||
|
|
var invalid *aisettings.ValidationError
|
||
|
|
switch {
|
||
|
|
case errors.As(err, &invalid):
|
||
|
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
|
||
|
|
case err != nil:
|
||
|
|
logger.ErrorContext(r.Context(), action, "error", err)
|
||
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||
|
|
default:
|
||
|
|
writeJSON(w, r, logger, http.StatusOK, settings)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// writeAIFailure writes a Verify or Models error: 409 not_configured when
|
||
|
|
// the settings cannot build a provider, 409 settings_changed when they
|
||
|
|
// changed during verification, and for a failed provider call a 502 (504
|
||
|
|
// for a timeout) whose code names the llm typed error: auth_failed,
|
||
|
|
// rate_limited, timeout, bad_output or provider_error. Rate limits and
|
||
|
|
// timeouts are retryable. Anything else is 500.
|
||
|
|
func writeAIFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, aisettings.ErrNotConfigured):
|
||
|
|
writeError(w, r, logger, http.StatusConflict, "not_configured", err.Error())
|
||
|
|
case errors.Is(err, aisettings.ErrChanged):
|
||
|
|
writeError(w, r, logger, http.StatusConflict, "settings_changed", err.Error())
|
||
|
|
case errors.Is(err, aisettings.ErrProviderFailed):
|
||
|
|
logger.WarnContext(r.Context(), action+" failed", "error", err)
|
||
|
|
status, e := http.StatusBadGateway, apiError{Code: "provider_error", Message: err.Error()}
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, llm.ErrAuth):
|
||
|
|
e.Code = "auth_failed"
|
||
|
|
case errors.Is(err, llm.ErrRateLimited):
|
||
|
|
e.Code, e.Retryable = "rate_limited", true
|
||
|
|
case errors.Is(err, llm.ErrTimeout):
|
||
|
|
status, e.Code, e.Retryable = http.StatusGatewayTimeout, "timeout", true
|
||
|
|
case errors.Is(err, llm.ErrBadOutput):
|
||
|
|
e.Code = "bad_output"
|
||
|
|
}
|
||
|
|
writeAPIError(w, r, logger, status, e)
|
||
|
|
default:
|
||
|
|
logger.ErrorContext(r.Context(), action, "error", err)
|
||
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||
|
|
}
|
||
|
|
}
|