* feat(assistant): optimize MCP and custom widget authoring * fix(assistant): isolate server-safe JSX analyzer * refactor(assistant): generate MCP tool staging * fix(assistant): preserve widget placement follow-up * fix(ci): allow fast gate to finish * fix(custom-widgets): address review findings * fix(custom-widgets): split preview evidence module * style: format assistant custom widget files * fix(custom-widgets): sync formatted skill schema
691 lines
22 KiB
Go
691 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase"
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
)
|
|
|
|
const (
|
|
homarrProviderModelID = "homarr/model"
|
|
homarrProviderModelName = "Homarr model"
|
|
defaultOpenRouterModelID = "~deepseek/deepseek-v4-flash-latest"
|
|
maxChatInputTokens = 256 * 1024
|
|
maxChatOutputTokens = 32 * 1024
|
|
defaultDailyLimit = 50
|
|
defaultGlobalDailyLimit = 10_000
|
|
// The Homarr composer permits five 1 MB images, which expand to about 6.7 MB as
|
|
// base64. Keep the authenticated transport bounded without confusing bytes with
|
|
// model tokens; validate textual and image content separately below.
|
|
maxChatRequestBytes = 12_000_000
|
|
// One UTF-8 byte cannot expand into more than one tokenizer fallback token.
|
|
// This conservative bound keeps every accepted text payload within 256K tokens
|
|
// without coupling the public alias to the private upstream tokenizer.
|
|
maxChatTextBytes = maxChatInputTokens
|
|
maxChatImageDataBytes = 1_400_000
|
|
maxChatImages = 5
|
|
maxChatMessages = 512
|
|
maxChatResponseBytes = 8 << 20
|
|
maxWebSearchResults = 5
|
|
maxWebSearchUses = 3
|
|
maxWebSearchTotal = 10
|
|
maxWebSearchCharacters = 2500
|
|
webSearchContextSize = "low"
|
|
maxUserInFlight = 2
|
|
maxGlobalInFlight = 100
|
|
openRouterDefaultURL = "https://openrouter.ai/api/v1"
|
|
openRouterReferer = "https://homarr.dev"
|
|
openRouterTitle = "Homarr AI Assistant"
|
|
providerRequestTimeout = 5 * time.Minute
|
|
)
|
|
|
|
var (
|
|
errProviderResponseTooLarge = errors.New("provider response is too large")
|
|
errInvalidProviderResponse = errors.New("invalid provider response")
|
|
errInvalidTools = errors.New("invalid request tools")
|
|
errUnsupportedTool = errors.New("unsupported request tool")
|
|
errTooManyImages = errors.New("too many images or image too large")
|
|
errRemoteImage = errors.New("remote image not allowed")
|
|
errInputTooLarge = errors.New("request exceeds model input limit")
|
|
)
|
|
|
|
var clientControlledRoutingFields = []string{
|
|
"models", "provider", "route", "plugins", "transforms", "extra_body", "extra_headers",
|
|
}
|
|
|
|
var clientControlledCostFields = []string{
|
|
"audio", "modalities", "logprobs", "top_logprobs", "prediction", "service_tier",
|
|
}
|
|
|
|
type homarrProvider struct {
|
|
apiKey string
|
|
baseURL string
|
|
modelID string
|
|
dailyLimit int
|
|
globalDailyLimit int
|
|
httpClient *http.Client
|
|
now func() time.Time
|
|
inFlightMutex sync.Mutex
|
|
inFlightByUser map[string]int
|
|
globalInFlight int
|
|
}
|
|
|
|
type quotaSnapshot struct {
|
|
Limit int `json:"limit"`
|
|
Used int `json:"used"`
|
|
Remaining int `json:"remaining"`
|
|
ResetsAt time.Time `json:"resetsAt"`
|
|
}
|
|
|
|
type providerRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []providerMessage `json:"messages"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
type providerMessage struct {
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
type quotaExceededError struct {
|
|
Snapshot quotaSnapshot
|
|
}
|
|
|
|
func (err *quotaExceededError) Error() string { return "daily Homarr provider allowance exhausted" }
|
|
|
|
func registerHomarrProvider(app *pocketbase.PocketBase) {
|
|
provider, err := newHomarrProviderFromEnv()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
app.OnServe().BindFunc(func(event *core.ServeEvent) error {
|
|
group := event.Router.Group("/api/ai")
|
|
group.GET("/v1/models", provider.models)
|
|
group.GET("/usage", provider.usage).
|
|
Bind(apis.RequireAuth("users")).
|
|
Unbind(apis.DefaultActivityLoggerMiddlewareId)
|
|
group.POST("/v1/chat/completions", provider.chat).
|
|
Bind(apis.RequireAuth("users"), apis.BodyLimit(maxChatRequestBytes)).
|
|
Unbind(apis.DefaultActivityLoggerMiddlewareId)
|
|
return event.Next()
|
|
})
|
|
}
|
|
|
|
func newHomarrProviderFromEnv() (*homarrProvider, error) {
|
|
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("HOMARR_AI_OPENROUTER_BASE_URL")), "/")
|
|
if baseURL == "" {
|
|
baseURL = openRouterDefaultURL
|
|
}
|
|
parsedURL, err := url.Parse(baseURL)
|
|
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") ||
|
|
parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
|
|
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must be an absolute HTTP(S) URL without credentials, a query, or a fragment")
|
|
}
|
|
if parsedURL.Scheme != "https" && strings.TrimSpace(os.Getenv("HOMARR_AI_ALLOW_INSECURE_UPSTREAM")) != "true" {
|
|
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must use HTTPS unless HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true")
|
|
}
|
|
|
|
modelID := strings.TrimSpace(os.Getenv("HOMARR_AI_OPENROUTER_MODEL"))
|
|
if modelID == "" {
|
|
modelID = defaultOpenRouterModelID
|
|
}
|
|
if len(modelID) > 256 || strings.IndexFunc(modelID, func(value rune) bool {
|
|
return unicode.IsSpace(value) || unicode.IsControl(value)
|
|
}) >= 0 {
|
|
return nil, errors.New("HOMARR_AI_OPENROUTER_MODEL must be a non-empty model identifier of at most 256 characters")
|
|
}
|
|
|
|
dailyLimit := defaultDailyLimit
|
|
if rawLimit := strings.TrimSpace(os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")); rawLimit != "" {
|
|
value, parseErr := strconv.Atoi(rawLimit)
|
|
if parseErr != nil || value < 1 || value > 100_000 {
|
|
return nil, errors.New("HOMARR_AI_DAILY_REQUEST_LIMIT must be an integer between 1 and 100000")
|
|
}
|
|
dailyLimit = value
|
|
}
|
|
globalDailyLimit := defaultGlobalDailyLimit
|
|
if rawLimit := strings.TrimSpace(os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")); rawLimit != "" {
|
|
value, parseErr := strconv.Atoi(rawLimit)
|
|
if parseErr != nil || value < 1 || value > 10_000_000 {
|
|
return nil, errors.New("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT must be an integer between 1 and 10000000")
|
|
}
|
|
globalDailyLimit = value
|
|
}
|
|
|
|
return &homarrProvider{
|
|
apiKey: strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")),
|
|
baseURL: baseURL,
|
|
modelID: modelID,
|
|
dailyLimit: dailyLimit,
|
|
globalDailyLimit: globalDailyLimit,
|
|
httpClient: &http.Client{
|
|
Timeout: providerRequestTimeout,
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
now: time.Now,
|
|
inFlightByUser: make(map[string]int),
|
|
}, nil
|
|
}
|
|
|
|
func (provider *homarrProvider) models(event *core.RequestEvent) error {
|
|
if provider.apiKey == "" {
|
|
return event.JSON(http.StatusServiceUnavailable, openAIError("The Homarr provider is not configured."))
|
|
}
|
|
return event.JSON(http.StatusOK, map[string]any{
|
|
"object": "list",
|
|
"data": []map[string]any{{
|
|
"id": homarrProviderModelID,
|
|
"object": "model",
|
|
"name": homarrProviderModelName,
|
|
"description": "The tool-capable model selected by the Homarr team.",
|
|
"owned_by": "homarr",
|
|
"context_length": maxChatInputTokens,
|
|
"supported_parameters": []string{
|
|
"tools", "tool_choice", "reasoning", "include_reasoning", "structured_outputs",
|
|
},
|
|
"architecture": map[string]any{
|
|
"input_modalities": []string{"text", "image"},
|
|
"output_modalities": []string{"text"},
|
|
},
|
|
}},
|
|
})
|
|
}
|
|
|
|
func (provider *homarrProvider) usage(event *core.RequestEvent) error {
|
|
snapshot, err := provider.quota(event.App, event.Auth)
|
|
if err != nil {
|
|
return event.InternalServerError("The Homarr provider allowance could not be loaded.", nil)
|
|
}
|
|
return event.JSON(http.StatusOK, snapshot)
|
|
}
|
|
|
|
func (provider *homarrProvider) chat(event *core.RequestEvent) error {
|
|
if provider.apiKey == "" {
|
|
return event.JSON(http.StatusServiceUnavailable, openAIError("The Homarr provider is not configured."))
|
|
}
|
|
if !provider.acquireRequest(event.Auth.Id) {
|
|
return event.JSON(http.StatusTooManyRequests, openAIError("Too many Homarr provider requests are already running."))
|
|
}
|
|
defer provider.releaseRequest(event.Auth.Id)
|
|
|
|
body, err := io.ReadAll(io.LimitReader(event.Request.Body, maxChatRequestBytes+1))
|
|
if err != nil || len(body) > maxChatRequestBytes {
|
|
return event.JSON(http.StatusRequestEntityTooLarge, openAIError("The request is too large."))
|
|
}
|
|
|
|
var requestBody providerRequest
|
|
if err := json.Unmarshal(body, &requestBody); err != nil || len(requestBody.Messages) == 0 {
|
|
return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON."))
|
|
}
|
|
if len(requestBody.Messages) > maxChatMessages {
|
|
return event.JSON(http.StatusBadRequest, openAIError("The request contains too many messages."))
|
|
}
|
|
if requestBody.Model != homarrProviderModelID {
|
|
return event.JSON(http.StatusBadRequest, openAIError("The Homarr provider offers only the Homarr model."))
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON."))
|
|
}
|
|
if err := validateProviderInput(payload); err != nil {
|
|
message := "The request exceeds the Homarr model input limit."
|
|
if errors.Is(err, errTooManyImages) {
|
|
message = "The request contains too many images or an image that is too large."
|
|
} else if errors.Is(err, errRemoteImage) {
|
|
message = "Remote image URLs are not supported. Upload the image to Homarr instead."
|
|
}
|
|
return event.JSON(http.StatusRequestEntityTooLarge, openAIError(message))
|
|
}
|
|
if err := sanitizeProviderPayload(payload, provider.modelID); err != nil {
|
|
message := "The request contains an unsupported tool."
|
|
if errors.Is(err, errInvalidTools) {
|
|
message = "The request tools are not valid."
|
|
}
|
|
return event.JSON(http.StatusBadRequest, openAIError(message))
|
|
}
|
|
upstreamBody, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared."))
|
|
}
|
|
|
|
upstreamRequest, err := http.NewRequestWithContext(
|
|
event.Request.Context(),
|
|
http.MethodPost,
|
|
provider.baseURL+"/chat/completions",
|
|
bytes.NewReader(upstreamBody),
|
|
)
|
|
if err != nil {
|
|
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared."))
|
|
}
|
|
snapshot, err := provider.startRequest(event.App, event.Auth)
|
|
if err != nil {
|
|
var quotaErr *quotaExceededError
|
|
if errors.As(err, "aErr) {
|
|
event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(quotaErr.Snapshot.Limit))
|
|
event.Response.Header().Set("X-Homarr-Quota-Remaining", "0")
|
|
event.Response.Header().Set("X-Homarr-Quota-Reset", quotaErr.Snapshot.ResetsAt.Format(time.RFC3339))
|
|
return event.JSON(http.StatusTooManyRequests, openAIError("Your daily Homarr provider allowance is exhausted."))
|
|
}
|
|
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be started."))
|
|
}
|
|
upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey)
|
|
upstreamRequest.Header.Set("Content-Type", "application/json")
|
|
upstreamRequest.Header.Set("Accept", "text/event-stream, application/json")
|
|
upstreamRequest.Header.Set("HTTP-Referer", openRouterReferer)
|
|
upstreamRequest.Header.Set("X-OpenRouter-Title", openRouterTitle)
|
|
upstreamRequest.Header.Set("X-OpenRouter-Metadata", "enabled")
|
|
|
|
upstreamResponse, err := provider.httpClient.Do(upstreamRequest)
|
|
if err != nil {
|
|
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached."))
|
|
}
|
|
defer func() { _ = upstreamResponse.Body.Close() }()
|
|
|
|
event.Response.Header().Set("Cache-Control", "no-store")
|
|
event.Response.Header().Set("X-Accel-Buffering", "no")
|
|
event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(snapshot.Limit))
|
|
event.Response.Header().Set("X-Homarr-Quota-Remaining", strconv.Itoa(snapshot.Remaining))
|
|
event.Response.Header().Set("X-Homarr-Quota-Reset", snapshot.ResetsAt.Format(time.RFC3339))
|
|
if requestID := upstreamResponse.Header.Get("X-Request-Id"); requestID != "" {
|
|
event.Response.Header().Set("X-Request-Id", requestID)
|
|
}
|
|
|
|
contentType := upstreamResponse.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/json"
|
|
}
|
|
if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 {
|
|
return event.JSON(safeUpstreamStatus(upstreamResponse.StatusCode), openAIError("The model endpoint rejected the request."))
|
|
}
|
|
|
|
if requestBody.Stream {
|
|
event.Response.Header().Set("Content-Type", contentType)
|
|
event.Response.WriteHeader(upstreamResponse.StatusCode)
|
|
if _, streamErr := copyAliasedSSE(flushingWriter{event.Response}, upstreamResponse.Body, maxChatResponseBytes); streamErr != nil {
|
|
_, _ = io.WriteString(flushingWriter{event.Response}, streamErrorFrame())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
responseBody, readErr := readBoundedBody(upstreamResponse.Body, maxChatResponseBytes)
|
|
if readErr != nil {
|
|
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
|
|
}
|
|
responseBody, readErr = aliasCompletionBody(responseBody)
|
|
if readErr != nil {
|
|
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
|
|
}
|
|
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
|
|
}
|
|
|
|
func streamErrorFrame() string {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"error": map[string]any{
|
|
"message": "The model endpoint returned an invalid response.",
|
|
"type": "homarr_provider_error",
|
|
},
|
|
})
|
|
return "data: " + string(payload) + "\n\ndata: [DONE]\n\n"
|
|
}
|
|
|
|
func (provider *homarrProvider) acquireRequest(userID string) bool {
|
|
provider.inFlightMutex.Lock()
|
|
defer provider.inFlightMutex.Unlock()
|
|
if provider.globalInFlight >= maxGlobalInFlight || provider.inFlightByUser[userID] >= maxUserInFlight {
|
|
return false
|
|
}
|
|
provider.globalInFlight++
|
|
provider.inFlightByUser[userID]++
|
|
return true
|
|
}
|
|
|
|
func (provider *homarrProvider) releaseRequest(userID string) {
|
|
provider.inFlightMutex.Lock()
|
|
defer provider.inFlightMutex.Unlock()
|
|
provider.globalInFlight--
|
|
provider.inFlightByUser[userID]--
|
|
if provider.inFlightByUser[userID] == 0 {
|
|
delete(provider.inFlightByUser, userID)
|
|
}
|
|
}
|
|
|
|
func safeUpstreamStatus(status int) int {
|
|
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
|
return http.StatusBadGateway
|
|
}
|
|
return status
|
|
}
|
|
|
|
func sanitizeProviderPayload(payload map[string]any, upstreamModelID string) error {
|
|
for _, field := range clientControlledRoutingFields {
|
|
delete(payload, field)
|
|
}
|
|
for _, field := range clientControlledCostFields {
|
|
delete(payload, field)
|
|
}
|
|
delete(payload, "max_completion_tokens")
|
|
delete(payload, "max_tokens")
|
|
delete(payload, "metadata")
|
|
delete(payload, "user")
|
|
if reasoning, ok := payload["reasoning"].(map[string]any); ok {
|
|
delete(reasoning, "max_tokens")
|
|
}
|
|
|
|
if rawTools, exists := payload["tools"]; exists {
|
|
tools, ok := rawTools.([]any)
|
|
if !ok {
|
|
return errInvalidTools
|
|
}
|
|
sanitizedTools := make([]any, 0, len(tools))
|
|
webSearchAdded := false
|
|
for _, rawTool := range tools {
|
|
tool, ok := rawTool.(map[string]any)
|
|
if !ok {
|
|
return errUnsupportedTool
|
|
}
|
|
switch tool["type"] {
|
|
case "function":
|
|
sanitizedTools = append(sanitizedTools, tool)
|
|
case "openrouter:web_search":
|
|
if !webSearchAdded {
|
|
sanitizedTools = append(sanitizedTools, map[string]any{
|
|
"type": "openrouter:web_search",
|
|
"parameters": map[string]any{
|
|
"max_results": maxWebSearchResults,
|
|
"max_uses": maxWebSearchUses,
|
|
"max_total_results": maxWebSearchTotal,
|
|
"max_characters": maxWebSearchCharacters,
|
|
"search_context_size": webSearchContextSize,
|
|
},
|
|
})
|
|
webSearchAdded = true
|
|
}
|
|
default:
|
|
return errUnsupportedTool
|
|
}
|
|
}
|
|
payload["tools"] = sanitizedTools
|
|
}
|
|
|
|
payload["model"] = upstreamModelID
|
|
payload["max_completion_tokens"] = maxChatOutputTokens
|
|
payload["n"] = 1
|
|
payload["parallel_tool_calls"] = false
|
|
payload["usage"] = map[string]any{"include": true}
|
|
payload["provider"] = map[string]any{"zdr": true, "data_collection": "deny"}
|
|
return nil
|
|
}
|
|
|
|
func validateProviderInput(payload map[string]any) error {
|
|
textBytes := 0
|
|
imageCount := 0
|
|
var walk func(any) error
|
|
walk = func(value any) error {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
if strings.HasPrefix(typed, "data:image/") {
|
|
imageCount++
|
|
if imageCount > maxChatImages || len(typed) > maxChatImageDataBytes {
|
|
return errTooManyImages
|
|
}
|
|
return nil
|
|
}
|
|
textBytes += len(typed)
|
|
if textBytes > maxChatTextBytes {
|
|
return errInputTooLarge
|
|
}
|
|
case []any:
|
|
for _, item := range typed {
|
|
if err := walk(item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case map[string]any:
|
|
if typed["type"] == "image_url" {
|
|
image, ok := typed["image_url"].(map[string]any)
|
|
imageURL, urlOK := image["url"].(string)
|
|
if !ok || !urlOK || !strings.HasPrefix(imageURL, "data:image/") {
|
|
return errRemoteImage
|
|
}
|
|
imageCount++
|
|
if imageCount > maxChatImages || len(imageURL) > maxChatImageDataBytes {
|
|
return errTooManyImages
|
|
}
|
|
}
|
|
for key, item := range typed {
|
|
if typed["type"] == "image_url" && key == "image_url" {
|
|
continue
|
|
}
|
|
textBytes += len(key)
|
|
if textBytes > maxChatTextBytes {
|
|
return errInputTooLarge
|
|
}
|
|
if err := walk(item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return walk(payload)
|
|
}
|
|
|
|
func aliasCompletionBody(body []byte) ([]byte, error) {
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, errInvalidProviderResponse
|
|
}
|
|
payload["model"] = homarrProviderModelID
|
|
aliased, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, errInvalidProviderResponse
|
|
}
|
|
return aliased, nil
|
|
}
|
|
|
|
func copyAliasedSSE(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
|
|
buffered := bufio.NewReader(io.LimitReader(reader, limit+1))
|
|
var written int64
|
|
var read int64
|
|
for {
|
|
line, err := buffered.ReadString('\n')
|
|
read += int64(len(line))
|
|
if read > limit {
|
|
return written, errProviderResponseTooLarge
|
|
}
|
|
if strings.HasPrefix(line, "data: ") && strings.TrimSpace(strings.TrimPrefix(line, "data: ")) != "[DONE]" {
|
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
|
|
aliased, aliasErr := aliasCompletionBody([]byte(payload))
|
|
if aliasErr != nil {
|
|
return written, aliasErr
|
|
}
|
|
line = "data: " + string(aliased) + "\n"
|
|
}
|
|
if written+int64(len(line)) > limit {
|
|
return written, errProviderResponseTooLarge
|
|
}
|
|
count, writeErr := io.WriteString(writer, line)
|
|
written += int64(count)
|
|
if writeErr != nil {
|
|
return written, writeErr
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
return written, nil
|
|
}
|
|
if err != nil {
|
|
return written, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func copyBounded(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
|
|
written, err := io.Copy(writer, io.LimitReader(reader, limit))
|
|
if err != nil {
|
|
return written, err
|
|
}
|
|
var extra [1]byte
|
|
read, err := reader.Read(extra[:])
|
|
if read > 0 {
|
|
return written, errProviderResponseTooLarge
|
|
}
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return written, err
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
func readBoundedBody(reader io.Reader, limit int64) ([]byte, error) {
|
|
body, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(body)) > limit {
|
|
return nil, errProviderResponseTooLarge
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func (provider *homarrProvider) quota(app core.App, user *core.Record) (quotaSnapshot, error) {
|
|
now := provider.now().UTC()
|
|
var snapshot quotaSnapshot
|
|
err := app.RunInTransaction(func(txApp core.App) error {
|
|
quota, current, quotaChanged, err := provider.loadQuota(txApp, user, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
globalQuota, globalRemaining, globalQuotaChanged, err := provider.loadGlobalQuota(txApp, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
current.Remaining = min(current.Remaining, globalRemaining)
|
|
snapshot = current
|
|
if globalQuotaChanged {
|
|
if err := txApp.Save(globalQuota); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if quotaChanged {
|
|
return txApp.Save(quota)
|
|
}
|
|
return nil
|
|
})
|
|
return snapshot, err
|
|
}
|
|
|
|
func (provider *homarrProvider) loadGlobalQuota(app core.App, now time.Time) (*core.Record, int, bool, error) {
|
|
quota, err := app.FindFirstRecordByFilter("assistant_global_quota", "key = 'default'")
|
|
if err != nil {
|
|
return nil, 0, false, err
|
|
}
|
|
changed := false
|
|
if quota.GetString("day") != now.Format(time.DateOnly) {
|
|
quota.Set("day", now.Format(time.DateOnly))
|
|
quota.Set("used", 0)
|
|
changed = true
|
|
}
|
|
return quota, max(0, provider.globalDailyLimit-quota.GetInt("used")), changed, nil
|
|
}
|
|
|
|
func (provider *homarrProvider) loadQuota(
|
|
app core.App,
|
|
user *core.Record,
|
|
now time.Time,
|
|
) (*core.Record, quotaSnapshot, bool, error) {
|
|
day := now.Format(time.DateOnly)
|
|
quota, err := app.FindFirstRecordByFilter(
|
|
"assistant_quotas",
|
|
"user = {:user} && day = {:day}",
|
|
dbx.Params{"user": user.Id, "day": day},
|
|
)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, quotaSnapshot{}, false, err
|
|
}
|
|
changed := false
|
|
if quota == nil {
|
|
collection, err := app.FindCollectionByNameOrId("assistant_quotas")
|
|
if err != nil {
|
|
return nil, quotaSnapshot{}, false, err
|
|
}
|
|
quota = core.NewRecord(collection)
|
|
quota.Set("user", user.Id)
|
|
quota.Set("day", day)
|
|
quota.Set("used", 0)
|
|
changed = true
|
|
}
|
|
limit := provider.dailyLimit
|
|
used := quota.GetInt("used")
|
|
return quota, quotaSnapshot{
|
|
Limit: limit,
|
|
Used: used,
|
|
Remaining: max(0, limit-used),
|
|
ResetsAt: time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC),
|
|
}, changed, nil
|
|
}
|
|
|
|
func (provider *homarrProvider) startRequest(
|
|
app core.App,
|
|
user *core.Record,
|
|
) (quotaSnapshot, error) {
|
|
var snapshot quotaSnapshot
|
|
err := app.RunInTransaction(func(txApp core.App) error {
|
|
now := provider.now().UTC()
|
|
quota, current, _, err := provider.loadQuota(txApp, user, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
globalQuota, globalRemaining, _, err := provider.loadGlobalQuota(txApp, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Remaining < 1 || globalRemaining < 1 {
|
|
return "aExceededError{Snapshot: current}
|
|
}
|
|
current.Used++
|
|
current.Remaining = max(0, current.Limit-current.Used)
|
|
quota.Set("used", current.Used)
|
|
globalQuota.Set("used", globalQuota.GetInt("used")+1)
|
|
snapshot = current
|
|
if err := txApp.Save(globalQuota); err != nil {
|
|
return err
|
|
}
|
|
return txApp.Save(quota)
|
|
})
|
|
return snapshot, err
|
|
}
|
|
|
|
func openAIError(message string) map[string]any {
|
|
return map[string]any{"error": map[string]any{"message": message, "type": "homarr_provider_error"}}
|
|
}
|
|
|
|
type flushingWriter struct {
|
|
http.ResponseWriter
|
|
}
|
|
|
|
func (writer flushingWriter) Write(chunk []byte) (int, error) {
|
|
written, err := writer.ResponseWriter.Write(chunk)
|
|
if flusher, ok := writer.ResponseWriter.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
return written, err
|
|
}
|