453 lines
15 KiB
Go
453 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/pocketbase/dbx"
|
|
"github.com/pocketbase/pocketbase"
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
)
|
|
|
|
const (
|
|
homarrProviderModelID = "homarr/deepseek-v4-flash-latest"
|
|
openRouterModelID = "~deepseek/deepseek-v4-flash-latest"
|
|
defaultDailyLimit = 50
|
|
maxChatRequestBytes = 8 << 20
|
|
maxUpstreamErrorBytes = 1 << 20
|
|
openRouterDefaultURL = "https://openrouter.ai/api/v1"
|
|
providerRequestTimeout = 5 * time.Minute
|
|
)
|
|
|
|
type homarrProvider struct {
|
|
apiKey string
|
|
baseURL string
|
|
dailyLimit int
|
|
httpClient *http.Client
|
|
now func() time.Time
|
|
}
|
|
|
|
type quotaSnapshot struct {
|
|
Limit int `json:"limit"`
|
|
Used int `json:"used"`
|
|
Remaining int `json:"remaining"`
|
|
ResetsAt time.Time `json:"resetsAt"`
|
|
}
|
|
|
|
type requestUsage struct {
|
|
InputTokens int
|
|
OutputTokens int
|
|
TotalTokens int
|
|
Cost float64
|
|
}
|
|
|
|
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"))
|
|
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") {
|
|
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must be an absolute HTTP(S) URL")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
return &homarrProvider{
|
|
apiKey: strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")),
|
|
baseURL: baseURL,
|
|
dailyLimit: dailyLimit,
|
|
httpClient: &http.Client{
|
|
Timeout: providerRequestTimeout,
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
now: time.Now,
|
|
}, nil
|
|
}
|
|
|
|
func (provider *homarrProvider) models(event *core.RequestEvent) error {
|
|
return event.JSON(http.StatusOK, map[string]any{
|
|
"object": "list",
|
|
"data": []map[string]any{{
|
|
"id": homarrProviderModelID,
|
|
"object": "model",
|
|
"name": "DeepSeek V4 Flash Latest",
|
|
"description": "Fast agentic model provided by the community-funded Homarr provider.",
|
|
"owned_by": "homarr",
|
|
"context_length": 1_048_576,
|
|
"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, 0)
|
|
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."))
|
|
}
|
|
|
|
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 requestBody.Model != homarrProviderModelID {
|
|
return event.JSON(http.StatusBadRequest, openAIError("The Homarr provider offers only DeepSeek V4 Flash Latest."))
|
|
}
|
|
|
|
requestUnits := countRequestUnits(requestBody.Messages)
|
|
snapshot, err := provider.quota(event.App, event.Auth, requestUnits)
|
|
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 allowance could not be reserved."))
|
|
}
|
|
|
|
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."))
|
|
}
|
|
payload["model"] = openRouterModelID
|
|
payload["usage"] = map[string]any{"include": true}
|
|
upstreamBody, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared."))
|
|
}
|
|
|
|
privateRequest, publicActivity, err := provider.startActivity(event.App, event.Auth, requestUnits)
|
|
if err != nil {
|
|
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be started."))
|
|
}
|
|
startedAt := provider.now().UTC()
|
|
upstreamRequest, err := http.NewRequestWithContext(
|
|
event.Request.Context(),
|
|
http.MethodPost,
|
|
provider.baseURL+"/chat/completions",
|
|
bytes.NewReader(upstreamBody),
|
|
)
|
|
if err != nil {
|
|
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
|
|
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared."))
|
|
}
|
|
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", "https://homarr.dev")
|
|
upstreamRequest.Header.Set("X-Title", "Homarr Provider")
|
|
upstreamRequest.Header.Set("X-OpenRouter-Metadata", "enabled")
|
|
|
|
upstreamResponse, err := provider.httpClient.Do(upstreamRequest)
|
|
if err != nil {
|
|
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
|
|
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached."))
|
|
}
|
|
defer 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 {
|
|
errorBody, _ := io.ReadAll(io.LimitReader(upstreamResponse.Body, maxUpstreamErrorBytes))
|
|
provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt)
|
|
return event.Blob(upstreamResponse.StatusCode, contentType, errorBody)
|
|
}
|
|
|
|
if requestBody.Stream {
|
|
capture := newSSEUsageCapture()
|
|
event.Response.Header().Set("Content-Type", contentType)
|
|
event.Response.WriteHeader(upstreamResponse.StatusCode)
|
|
_, copyErr := io.Copy(flushingWriter{event.Response}, io.TeeReader(upstreamResponse.Body, capture))
|
|
provider.finishActivity(event.App, privateRequest, publicActivity, statusForError(copyErr), capture.Usage(), startedAt)
|
|
return nil
|
|
}
|
|
|
|
responseBody, readErr := io.ReadAll(upstreamResponse.Body)
|
|
usage := usageFromJSON(responseBody)
|
|
provider.finishActivity(event.App, privateRequest, publicActivity, statusForError(readErr), usage, startedAt)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
|
|
}
|
|
|
|
func countRequestUnits(messages []providerMessage) int {
|
|
units := 1
|
|
for index := len(messages) - 1; index >= 0 && messages[index].Role == "tool"; index-- {
|
|
units++
|
|
}
|
|
return units
|
|
}
|
|
|
|
func (provider *homarrProvider) quota(app core.App, user *core.Record, reserve int) (quotaSnapshot, error) {
|
|
now := provider.now().UTC()
|
|
today := now.Format(time.DateOnly)
|
|
resetsAt := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
|
var snapshot quotaSnapshot
|
|
err := app.RunInTransaction(func(txApp core.App) error {
|
|
quota, err := txApp.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if quota == nil {
|
|
collection, err := txApp.FindCollectionByNameOrId("assistant_quotas")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
quota = core.NewRecord(collection)
|
|
quota.Set("user", user.Id)
|
|
quota.Set("dailyLimit", provider.dailyLimit)
|
|
}
|
|
if quota.GetString("day") != today {
|
|
quota.Set("day", today)
|
|
quota.Set("used", 0)
|
|
quota.Set("inputTokens", 0)
|
|
quota.Set("outputTokens", 0)
|
|
quota.Set("totalTokens", 0)
|
|
}
|
|
limit := quota.GetInt("dailyLimit")
|
|
used := quota.GetInt("used")
|
|
snapshot = quotaSnapshot{Limit: limit, Used: used, Remaining: max(0, limit-used), ResetsAt: resetsAt}
|
|
if reserve > snapshot.Remaining {
|
|
return "aExceededError{Snapshot: snapshot}
|
|
}
|
|
if reserve > 0 {
|
|
used += reserve
|
|
quota.Set("used", used)
|
|
snapshot.Used = used
|
|
snapshot.Remaining = max(0, limit-used)
|
|
}
|
|
return txApp.Save(quota)
|
|
})
|
|
return snapshot, err
|
|
}
|
|
|
|
func (provider *homarrProvider) startActivity(app core.App, user *core.Record, requestUnits int) (*core.Record, *core.Record, error) {
|
|
privateCollection, err := app.FindCollectionByNameOrId("assistant_requests")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
publicCollection, err := app.FindCollectionByNameOrId("assistant_activity")
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
privateRequest := core.NewRecord(privateCollection)
|
|
publicActivity := core.NewRecord(publicCollection)
|
|
for _, record := range []*core.Record{privateRequest, publicActivity} {
|
|
record.Set("status", "processing")
|
|
record.Set("model", homarrProviderModelID)
|
|
record.Set("requestUnits", requestUnits)
|
|
}
|
|
privateRequest.Set("user", user.Id)
|
|
privateRequest.Set("publicActivity", publicActivity.Id)
|
|
|
|
err = app.RunInTransaction(func(txApp core.App) error {
|
|
if err := txApp.Save(publicActivity); err != nil {
|
|
return err
|
|
}
|
|
privateRequest.Set("publicActivity", publicActivity.Id)
|
|
return txApp.Save(privateRequest)
|
|
})
|
|
return privateRequest, publicActivity, err
|
|
}
|
|
|
|
func (provider *homarrProvider) finishActivity(
|
|
app core.App,
|
|
privateRequest *core.Record,
|
|
publicActivity *core.Record,
|
|
status string,
|
|
usage requestUsage,
|
|
startedAt time.Time,
|
|
) {
|
|
durationMs := max(0, int(provider.now().UTC().Sub(startedAt).Milliseconds()))
|
|
_ = app.RunInTransaction(func(txApp core.App) error {
|
|
for _, record := range []*core.Record{privateRequest, publicActivity} {
|
|
record.Set("status", status)
|
|
record.Set("inputTokens", usage.InputTokens)
|
|
record.Set("outputTokens", usage.OutputTokens)
|
|
record.Set("totalTokens", usage.TotalTokens)
|
|
record.Set("durationMs", durationMs)
|
|
record.Set("cost", usage.Cost)
|
|
if err := txApp.Save(record); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
quota, err := txApp.FindFirstRecordByFilter(
|
|
"assistant_quotas",
|
|
"user = {:user}",
|
|
dbx.Params{"user": privateRequest.GetString("user")},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
quota.Set("inputTokens", quota.GetInt("inputTokens")+usage.InputTokens)
|
|
quota.Set("outputTokens", quota.GetInt("outputTokens")+usage.OutputTokens)
|
|
quota.Set("totalTokens", quota.GetInt("totalTokens")+usage.TotalTokens)
|
|
return txApp.Save(quota)
|
|
})
|
|
}
|
|
|
|
func statusForError(err error) string {
|
|
if err != nil {
|
|
return "failed"
|
|
}
|
|
return "completed"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type sseUsageCapture struct {
|
|
pending []byte
|
|
usage requestUsage
|
|
}
|
|
|
|
func newSSEUsageCapture() *sseUsageCapture { return &sseUsageCapture{} }
|
|
|
|
func (capture *sseUsageCapture) Write(chunk []byte) (int, error) {
|
|
capture.pending = append(capture.pending, chunk...)
|
|
for {
|
|
end := bytes.IndexByte(capture.pending, '\n')
|
|
if end < 0 {
|
|
break
|
|
}
|
|
line := bytes.TrimSuffix(capture.pending[:end], []byte{'\r'})
|
|
if bytes.HasPrefix(line, []byte("data: ")) {
|
|
capture.mergeUsage(bytes.TrimPrefix(line, []byte("data: ")))
|
|
}
|
|
capture.pending = capture.pending[end+1:]
|
|
}
|
|
if len(capture.pending) > maxUpstreamErrorBytes {
|
|
capture.pending = capture.pending[len(capture.pending)-maxUpstreamErrorBytes:]
|
|
}
|
|
return len(chunk), nil
|
|
}
|
|
|
|
func (capture *sseUsageCapture) mergeUsage(data []byte) {
|
|
if bytes.Equal(data, []byte("[DONE]")) || !bytes.Contains(data, []byte(`"usage"`)) {
|
|
return
|
|
}
|
|
usage := usageFromJSON(data)
|
|
if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.TotalTokens != 0 || usage.Cost != 0 {
|
|
capture.usage = usage
|
|
}
|
|
}
|
|
|
|
func (capture *sseUsageCapture) Usage() requestUsage { return capture.usage }
|
|
|
|
func usageFromJSON(data []byte) requestUsage {
|
|
var payload struct {
|
|
Usage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
Cost float64 `json:"cost"`
|
|
} `json:"usage"`
|
|
}
|
|
if json.Unmarshal(data, &payload) != nil {
|
|
return requestUsage{}
|
|
}
|
|
return requestUsage{
|
|
InputTokens: payload.Usage.PromptTokens,
|
|
OutputTokens: payload.Usage.CompletionTokens,
|
|
TotalTokens: payload.Usage.TotalTokens,
|
|
Cost: payload.Usage.Cost,
|
|
}
|
|
}
|