Add Homarr provider proxy foundation

This commit is contained in:
ajnart committed 2026-08-11 19:02:34 +02:00
1 parent 9143c15454
commit bc59355eab
15 files changed
+1258 -2

No files matched your search

+5
View File
@@ -7,6 +7,11 @@ HOMARR_WEBSITE_URL=http://127.0.0.1:3003
WORKSHOP_API_URL=http://127.0.0.1:8090
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
# Optional Homarr provider. The upstream key stays only in the Workshop server.
# OPENROUTER_API_KEY=
HOMARR_AI_DAILY_REQUEST_LIMIT=50
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# Optional local GitHub OAuth application.
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
+5
View File
@@ -7,6 +7,11 @@ WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
PB_ALLOWED_ORIGINS=*
# Set this secret in the deployment environment to enable the Homarr provider.
# OPENROUTER_API_KEY=
HOMARR_AI_DAILY_REQUEST_LIMIT=50
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# Optional GitHub OAuth application for the preview hostname.
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
+12
View File
@@ -2,6 +2,17 @@
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39.9@sha256:c0667fe6c5197ff226ff642120ab1ad870c3f2f27300940a61e5a7fa082b6816
FROM golang:1.25.7-alpine AS pocketbase-builder
WORKDIR /src
COPY apps/workshop/go.mod apps/workshop/go.sum ./
RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod go mod download
COPY apps/workshop/*.go ./
RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pocketbase .
FROM pocketbase-builder AS pocketbase-test
RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod go test ./...
FROM node:24.18.0-alpine AS dependencies
WORKDIR /app
ENV COREPACK_HOME=/corepack
@@ -26,6 +37,7 @@ RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
ENV PORT=8090
COPY --from=pocketbase-builder /out/pocketbase /usr/local/bin/pocketbase
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data /pb_public
+3
View File
@@ -13,6 +13,9 @@ services:
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
ports:
- "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090"
volumes:
+18
View File
@@ -1,6 +1,18 @@
name: homarr-workshop
services:
assistant-upstream:
profiles:
- test
image: node:24.18.0-alpine
command: ["node", "/mock/openrouter-mock.mjs"]
environment:
PORT: 18091
volumes:
- ./tests/openrouter-mock.mjs:/mock/openrouter-mock.mjs:ro
security_opt:
- no-new-privileges:true
workshop:
build:
context: ../..
@@ -15,6 +27,9 @@ services:
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
ports:
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
@@ -44,6 +59,9 @@ services:
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
ports:
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}"
+47
View File
@@ -0,0 +1,47 @@
module github.com/homarr-labs/homarr/apps/workshop
go 1.25.0
require (
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.39.9
)
require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0 // indirect
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/image v0.44.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.54.0 // indirect
)
+143
View File
@@ -0,0 +1,143 @@
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0 h1:1JJPIzrFPTNEHCFkIDhKV2CHBklTA/7VHJp9sVB8Em0=
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
github.com/pocketbase/pocketbase v0.39.9 h1:zmrvbWJwBlb+iXst02uD6zNqNpiyG86KtlOJjd+Bhx4=
github.com/pocketbase/pocketbase v0.39.9/go.mod h1:6l4ZFFa8kNkxLSumxzVm+DtczfUz86Wqow0bQYiXEkQ=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+452
View File
@@ -0,0 +1,452 @@
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, &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 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 &quotaExceededError{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,
}
}
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"math"
"testing"
)
func TestCountRequestUnits(t *testing.T) {
tests := []struct {
name string
messages []providerMessage
want int
}{
{name: "user request", messages: []providerMessage{{Role: "user"}}, want: 1},
{
name: "parallel tool results",
messages: []providerMessage{
{Role: "user"},
{Role: "assistant"},
{Role: "tool"},
{Role: "tool"},
},
want: 3,
},
{
name: "historical tools are not charged again",
messages: []providerMessage{
{Role: "user"},
{Role: "assistant"},
{Role: "tool"},
{Role: "assistant"},
{Role: "user"},
},
want: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := countRequestUnits(test.messages); got != test.want {
t.Fatalf("countRequestUnits() = %d, want %d", got, test.want)
}
})
}
}
func TestSSEUsageCaptureHandlesFragmentedEvents(t *testing.T) {
capture := newSSEUsageCapture()
chunks := []string{
"data: {\"id\":\"gen-test\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
"data: {\"usage\":{\"prompt_tokens\":12,\"completion_",
"tokens\":7,\"total_tokens\":19,\"cost\":0.00041}}\n\n",
"data: [DONE]\n\n",
}
for _, chunk := range chunks {
if _, err := capture.Write([]byte(chunk)); err != nil {
t.Fatal(err)
}
}
usage := capture.Usage()
if usage.InputTokens != 12 || usage.OutputTokens != 7 || usage.TotalTokens != 19 {
t.Fatalf("unexpected usage: %+v", usage)
}
if math.Abs(usage.Cost-0.00041) > 0.0000001 {
t.Fatalf("unexpected cost: %f", usage.Cost)
}
}
func TestProviderEnvironment(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", "https://router.example/v1/")
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "75")
provider, err := newHomarrProviderFromEnv()
if err != nil {
t.Fatal(err)
}
if provider.apiKey != "test-key" || provider.baseURL != "https://router.example/v1" || provider.dailyLimit != 75 {
t.Fatalf("unexpected provider config: %#v", provider)
}
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "0")
if _, err := newHomarrProviderFromEnv(); err == nil {
t.Fatal("expected invalid daily limit to fail")
}
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/jsvm"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/hook"
"github.com/pocketbase/pocketbase/tools/osutils"
)
func main() {
app := pocketbase.New()
var hooksDir string
app.RootCmd.PersistentFlags().StringVar(&hooksDir, "hooksDir", "", "the directory with the JS app hooks")
var hooksWatch bool
app.RootCmd.PersistentFlags().BoolVar(&hooksWatch, "hooksWatch", true, "auto restart on JS hook changes")
var hooksPool int
app.RootCmd.PersistentFlags().IntVar(&hooksPool, "hooksPool", 15, "the number of prewarmed JS runtimes")
var migrationsDir string
app.RootCmd.PersistentFlags().StringVar(&migrationsDir, "migrationsDir", "", "the directory with JS migrations")
var automigrate bool
app.RootCmd.PersistentFlags().BoolVar(&automigrate, "automigrate", true, "enable automatic migrations")
var publicDir string
app.RootCmd.PersistentFlags().StringVar(&publicDir, "publicDir", defaultPublicDir(), "the static files directory")
var indexFallback bool
app.RootCmd.PersistentFlags().BoolVar(&indexFallback, "indexFallback", true, "serve index.html for missing paths")
_ = app.RootCmd.ParseFlags(os.Args[1:])
jsvm.MustRegister(app, jsvm.Config{
MigrationsDir: migrationsDir,
HooksDir: hooksDir,
HooksWatch: hooksWatch,
HooksPoolSize: hooksPool,
})
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
registerHomarrProvider(app)
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
Func: func(event *core.ServeEvent) error {
if !event.Router.HasRoute(http.MethodGet, "/{path...}") {
event.Router.GET("/{path...}", apis.Static(os.DirFS(publicDir), indexFallback))
}
return event.Next()
},
Priority: 999,
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
func defaultPublicDir() string {
if osutils.IsProbablyGoRun() {
return "./pb_public"
}
return filepath.Join(os.Args[0], "../pb_public")
}
@@ -0,0 +1,98 @@
/// <reference path="../pb_data/types.d.ts" />
const requestFields = () => [
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["processing", "completed", "failed"] },
{ type: "text", name: "model", required: true, min: 1, max: 128 },
{ type: "number", name: "requestUnits", required: true, onlyInt: true, min: 1, max: 1000 },
{ type: "number", name: "inputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "outputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "totalTokens", onlyInt: true, min: 0 },
{ type: "number", name: "durationMs", onlyInt: true, min: 0 },
{ type: "number", name: "cost", min: 0 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
];
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
const activity = new Collection({
type: "base",
name: "assistant_activity",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: requestFields(),
});
activity.addIndex("idx_assistant_activity_created", false, "created", "");
app.save(activity);
const requests = new Collection({
type: "base",
name: "assistant_requests",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
...requestFields(),
{
type: "relation",
name: "user",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{
type: "relation",
name: "publicActivity",
required: true,
maxSelect: 1,
collectionId: activity.id,
cascadeDelete: true,
},
],
});
requests.addIndex("idx_assistant_requests_user_created", false, "user, created", "");
app.save(requests);
const quotas = new Collection({
type: "base",
name: "assistant_quotas",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{
type: "relation",
name: "user",
required: true,
maxSelect: 1,
collectionId: users.id,
cascadeDelete: true,
},
{ type: "number", name: "dailyLimit", required: true, onlyInt: true, min: 0, max: 100000 },
{ type: "text", name: "day", required: true, min: 10, max: 10 },
{ type: "number", name: "used", onlyInt: true, min: 0 },
{ type: "number", name: "inputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "outputTokens", onlyInt: true, min: 0 },
{ type: "number", name: "totalTokens", onlyInt: true, min: 0 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
app.save(quotas);
},
(app) => {
app.delete(app.findCollectionByNameOrId("assistant_quotas"));
app.delete(app.findCollectionByNameOrId("assistant_requests"));
app.delete(app.findCollectionByNameOrId("assistant_activity"));
},
);
+5 -2
View File
@@ -3,12 +3,15 @@ set -eu
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
export HOMARR_AI_OPENROUTER_BASE_URL=http://assistant-upstream:18091/api/v1
export OPENROUTER_API_KEY=workshop-test-openrouter-key
docker build --target pocketbase-test -f apps/workshop/Dockerfile .
node apps/workshop/tests/workshop-contracts.mjs
node apps/workshop/tests/workshop-migration.mjs
cleanup() {
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose --profile test -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
@@ -16,7 +19,7 @@ trap cleanup EXIT
HOMARR_WEBSITE_URL=https://preview.example.invalid \
WORKSHOP_API_URL=https://api.preview.example.invalid \
WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose --profile test -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml up --build -d
for attempt in $(seq 1 60); do
+71
View File
@@ -0,0 +1,71 @@
import { createServer } from "node:http";
const port = Number(process.env.PORT ?? 18091);
const json = (response, status, body) => {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body));
};
const server = createServer(async (request, response) => {
if (request.method === "GET" && request.url === "/health") {
json(response, 200, { ok: true });
return;
}
if (request.method !== "POST" || request.url !== "/api/v1/chat/completions") {
json(response, 404, { error: { message: "Not found" } });
return;
}
if (request.headers.authorization !== "Bearer workshop-test-openrouter-key") {
json(response, 401, { error: { message: "Invalid upstream credentials" } });
return;
}
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (body.model !== "~deepseek/deepseek-v4-flash-latest") {
json(response, 400, { error: { message: "Unexpected upstream model" } });
return;
}
const completion = {
id: "gen-workshop-test",
object: "chat.completion",
model: body.model,
choices: [{ index: 0, message: { role: "assistant", content: "Hello from the Homarr provider" }, finish_reason: "stop" }],
usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19, cost: 0.00041 },
};
if (!body.stream) {
json(response, 200, completion);
return;
}
response.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
"x-request-id": "mock-openrouter-request",
});
response.write(
`data: ${JSON.stringify({
id: completion.id,
object: "chat.completion.chunk",
model: body.model,
choices: [{ index: 0, delta: { role: "assistant", content: "Hello from " }, finish_reason: null }],
})}\n\n`,
);
setTimeout(() => {
response.write(
`data: ${JSON.stringify({
id: completion.id,
object: "chat.completion.chunk",
model: body.model,
choices: [{ index: 0, delta: { content: "the Homarr provider" }, finish_reason: "stop" }],
usage: completion.usage,
})}\n\n`,
);
response.end("data: [DONE]\n\n");
}, 150);
});
server.listen(port, "0.0.0.0");
@@ -73,6 +73,34 @@ const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_wi
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
}
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
for (const required of [
'name: "assistant_quotas"',
'name: "assistant_requests"',
'name: "assistant_activity"',
'listRule: ""',
"idx_assistant_quotas_user",
]) {
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
}
if (/assistant_activity[\s\S]*name: "user"/u.test(providerMigration.split("const requests")[0])) {
throw new Error("The public assistant activity collection must not contain user identity");
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/deepseek-v4-flash-latest"',
'apis.RequireAuth("users")',
"DefaultActivityLoggerMiddlewareId",
'os.Getenv("OPENROUTER_API_KEY")',
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
]) {
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
}
if (/logger\(\)|\.Logger\(/u.test(providerBackend)) {
throw new Error("The Homarr provider must not log request content or user tokens");
}
for (const required of [
"workshop_migration_state",
"addedUserFields",
@@ -1,5 +1,34 @@
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
const createSseReader = (body) => {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
return {
cancel: () => reader.cancel(),
async next() {
while (true) {
const boundary = buffer.indexOf("\n\n");
if (boundary >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const result = { event: "message", id: "", data: "" };
for (const line of block.split("\n")) {
if (line.startsWith("event:")) result.event = line.slice(6).trim();
if (line.startsWith("id:")) result.id = line.slice(3).trim();
if (line.startsWith("data:")) result.data += line.slice(5).trim();
}
if (result.data || result.id) return result;
continue;
}
const chunk = await reader.read();
if (chunk.done) throw new Error("PocketBase realtime stream ended unexpectedly");
buffer += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n");
}
},
};
};
const request = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
@@ -35,6 +64,9 @@ for (const required of [
"reports",
"workshop_listings",
"workshop_report_summaries",
"assistant_quotas",
"assistant_requests",
"assistant_activity",
]) {
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
}
@@ -100,6 +132,182 @@ const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "widget-visitor", visitorPassword);
const visitorSession = await signIn(visitor.email, visitorPassword);
const modelList = await request("/api/ai/v1/models");
if (
modelList.object !== "list" ||
modelList.data.length !== 1 ||
modelList.data[0].id !== "homarr/deepseek-v4-flash-latest"
) {
throw new Error("The Homarr provider must advertise exactly one model");
}
await expectStatus("/api/ai/usage", {}, 401);
const initialUsage = await request("/api/ai/usage", { headers: authorSession.headers });
const initialReset = new Date(initialUsage.resetsAt);
if (
initialUsage.limit !== 50 ||
initialUsage.used !== 0 ||
initialUsage.remaining !== 50 ||
initialReset.getUTCHours() !== 0 ||
initialReset <= new Date()
) {
throw new Error(`Unexpected initial Homarr provider allowance: ${JSON.stringify(initialUsage)}`);
}
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({ model: "another-model", messages: [{ role: "user", content: "hello" }] }),
},
400,
);
const realtimeResponse = await fetch(`${baseUrl}/api/realtime`);
if (!realtimeResponse.ok || !realtimeResponse.body) throw new Error("PocketBase realtime connection failed");
const realtime = createSseReader(realtimeResponse.body);
const connectedEvent = await realtime.next();
if (connectedEvent.event !== "PB_CONNECT" || !connectedEvent.id) {
throw new Error("PocketBase realtime did not provide a client ID");
}
await request("/api/realtime", {
method: "POST",
body: JSON.stringify({ clientId: connectedEvent.id, subscriptions: ["assistant_activity/*"] }),
});
const activityEvent = (async () => {
while (true) {
const event = await realtime.next();
const payload = JSON.parse(event.data);
if (payload.action === "create") return payload.record;
}
})();
const streamStartedAt = performance.now();
const streamResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
messages: [{ role: "user", content: "hello" }],
stream: true,
}),
});
if (!streamResponse.ok || streamResponse.headers.get("x-homarr-quota-remaining") !== "49") {
throw new Error(`Streaming provider request failed: ${streamResponse.status}`);
}
const streamReader = streamResponse.body.getReader();
const firstChunk = await streamReader.read();
const firstChunkAt = performance.now();
let streamText = new TextDecoder().decode(firstChunk.value ?? new Uint8Array());
while (true) {
const chunk = await streamReader.read();
if (chunk.done) break;
streamText += new TextDecoder().decode(chunk.value);
}
const streamFinishedAt = performance.now();
if (
firstChunk.done ||
!streamText.includes("Hello from ") ||
!streamText.includes("the Homarr provider") ||
streamFinishedAt - firstChunkAt < 50 ||
firstChunkAt - streamStartedAt > 5_000
) {
throw new Error("The Homarr provider did not preserve the upstream SSE stream");
}
const liveActivity = await Promise.race([
activityEvent,
new Promise((_, reject) => setTimeout(() => reject(new Error("Assistant activity realtime event timed out")), 2_000)),
]);
await realtime.cancel();
if (liveActivity.model !== "homarr/deepseek-v4-flash-latest" || liveActivity.requestUnits !== 1) {
throw new Error("The public assistant activity event is invalid");
}
const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
messages: [
{ role: "user", content: "inspect my dashboard" },
{ role: "assistant", content: null, tool_calls: [{ id: "one" }, { id: "two" }] },
{ role: "tool", tool_call_id: "one", content: "{}" },
{ role: "tool", tool_call_id: "two", content: "{}" },
],
stream: false,
}),
});
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "46") {
throw new Error("Tool results must each consume one Homarr provider request unit");
}
const afterToolsUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (afterToolsUsage.used !== 4 || afterToolsUsage.remaining !== 46) {
throw new Error(`Tool request accounting is incorrect: ${JSON.stringify(afterToolsUsage)}`);
}
const publicActivities = await request("/api/collections/assistant_activity/records?sort=-created&perPage=10");
if (
publicActivities.items.length < 2 ||
publicActivities.items.some((item) => "user" in item) ||
!publicActivities.items.some(
(item) => item.status === "completed" && item.inputTokens === 12 && item.outputTokens === 7,
)
) {
throw new Error("Public assistant activity must be anonymous and include completed token totals");
}
await expectStatus("/api/collections/assistant_requests/records", { headers: authorSession.headers }, 403);
await expectStatus("/api/collections/assistant_quotas/records", { headers: authorSession.headers }, 403);
const privateRequests = await request("/api/collections/assistant_requests/records?perPage=20", {
headers: rootHeaders,
});
if (
privateRequests.items.length < 2 ||
privateRequests.items.some((item) => item.user !== author.id) ||
privateRequests.items.some((item) => "messages" in item || "prompt" in item || "body" in item)
) {
throw new Error("Private provider accounting must identify the user without storing request content");
}
const quotas = await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders });
const authorQuota = quotas.items.find((item) => item.user === author.id);
if (!authorQuota || authorQuota.dailyLimit !== 50 || authorQuota.used !== 4 || authorQuota.totalTokens !== 38) {
throw new Error(`Private provider quota is incorrect: ${JSON.stringify(authorQuota)}`);
}
await request(`/api/collections/assistant_quotas/records/${authorQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ day: "2000-01-01", used: 49, dailyLimit: 2 }),
});
const resetUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (resetUsage.limit !== 2 || resetUsage.used !== 0 || resetUsage.remaining !== 2) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const allowanceProbe = () =>
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/deepseek-v4-flash-latest",
messages: [{ role: "user", content: "allowance probe" }],
stream: false,
}),
});
if (!(await allowanceProbe()).ok || !(await allowanceProbe()).ok) {
throw new Error("The per-user allowance rejected a permitted request");
}
const exhaustedResponse = await allowanceProbe();
if (
exhaustedResponse.status !== 429 ||
exhaustedResponse.headers.get("x-homarr-quota-remaining") !== "0" ||
!exhaustedResponse.headers.get("x-homarr-quota-reset")
) {
throw new Error("The per-user allowance was not enforced atomically");
}
const widget = {
$schema: "homarr-custom-widget-v2",
name: "Workshop runtime probe",