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:
1 parent
1611a9033b
commit
6ea7a6ff09
77 files changed
+4051
-774
No files matched your search
@@ -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,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=
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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, "aErr) {
|
||||
event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(quotaErr.Snapshot.Limit))
|
||||
event.Response.Header().Set("X-Homarr-Quota-Remaining", "0")
|
||||
event.Response.Header().Set("X-Homarr-Quota-Reset", quotaErr.Snapshot.ResetsAt.Format(time.RFC3339))
|
||||
return event.JSON(http.StatusTooManyRequests, openAIError("Your daily Homarr provider allowance is exhausted."))
|
||||
}
|
||||
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be started."))
|
||||
}
|
||||
upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey)
|
||||
upstreamRequest.Header.Set("Content-Type", "application/json")
|
||||
upstreamRequest.Header.Set("Accept", "text/event-stream, application/json")
|
||||
upstreamRequest.Header.Set("HTTP-Referer", openRouterReferer)
|
||||
upstreamRequest.Header.Set("X-OpenRouter-Title", openRouterTitle)
|
||||
upstreamRequest.Header.Set("X-OpenRouter-Metadata", "enabled")
|
||||
|
||||
upstreamResponse, err := provider.httpClient.Do(upstreamRequest)
|
||||
if err != nil {
|
||||
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached."))
|
||||
}
|
||||
defer func() { _ = upstreamResponse.Body.Close() }()
|
||||
|
||||
event.Response.Header().Set("Cache-Control", "no-store")
|
||||
event.Response.Header().Set("X-Accel-Buffering", "no")
|
||||
event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(snapshot.Limit))
|
||||
event.Response.Header().Set("X-Homarr-Quota-Remaining", strconv.Itoa(snapshot.Remaining))
|
||||
event.Response.Header().Set("X-Homarr-Quota-Reset", snapshot.ResetsAt.Format(time.RFC3339))
|
||||
if requestID := upstreamResponse.Header.Get("X-Request-Id"); requestID != "" {
|
||||
event.Response.Header().Set("X-Request-Id", requestID)
|
||||
}
|
||||
|
||||
contentType := upstreamResponse.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 {
|
||||
return event.JSON(safeUpstreamStatus(upstreamResponse.StatusCode), openAIError("The model endpoint rejected the request."))
|
||||
}
|
||||
|
||||
if requestBody.Stream {
|
||||
event.Response.Header().Set("Content-Type", contentType)
|
||||
event.Response.WriteHeader(upstreamResponse.StatusCode)
|
||||
if _, streamErr := copyAliasedSSE(flushingWriter{event.Response}, upstreamResponse.Body, maxChatResponseBytes); streamErr != nil {
|
||||
_, _ = io.WriteString(flushingWriter{event.Response}, streamErrorFrame())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
responseBody, readErr := readBoundedBody(upstreamResponse.Body, maxChatResponseBytes)
|
||||
if readErr != nil {
|
||||
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
|
||||
}
|
||||
responseBody, readErr = aliasCompletionBody(responseBody)
|
||||
if readErr != nil {
|
||||
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
|
||||
}
|
||||
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
|
||||
}
|
||||
|
||||
func streamErrorFrame() string {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": "The model endpoint returned an invalid response.",
|
||||
"type": "homarr_provider_error",
|
||||
},
|
||||
})
|
||||
return "data: " + string(payload) + "\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) acquireRequest(userID string) bool {
|
||||
provider.inFlightMutex.Lock()
|
||||
defer provider.inFlightMutex.Unlock()
|
||||
if provider.globalInFlight >= maxGlobalInFlight || provider.inFlightByUser[userID] >= maxUserInFlight {
|
||||
return false
|
||||
}
|
||||
provider.globalInFlight++
|
||||
provider.inFlightByUser[userID]++
|
||||
return true
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) releaseRequest(userID string) {
|
||||
provider.inFlightMutex.Lock()
|
||||
defer provider.inFlightMutex.Unlock()
|
||||
provider.globalInFlight--
|
||||
provider.inFlightByUser[userID]--
|
||||
if provider.inFlightByUser[userID] == 0 {
|
||||
delete(provider.inFlightByUser, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func safeUpstreamStatus(status int) int {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func sanitizeProviderPayload(payload map[string]any, upstreamModelID string) error {
|
||||
for _, field := range clientControlledRoutingFields {
|
||||
delete(payload, field)
|
||||
}
|
||||
for _, field := range clientControlledCostFields {
|
||||
delete(payload, field)
|
||||
}
|
||||
delete(payload, "max_completion_tokens")
|
||||
delete(payload, "max_tokens")
|
||||
delete(payload, "metadata")
|
||||
delete(payload, "user")
|
||||
if reasoning, ok := payload["reasoning"].(map[string]any); ok {
|
||||
delete(reasoning, "max_tokens")
|
||||
}
|
||||
|
||||
if rawTools, exists := payload["tools"]; exists {
|
||||
tools, ok := rawTools.([]any)
|
||||
if !ok {
|
||||
return errInvalidTools
|
||||
}
|
||||
sanitizedTools := make([]any, 0, len(tools))
|
||||
webSearchAdded := false
|
||||
for _, rawTool := range tools {
|
||||
tool, ok := rawTool.(map[string]any)
|
||||
if !ok {
|
||||
return errUnsupportedTool
|
||||
}
|
||||
switch tool["type"] {
|
||||
case "function":
|
||||
sanitizedTools = append(sanitizedTools, tool)
|
||||
case "openrouter:web_search":
|
||||
if !webSearchAdded {
|
||||
sanitizedTools = append(sanitizedTools, map[string]any{
|
||||
"type": "openrouter:web_search",
|
||||
"parameters": map[string]any{
|
||||
"max_results": maxWebSearchResults,
|
||||
"max_uses": maxWebSearchUses,
|
||||
},
|
||||
})
|
||||
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 "aExceededError{Snapshot: current}
|
||||
}
|
||||
current.Used++
|
||||
current.Remaining = max(0, current.Limit-current.Used)
|
||||
quota.Set("used", current.Used)
|
||||
globalQuota.Set("used", globalQuota.GetInt("used")+1)
|
||||
snapshot = current
|
||||
if err := txApp.Save(globalQuota); err != nil {
|
||||
return err
|
||||
}
|
||||
return txApp.Save(quota)
|
||||
})
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func openAIError(message string) map[string]any {
|
||||
return map[string]any{"error": map[string]any{"message": message, "type": "homarr_provider_error"}}
|
||||
}
|
||||
|
||||
type flushingWriter struct {
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (writer flushingWriter) Write(chunk []byte) (int, error) {
|
||||
written, err := writer.ResponseWriter.Write(chunk)
|
||||
if flusher, ok := writer.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user