feat: add the community-funded Homarr assistant provider (#6563)

* Add Homarr provider proxy foundation

* Complete Homarr provider integration

* Harden Homarr provider integration

* Address Homarr provider review feedback

* Address CodeRabbit provider feedback

* Localize provider errors

* Handle provider activity load failures

* docs: improve workshop and homepage

* Harden Homarr provider limits

* fix: harden the Homarr provider

* fix: address provider review feedback

* fix: clarify provider request accounting

* feat: complete the Homarr provider endpoint

* fix: terminate invalid provider streams
This commit is contained in:
Thomas authored and ajnart committed 2026-08-14 11:32:11 +02:00
1 parent b8f360c04e
commit 45d8bc9443
77 files changed
+4035 -763

No files matched your search

+8
View File
@@ -7,6 +7,14 @@ 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_GLOBAL_DAILY_REQUEST_LIMIT=10000
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# HOMARR_AI_OPENROUTER_MODEL=
# HOMARR_AI_ALLOW_INSECURE_UPSTREAM=false
# Optional local GitHub OAuth application.
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
+7
View File
@@ -7,6 +7,13 @@ 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_GLOBAL_DAILY_REQUEST_LIMIT=10000
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# HOMARR_AI_OPENROUTER_MODEL=
# 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
+12
View File
@@ -123,6 +123,18 @@ pnpm --filter @homarr/docs typecheck
The integration suite uses a separate Compose project and deletes only its disposable volume.
To smoke-test a deployed Workshop against its real configured upstream, use a dedicated low-quota test user:
```sh
HOMARR_AI_LIVE_SMOKE=1 \
WORKSHOP_LIVE_TEST_URL=https://workshop.example.com \
WORKSHOP_LIVE_TEST_TOKEN=<temporary-pocketbase-user-token> \
pnpm test:workshop-live-provider
```
This opt-in check consumes one real request. It verifies the stable model contract, an authenticated completion, quota
headers, and server-side user accounting without requiring the OpenRouter key or upstream model on the test client.
## Backup and restore
PocketBase data lives in `/pb_data`. Stop writes and copy it into a new, empty host directory for each
+5
View File
@@ -13,6 +13,11 @@ 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_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
ports:
- "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090"
volumes:
+25
View File
@@ -1,6 +1,19 @@
name: homarr-workshop
services:
assistant-upstream:
profiles:
- test
image: node:24.18.0-alpine
command: ["node", "/mock/openrouter-mock.mjs"]
environment:
PORT: 18091
EXPECTED_MODEL: mock/team-selected-model
volumes:
- ./tests/openrouter-mock.mjs:/mock/openrouter-mock.mjs:ro
security_opt:
- no-new-privileges:true
workshop:
build:
context: ../..
@@ -15,6 +28,12 @@ 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_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
HOMARR_AI_ALLOW_INSECURE_UPSTREAM: ${HOMARR_AI_ALLOW_INSECURE_UPSTREAM:-false}
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 +63,12 @@ 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_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
HOMARR_AI_ALLOW_INSECURE_UPSTREAM: ${HOMARR_AI_ALLOW_INSECURE_UPSTREAM:-false}
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=
+682
View File
@@ -0,0 +1,682 @@
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
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,
},
})
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) {
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
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)
changed = true
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
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
}
+276
View File
@@ -0,0 +1,276 @@
package main
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestSanitizeProviderPayload(t *testing.T) {
payload := map[string]any{
"model": homarrProviderModelID,
"models": []string{"attacker/model"},
"provider": map[string]any{"order": []string{"attacker"}},
"route": "fallback",
"plugins": []string{"web"},
"transforms": []string{"middle-out"},
"max_tokens": 1_000_000,
"max_completion_tokens": 1_000_000,
"n": 100,
"parallel_tool_calls": true,
"extra_body": map[string]any{"provider": map[string]any{"order": []string{"attacker"}}},
"extra_headers": map[string]any{"Authorization": "Bearer attacker"},
"metadata": map[string]any{"user": "private-user-id"},
"audio": map[string]any{"format": "wav"},
"modalities": []string{"text", "audio"},
"logprobs": true,
"top_logprobs": 20,
"prediction": map[string]any{"content": strings.Repeat("x", 100)},
"service_tier": "priority",
"reasoning": map[string]any{"effort": "high", "max_tokens": 1_000_000},
"user": "private-user-id",
"tools": []any{
map[string]any{"type": "function", "function": map[string]any{"name": "board_list"}},
map[string]any{
"type": "openrouter:web_search",
"parameters": map[string]any{"max_results": 1000, "max_uses": 1000},
},
map[string]any{"type": "openrouter:web_search"},
},
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
}
if err := sanitizeProviderPayload(payload, "mock/team-selected-model"); err != nil {
t.Fatal(err)
}
for _, field := range clientControlledRoutingFields {
if field == "provider" {
continue
}
if _, exists := payload[field]; exists {
t.Fatalf("client-controlled routing field %q was forwarded", field)
}
}
for _, field := range clientControlledCostFields {
if _, exists := payload[field]; exists {
t.Fatalf("client-controlled cost field %q was forwarded", field)
}
}
if payload["model"] != "mock/team-selected-model" {
t.Fatalf("unexpected upstream model: %v", payload["model"])
}
if payload["max_completion_tokens"] != maxChatOutputTokens || payload["n"] != 1 || payload["parallel_tool_calls"] != false {
t.Fatalf("unsafe generation controls were forwarded: %#v", payload)
}
if _, exists := payload["max_tokens"]; exists {
t.Fatal("deprecated client max_tokens was forwarded")
}
if _, exists := payload["user"]; exists {
t.Fatal("client user identifier was forwarded")
}
if _, exists := payload["metadata"]; exists {
t.Fatal("client metadata was forwarded")
}
reasoning := payload["reasoning"].(map[string]any)
if _, exists := reasoning["max_tokens"]; exists || reasoning["effort"] != "high" {
t.Fatalf("reasoning budget was not bounded safely: %#v", reasoning)
}
tools := payload["tools"].([]any)
if len(tools) != 2 {
t.Fatalf("unexpected sanitized tool count: %d", len(tools))
}
webSearch := tools[1].(map[string]any)
parameters := webSearch["parameters"].(map[string]any)
if parameters["max_results"] != maxWebSearchResults || parameters["max_uses"] != maxWebSearchUses {
t.Fatalf("web search limits were not enforced: %#v", parameters)
}
usage, err := json.Marshal(payload["usage"])
if err != nil || string(usage) != `{"include":true}` {
t.Fatalf("unexpected usage options: %s, %v", usage, err)
}
privacy := payload["provider"].(map[string]any)
if privacy["zdr"] != true || privacy["data_collection"] != "deny" {
t.Fatalf("upstream privacy controls were not enforced: %#v", privacy)
}
}
func TestSanitizeProviderPayloadRejectsUnsupportedServerTools(t *testing.T) {
payload := map[string]any{"tools": []any{map[string]any{"type": "openrouter:computer"}}}
if err := sanitizeProviderPayload(payload, "mock/team-selected-model"); err == nil {
t.Fatal("expected unsupported server tool to be rejected")
}
}
func TestSafeUpstreamStatus(t *testing.T) {
for _, status := range []int{401, 403} {
if actual := safeUpstreamStatus(status); actual != 502 {
t.Fatalf("expected upstream %d to become 502, got %d", status, actual)
}
}
if actual := safeUpstreamStatus(429); actual != 429 {
t.Fatalf("expected upstream 429 to be preserved, got %d", actual)
}
}
func TestValidateProviderInput(t *testing.T) {
payload := map[string]any{
"messages": []any{map[string]any{
"role": "user",
"content": []any{map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": "data:image/png;base64," + strings.Repeat("a", 1_300_000)},
}},
}},
}
if err := validateProviderInput(payload); err != nil {
t.Fatalf("expected a supported Homarr image request, got %v", err)
}
payload["messages"] = []any{map[string]any{"role": "user", "content": strings.Repeat("x", maxChatTextBytes+1)}}
if err := validateProviderInput(payload); !errors.Is(err, errInputTooLarge) {
t.Fatalf("expected oversized text input to be rejected, got %v", err)
}
payload["messages"] = []any{map[string]any{
"role": "user",
"content": []any{map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": "data:image/png;base64," + strings.Repeat("a", maxChatImageDataBytes)},
}},
}}
if err := validateProviderInput(payload); !errors.Is(err, errTooManyImages) {
t.Fatalf("expected oversized image input to be rejected, got %v", err)
}
payload["messages"] = []any{map[string]any{
"role": "user",
"content": []any{map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": "https://attacker.example/expensive-image.png"},
}},
}}
if err := validateProviderInput(payload); !errors.Is(err, errRemoteImage) {
t.Fatalf("expected a remote image URL to be rejected, got %v", err)
}
payload["messages"] = []any{map[string]any{
"role": "user",
"content": []any{
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
},
}}
if err := validateProviderInput(payload); err != nil {
t.Fatalf("expected five uploaded images to be accepted, got %v", err)
}
payload["messages"].([]any)[0].(map[string]any)["content"] = append(
payload["messages"].([]any)[0].(map[string]any)["content"].([]any),
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
)
if err := validateProviderInput(payload); !errors.Is(err, errTooManyImages) {
t.Fatalf("expected six uploaded images to be rejected, got %v", err)
}
}
func TestProviderInFlightLimits(t *testing.T) {
provider := &homarrProvider{inFlightByUser: make(map[string]int)}
if !provider.acquireRequest("one") {
t.Fatal("expected the first user request to be admitted")
}
if !provider.acquireRequest("one") {
t.Fatal("expected the second user request to be admitted")
}
if provider.acquireRequest("one") {
t.Fatal("expected the third user request to be rejected")
}
provider.releaseRequest("one")
provider.releaseRequest("one")
for index := range maxGlobalInFlight {
if !provider.acquireRequest(string(rune(index + 1))) {
t.Fatalf("expected global request %d to be admitted", index)
}
}
if provider.acquireRequest("overflow") {
t.Fatal("expected the global in-flight limit to reject overflow")
}
}
func TestCompletionModelAlias(t *testing.T) {
body, err := aliasCompletionBody([]byte(`{"id":"one","model":"private/model","choices":[]}`))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "private/model") || !strings.Contains(string(body), `"model":"homarr/model"`) {
t.Fatalf("private upstream model leaked in JSON: %s", body)
}
var stream strings.Builder
_, err = copyAliasedSSE(
&stream,
strings.NewReader("data: {\"id\":\"one\",\"model\":\"private/model\",\"choices\":[]}\n\ndata: [DONE]\n\n"),
1024,
)
if err != nil || strings.Contains(stream.String(), "private/model") ||
!strings.Contains(stream.String(), `"model":"homarr/model"`) {
t.Fatalf("private upstream model leaked in SSE: %s, %v", stream.String(), err)
}
}
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_OPENROUTER_MODEL", "mock/team-selected-model")
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "75")
t.Setenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT", "900")
provider, err := newHomarrProviderFromEnv()
if err != nil {
t.Fatal(err)
}
if provider.apiKey != "test-key" || provider.baseURL != "https://router.example/v1" ||
provider.modelID != "mock/team-selected-model" || provider.dailyLimit != 75 || provider.globalDailyLimit != 900 {
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")
}
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "50")
for _, unsafeURL := range []string{
"https://user:password@router.example/v1",
"https://router.example/v1?key=secret",
"https://router.example/v1#fragment",
} {
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", unsafeURL)
if _, err := newHomarrProviderFromEnv(); err == nil {
t.Fatalf("expected unsafe upstream URL %q to fail", unsafeURL)
}
}
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", "http://router.example/v1")
if _, err := newHomarrProviderFromEnv(); err == nil {
t.Fatal("expected an insecure upstream to fail without an explicit development opt-in")
}
}
func TestReadBoundedBody(t *testing.T) {
body, err := readBoundedBody(strings.NewReader("1234"), 4)
if err != nil || string(body) != "1234" {
t.Fatalf("unexpected bounded body: %q, %v", body, err)
}
if _, err := readBoundedBody(strings.NewReader("12345"), 4); err != errProviderResponseTooLarge {
t.Fatalf("expected response size error, got %v", err)
}
}
func TestCopyBounded(t *testing.T) {
var target strings.Builder
written, err := copyBounded(&target, strings.NewReader("1234"), 4)
if err != nil || written != 4 || target.String() != "1234" {
t.Fatalf("unexpected bounded copy: %q, %d, %v", target.String(), written, err)
}
target.Reset()
written, err = copyBounded(&target, strings.NewReader("12345"), 4)
if err != errProviderResponseTooLarge || written != 4 || target.String() != "1234" {
t.Fatalf("expected bounded copy failure, got %q, %d, %v", target.String(), written, err)
}
}
+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,61 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const users = app.findCollectionByNameOrId("users");
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: "text", name: "day", required: true, min: 10, max: 10 },
{ type: "number", name: "used", 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);
const globalQuota = new Collection({
type: "base",
name: "assistant_global_quota",
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ type: "text", name: "key", required: true, min: 1, max: 32 },
{ type: "text", name: "day", required: true, min: 10, max: 10 },
{ type: "number", name: "used", onlyInt: true, min: 0 },
{ type: "autodate", name: "created", onCreate: true },
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
],
});
globalQuota.addIndex("idx_assistant_global_quota_key", true, "key", "");
app.save(globalQuota);
const globalRecord = new Record(globalQuota);
globalRecord.set("key", "default");
globalRecord.set("day", "1970-01-01");
globalRecord.set("used", 0);
app.save(globalRecord);
},
(app) => {
app.delete(app.findCollectionByNameOrId("assistant_global_quota"));
app.delete(app.findCollectionByNameOrId("assistant_quotas"));
},
);
+25 -2
View File
@@ -3,12 +3,17 @@ 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 HOMARR_AI_OPENROUTER_MODEL=mock/team-selected-model
export HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true
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 +21,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
@@ -59,6 +64,24 @@ for client_id in workshop-test-client workshop-rotated-client; do
node apps/workshop/tests/oauth-sync.integration.mjs
done
OPENROUTER_API_KEY="" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml up -d --force-recreate workshop
for attempt in $(seq 1 60); do
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
break
fi
if [ "$attempt" -eq 60 ]; then
exit 1
fi
sleep 1
done
models_status=$(curl --silent --output /dev/null --write-out '%{http_code}' \
"http://127.0.0.1:$WORKSHOP_TEST_PORT/api/ai/v1/models")
if [ "$models_status" != "503" ]; then
echo "Expected an unconfigured Homarr provider to return 503, received $models_status"
exit 1
fi
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
-f apps/workshop/docker-compose.yml stop workshop
printf 'y\n' | PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
@@ -0,0 +1,52 @@
const enabled = process.env.HOMARR_AI_LIVE_SMOKE === "1";
if (!enabled)
throw new Error("Set HOMARR_AI_LIVE_SMOKE=1 to acknowledge that this check consumes a real provider request");
const baseUrl = process.env.WORKSHOP_LIVE_TEST_URL?.replace(/\/+$/u, "");
const token = process.env.WORKSHOP_LIVE_TEST_TOKEN;
if (!baseUrl || !token) {
throw new Error("WORKSHOP_LIVE_TEST_URL and WORKSHOP_LIVE_TEST_TOKEN are required");
}
const requestJson = async (path, init = {}) => {
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ...init.headers },
signal: AbortSignal.timeout(60_000),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
return { body, headers: response.headers };
};
const { body: models } = await requestJson("/api/ai/v1/models");
if (models.data?.length !== 1 || models.data[0]?.id !== "homarr/model") {
throw new Error("The live Workshop endpoint did not advertise the stable Homarr model");
}
const { body: before } = await requestJson("/api/ai/usage");
if (before.remaining < 1) throw new Error("The live test user has no remaining Homarr provider allowance");
const { body: completion, headers } = await requestJson("/api/ai/v1/chat/completions", {
method: "POST",
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "Reply with exactly OK." }],
stream: false,
}),
});
if (
completion.model !== "homarr/model" ||
typeof completion.choices?.[0]?.message?.content !== "string" ||
completion.choices[0].message.content.length === 0 ||
headers.get("x-homarr-quota-remaining") === null
) {
throw new Error("The live Homarr provider did not return an OpenAI-compatible completion with quota headers");
}
const { body: after } = await requestJson("/api/ai/usage");
if (after.used < before.used + 1 || after.remaining >= before.remaining) {
throw new Error("The live completion was not charged to the authenticated Workshop user");
}
process.stdout.write("Live Homarr provider smoke test passed\n");
+203
View File
@@ -0,0 +1,203 @@
import { createServer } from "node:http";
const port = Number(process.env.PORT ?? 18091);
const expectedModel = process.env.EXPECTED_MODEL ?? "mock/team-selected-model";
const expectedApiKey = process.env.EXPECTED_API_KEY ?? "workshop-test-openrouter-key";
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 ${expectedApiKey}`) {
json(response, 401, { error: { message: "Invalid upstream credentials" } });
return;
}
if (
request.headers["http-referer"] !== "https://homarr.dev" ||
request.headers["x-openrouter-title"] !== "Homarr AI Assistant" ||
request.headers["x-openrouter-metadata"] !== "enabled"
) {
json(response, 400, { error: { message: "Homarr upstream attribution headers are missing or spoofed" } });
return;
}
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (body.model !== expectedModel) {
json(response, 400, { error: { message: "Unexpected upstream model" } });
return;
}
if (body.max_completion_tokens !== 32_768 || body.n !== 1 || body.parallel_tool_calls !== false) {
json(response, 400, { error: { message: "Unsafe generation controls reached the upstream" } });
return;
}
if (
"max_tokens" in body ||
"user" in body ||
"metadata" in body ||
"extra_body" in body ||
"extra_headers" in body ||
"max_tokens" in (body.reasoning ?? {})
) {
json(response, 400, { error: { message: "Private or unbounded request fields reached the upstream" } });
return;
}
const forbiddenCostField = ["audio", "modalities", "logprobs", "top_logprobs", "prediction", "service_tier"].find(
(field) => field in body,
);
if (forbiddenCostField) {
json(response, 400, { error: { message: `Client cost field was forwarded: ${forbiddenCostField}` } });
return;
}
const forbiddenRoutingField = ["models", "route", "plugins", "transforms"].find((field) => field in body);
if (forbiddenRoutingField) {
json(response, 400, { error: { message: `Client routing field was forwarded: ${forbiddenRoutingField}` } });
return;
}
if (body.provider?.zdr !== true || body.provider?.data_collection !== "deny") {
json(response, 400, { error: { message: "Required upstream privacy controls are missing" } });
return;
}
const webSearchTools = body.tools?.filter((tool) => tool.type === "openrouter:web_search") ?? [];
if (
webSearchTools.length > 1 ||
webSearchTools.some((tool) => tool.parameters?.max_results !== 5 || tool.parameters?.max_uses !== 3)
) {
json(response, 400, { error: { message: "Unbounded web search controls reached the upstream" } });
return;
}
if (body.messages?.some((message) => message.content === "upstream failure")) {
json(response, 503, { error: { message: "Simulated upstream failure" } });
return;
}
if (body.messages?.some((message) => message.content === "malformed stream")) {
response.writeHead(200, { "content-type": "text/event-stream" });
response.end('data: {"model":\n\n');
return;
}
if (body.messages?.some((message) => message.content === "in-flight probe")) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
const wantsToolCall = body.messages?.some((message) => message.content === "inspect my dashboard");
if (wantsToolCall && !body.tools?.some((tool) => tool.type === "function" && tool.function?.name === "board_list")) {
json(response, 400, { error: { message: "The authenticated function tool was not forwarded" } });
return;
}
const completion = {
id: "gen-workshop-test",
object: "chat.completion",
model: body.model,
choices: [
wantsToolCall
? {
index: 0,
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "call-board-list",
type: "function",
function: { name: "board_list", arguments: "{}" },
},
],
},
finish_reason: "tool_calls",
}
: {
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",
});
if (wantsToolCall) {
response.write(
`data: ${JSON.stringify({
id: completion.id,
object: "chat.completion.chunk",
model: body.model,
choices: [
{
index: 0,
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call-board-list",
type: "function",
function: { name: "board_list", arguments: "{" },
},
],
},
finish_reason: null,
},
],
})}\n\n`,
);
response.write(
`data: ${JSON.stringify({
id: completion.id,
object: "chat.completion.chunk",
model: body.model,
choices: [
{
index: 0,
delta: { tool_calls: [{ index: 0, function: { arguments: "}" } }] },
finish_reason: "tool_calls",
},
],
usage: completion.usage,
})}\n\n`,
);
response.end("data: [DONE]\n\n");
return;
}
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,32 @@ 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_global_quota"', "idx_assistant_quotas_user"]) {
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
}
for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) {
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
'apis.RequireAuth("users")',
"DefaultActivityLoggerMiddlewareId",
'os.Getenv("OPENROUTER_API_KEY")',
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")',
'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")',
'"data_collection": "deny"',
'"zdr": true',
]) {
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
}
if (/logger\(\)|\.Logger\(|log\.Print/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",
+390 -1
View File
@@ -35,10 +35,12 @@ for (const required of [
"reports",
"workshop_listings",
"workshop_report_summaries",
"assistant_quotas",
"assistant_global_quota",
]) {
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
}
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
for (const removed of ["workshop_admin_actions", "workshop_admins", "assistant_requests", "assistant_activity"]) {
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
}
@@ -99,6 +101,393 @@ if (unchangedAuthor.name !== "widget-author") {
const visitorPassword = "WorkshopVisitor123!";
const visitor = await createUser("widget-visitor@example.invalid", "widget-visitor", visitorPassword);
const visitorSession = await signIn(visitor.email, visitorPassword);
const concurrencyPassword = "WorkshopConcurrency123!";
const concurrencyUser = await createUser(
"provider-concurrency@example.invalid",
"provider-concurrency",
concurrencyPassword,
);
const concurrencySession = await signIn(concurrencyUser.email, concurrencyPassword);
const modelList = await request("/api/ai/v1/models");
if (
modelList.object !== "list" ||
modelList.data.length !== 1 ||
modelList.data[0].id !== "homarr/model" ||
modelList.data[0].context_length !== 256 * 1024
) {
throw new Error("The Homarr provider must advertise exactly one model");
}
await expectStatus("/api/ai/usage", {}, 401);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "unauthenticated" }],
}),
},
401,
);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
headers: { authorization: "Bearer forged-workshop-user-token" },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "forged identity" }],
}),
},
401,
);
const inFlightProbe = () =>
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...concurrencySession.headers },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "in-flight probe" }],
stream: false,
}),
});
const inFlightResponses = await Promise.all([inFlightProbe(), inFlightProbe(), inFlightProbe()]);
if (
inFlightResponses
.map((response) => response.status)
.toSorted()
.join(",") !== "200,200,429"
) {
throw new Error("The per-user in-flight request limit was not enforced");
}
const concurrencyUsage = await request("/api/ai/usage", { headers: concurrencySession.headers });
if (concurrencyUsage.used !== 2) {
throw new Error(`Rejected in-flight requests must not consume allowance: ${JSON.stringify(concurrencyUsage)}`);
}
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: "homarr/model",
messages: [
{ role: "user", content: "too many tools" },
...Array.from({ length: 1000 }, (_, index) => ({
role: "tool",
tool_call_id: `tool-${index}`,
content: "{}",
})),
],
}),
},
400,
);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "x".repeat(256 * 1024 + 1) }],
}),
},
413,
);
await expectStatus(
"/api/ai/v1/chat/completions",
{
method: "POST",
headers: authorSession.headers,
body: JSON.stringify({
model: "homarr/model",
messages: [
{
role: "user",
content: [{ type: "image_url", image_url: { url: "https://attacker.example/tracker.png" } }],
},
],
}),
},
413,
);
const usageAfterRejectedRequest = await request("/api/ai/usage", { headers: authorSession.headers });
if (usageAfterRejectedRequest.used !== 0) {
throw new Error("A rejected oversized request-unit batch consumed allowance");
}
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 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/model",
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 dataFrames = streamText.split(/\r?\n/u).filter((line) => line.startsWith("data:"));
if (
firstChunk.done ||
!streamText.includes("Hello from ") ||
!streamText.includes("the Homarr provider") ||
streamText.includes("mock/team-selected-model") ||
!streamText.includes('"model":"homarr/model"') ||
dataFrames.length < 3 ||
dataFrames.at(-1)?.trim() !== "data: [DONE]" ||
firstChunkAt - streamStartedAt > 5_000
) {
throw new Error("The Homarr provider did not preserve the upstream SSE stream");
}
const malformedStreamResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...concurrencySession.headers },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "malformed stream" }],
stream: true,
}),
});
const malformedStreamText = await malformedStreamResponse.text();
if (
!malformedStreamResponse.ok ||
!malformedStreamText.includes('"type":"homarr_provider_error"') ||
malformedStreamText.includes('data: {"model":') ||
!malformedStreamText.endsWith("data: [DONE]\n\n")
) {
throw new Error(`Malformed upstream streams did not terminate safely: ${malformedStreamText}`);
}
const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
...authorSession.headers,
"http-referer": "https://attacker.example",
"x-openrouter-title": "Attacker",
"x-openrouter-metadata": "attacker",
},
body: JSON.stringify({
model: "homarr/model",
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,
models: ["attacker/model"],
provider: { order: ["attacker"] },
route: "fallback",
plugins: ["web"],
transforms: ["middle-out"],
max_tokens: 1_000_000,
max_completion_tokens: 1_000_000,
n: 100,
parallel_tool_calls: true,
extra_body: { provider: { order: ["attacker"] } },
extra_headers: { Authorization: "Bearer attacker" },
metadata: { user: "must-not-reach-openrouter" },
audio: { format: "wav", voice: "alloy" },
modalities: ["text", "audio"],
logprobs: true,
top_logprobs: 20,
prediction: { content: "attacker-controlled prediction" },
service_tier: "priority",
reasoning: { effort: "high", max_tokens: 1_000_000 },
user: "must-not-reach-openrouter",
tools: [
{ type: "function", function: { name: "board_list" } },
{ type: "openrouter:web_search", parameters: { max_results: 1000, max_uses: 1000 } },
{ type: "openrouter:web_search" },
],
}),
});
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "48") {
throw new Error("Every upstream inference must consume one Homarr provider request unit");
}
const toolCompletion = await toolResponse.json();
if (
toolCompletion.model !== "homarr/model" ||
toolCompletion.choices?.[0]?.finish_reason !== "tool_calls" ||
toolCompletion.choices?.[0]?.message?.tool_calls?.[0]?.function?.name !== "board_list"
) {
throw new Error(
`The public model alias or function tool response was not forwarded: ${JSON.stringify(toolCompletion)}`,
);
}
const streamedToolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...concurrencySession.headers },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "inspect my dashboard" }],
tools: [{ type: "function", function: { name: "board_list", parameters: { type: "object" } } }],
stream: true,
}),
});
const streamedToolText = await streamedToolResponse.text();
if (
!streamedToolResponse.ok ||
streamedToolText.includes("mock/team-selected-model") ||
!streamedToolText.includes('"model":"homarr/model"') ||
!streamedToolText.includes('"name":"board_list"') ||
!streamedToolText.includes('"arguments":"{"') ||
!streamedToolText.includes('"arguments":"}"') ||
!streamedToolText.includes('"finish_reason":"tool_calls"') ||
!streamedToolText.endsWith("data: [DONE]\n\n")
) {
throw new Error(`Streamed function tool calls were not preserved safely: ${streamedToolText}`);
}
const afterToolsUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (afterToolsUsage.used !== 2 || afterToolsUsage.remaining !== 48) {
throw new Error(`Tool request accounting is incorrect: ${JSON.stringify(afterToolsUsage)}`);
}
const upstreamFailure = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "upstream failure" }],
stream: false,
}),
});
if (upstreamFailure.status !== 503 || upstreamFailure.headers.get("x-homarr-quota-remaining") !== "47") {
throw new Error("Every request forwarded upstream must consume Homarr provider allowance");
}
const upstreamFailureBody = await upstreamFailure.text();
if (upstreamFailureBody.includes("Simulated upstream failure")) {
throw new Error("Upstream error details must not be exposed to clients");
}
const afterFailureUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (afterFailureUsage.used !== 3 || afterFailureUsage.remaining !== 47) {
throw new Error(`Forwarded upstream failure escaped quota accounting: ${JSON.stringify(afterFailureUsage)}`);
}
const visitorUsage = await request("/api/ai/usage", { headers: visitorSession.headers });
if (visitorUsage.used !== 0 || visitorUsage.remaining !== 50) {
throw new Error(`Provider allowance leaked across users: ${JSON.stringify(visitorUsage)}`);
}
await expectStatus("/api/collections/assistant_quotas/records", { headers: authorSession.headers }, 403);
await expectStatus("/api/collections/assistant_global_quota/records", { headers: authorSession.headers }, 403);
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.used !== 3 ||
["dailyLimit", "inputTokens", "outputTokens", "totalTokens", "model", "cost"].some((field) => field in authorQuota)
) {
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 }),
});
const resetUsage = await request("/api/ai/usage", { headers: authorSession.headers });
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const resetQuota = (
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
).items.find((item) => item.user === author.id);
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ used: 49 }),
});
const allowanceProbe = () =>
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...authorSession.headers },
body: JSON.stringify({
model: "homarr/model",
messages: [{ role: "user", content: "allowance probe" }],
stream: false,
}),
});
const concurrentAllowanceResponses = await Promise.all([allowanceProbe(), allowanceProbe()]);
const concurrentStatuses = concurrentAllowanceResponses.map((response) => response.status).toSorted();
const exhaustedResponse = concurrentAllowanceResponses.find((response) => response.status === 429);
if (
concurrentStatuses.join(",") !== "200,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 globalQuota = (await request("/api/collections/assistant_global_quota/records", { headers: rootHeaders }))
.items[0];
await request(`/api/collections/assistant_global_quota/records/${globalQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ day: new Date().toISOString().slice(0, 10), used: 9_999 }),
});
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
body: JSON.stringify({ used: 0 }),
});
const globalResponses = await Promise.all([
allowanceProbe(),
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json", ...visitorSession.headers },
body: JSON.stringify({ model: "homarr/model", messages: [{ role: "user", content: "global allowance probe" }] }),
}),
]);
if (
globalResponses
.map((response) => response.status)
.toSorted()
.join(",") !== "200,429"
) {
throw new Error("The shared upstream allowance was not enforced atomically across users");
}
const widget = {
$schema: "homarr-custom-widget-v2",