Files
homarr/apps/workshop/homarr_provider.go
T
Thomas 5e23659d99 feat(assistant): optimize MCP and custom widget authoring (#6743)
* 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
2026-08-30 23:43:29 +02:00

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, &quotaErr) {
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 &quotaExceededError{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
}