diff --git a/apps/workshop/.env.example b/apps/workshop/.env.example index a67c02ff7..9f25ad9e0 100644 --- a/apps/workshop/.env.example +++ b/apps/workshop/.env.example @@ -7,6 +7,11 @@ HOMARR_WEBSITE_URL=http://127.0.0.1:3003 WORKSHOP_API_URL=http://127.0.0.1:8090 WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop +# Optional Homarr provider. The upstream key stays only in the Workshop server. +# OPENROUTER_API_KEY= +HOMARR_AI_DAILY_REQUEST_LIMIT=50 +# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 + # Optional local GitHub OAuth application. # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= diff --git a/apps/workshop/.env.staging.example b/apps/workshop/.env.staging.example index c783f53bc..e5908b8e4 100644 --- a/apps/workshop/.env.staging.example +++ b/apps/workshop/.env.staging.example @@ -7,6 +7,11 @@ WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev PB_ALLOWED_ORIGINS=* +# Set this secret in the deployment environment to enable the Homarr provider. +# OPENROUTER_API_KEY= +HOMARR_AI_DAILY_REQUEST_LIMIT=50 +# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 + # Optional GitHub OAuth application for the preview hostname. # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= diff --git a/apps/workshop/Dockerfile b/apps/workshop/Dockerfile index e206e3ef5..e0605fd79 100644 --- a/apps/workshop/Dockerfile +++ b/apps/workshop/Dockerfile @@ -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 diff --git a/apps/workshop/docker-compose.staging.yml b/apps/workshop/docker-compose.staging.yml index a4604e4c2..4de0d9307 100644 --- a/apps/workshop/docker-compose.staging.yml +++ b/apps/workshop/docker-compose.staging.yml @@ -13,6 +13,9 @@ services: WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50} + HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} ports: - "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090" volumes: diff --git a/apps/workshop/docker-compose.yml b/apps/workshop/docker-compose.yml index 22c1be2e6..41e95dc95 100644 --- a/apps/workshop/docker-compose.yml +++ b/apps/workshop/docker-compose.yml @@ -1,6 +1,18 @@ name: homarr-workshop services: + assistant-upstream: + profiles: + - test + image: node:24.18.0-alpine + command: ["node", "/mock/openrouter-mock.mjs"] + environment: + PORT: 18091 + volumes: + - ./tests/openrouter-mock.mjs:/mock/openrouter-mock.mjs:ro + security_opt: + - no-new-privileges:true + workshop: build: context: ../.. @@ -15,6 +27,9 @@ services: WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50} + HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003} ports: - "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}" @@ -44,6 +59,9 @@ services: WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50} + HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1} + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003} ports: - "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}" diff --git a/apps/workshop/go.mod b/apps/workshop/go.mod new file mode 100644 index 000000000..67c5f262c --- /dev/null +++ b/apps/workshop/go.mod @@ -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 +) diff --git a/apps/workshop/go.sum b/apps/workshop/go.sum new file mode 100644 index 000000000..1175daf91 --- /dev/null +++ b/apps/workshop/go.sum @@ -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= diff --git a/apps/workshop/homarr_provider.go b/apps/workshop/homarr_provider.go new file mode 100644 index 000000000..2d1620583 --- /dev/null +++ b/apps/workshop/homarr_provider.go @@ -0,0 +1,452 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/apis" + "github.com/pocketbase/pocketbase/core" +) + +const ( + homarrProviderModelID = "homarr/deepseek-v4-flash-latest" + openRouterModelID = "~deepseek/deepseek-v4-flash-latest" + defaultDailyLimit = 50 + maxChatRequestBytes = 8 << 20 + maxUpstreamErrorBytes = 1 << 20 + openRouterDefaultURL = "https://openrouter.ai/api/v1" + providerRequestTimeout = 5 * time.Minute +) + +type homarrProvider struct { + apiKey string + baseURL string + dailyLimit int + httpClient *http.Client + now func() time.Time +} + +type quotaSnapshot struct { + Limit int `json:"limit"` + Used int `json:"used"` + Remaining int `json:"remaining"` + ResetsAt time.Time `json:"resetsAt"` +} + +type requestUsage struct { + InputTokens int + OutputTokens int + TotalTokens int + Cost float64 +} + +type providerRequest struct { + Model string `json:"model"` + Messages []providerMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type providerMessage struct { + Role string `json:"role"` +} + +type quotaExceededError struct { + Snapshot quotaSnapshot +} + +func (err *quotaExceededError) Error() string { return "daily Homarr provider allowance exhausted" } + +func registerHomarrProvider(app *pocketbase.PocketBase) { + provider, err := newHomarrProviderFromEnv() + if err != nil { + panic(err) + } + + app.OnServe().BindFunc(func(event *core.ServeEvent) error { + group := event.Router.Group("/api/ai") + group.GET("/v1/models", provider.models) + group.GET("/usage", provider.usage).Bind(apis.RequireAuth("users")) + group.POST("/v1/chat/completions", provider.chat). + Bind(apis.RequireAuth("users"), apis.BodyLimit(maxChatRequestBytes)). + Unbind(apis.DefaultActivityLoggerMiddlewareId) + return event.Next() + }) +} + +func newHomarrProviderFromEnv() (*homarrProvider, error) { + baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("HOMARR_AI_OPENROUTER_BASE_URL")), "/") + if baseURL == "" { + baseURL = openRouterDefaultURL + } + parsedURL, err := url.Parse(baseURL) + if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") { + return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must be an absolute HTTP(S) URL") + } + + dailyLimit := defaultDailyLimit + if rawLimit := strings.TrimSpace(os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")); rawLimit != "" { + value, parseErr := strconv.Atoi(rawLimit) + if parseErr != nil || value < 1 || value > 100_000 { + return nil, errors.New("HOMARR_AI_DAILY_REQUEST_LIMIT must be an integer between 1 and 100000") + } + dailyLimit = value + } + + return &homarrProvider{ + apiKey: strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")), + baseURL: baseURL, + dailyLimit: dailyLimit, + httpClient: &http.Client{ + Timeout: providerRequestTimeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + now: time.Now, + }, nil +} + +func (provider *homarrProvider) models(event *core.RequestEvent) error { + return event.JSON(http.StatusOK, map[string]any{ + "object": "list", + "data": []map[string]any{{ + "id": homarrProviderModelID, + "object": "model", + "name": "DeepSeek V4 Flash Latest", + "description": "Fast agentic model provided by the community-funded Homarr provider.", + "owned_by": "homarr", + "context_length": 1_048_576, + "supported_parameters": []string{ + "tools", "tool_choice", "reasoning", "include_reasoning", "structured_outputs", + }, + "architecture": map[string]any{ + "input_modalities": []string{"text", "image"}, + "output_modalities": []string{"text"}, + }, + }}, + }) +} + +func (provider *homarrProvider) usage(event *core.RequestEvent) error { + snapshot, err := provider.quota(event.App, event.Auth, 0) + if err != nil { + return event.InternalServerError("The Homarr provider allowance could not be loaded.", nil) + } + return event.JSON(http.StatusOK, snapshot) +} + +func (provider *homarrProvider) chat(event *core.RequestEvent) error { + if provider.apiKey == "" { + return event.JSON(http.StatusServiceUnavailable, openAIError("The Homarr provider is not configured.")) + } + + body, err := io.ReadAll(io.LimitReader(event.Request.Body, maxChatRequestBytes+1)) + if err != nil || len(body) > maxChatRequestBytes { + return event.JSON(http.StatusRequestEntityTooLarge, openAIError("The request is too large.")) + } + + var requestBody providerRequest + if err := json.Unmarshal(body, &requestBody); err != nil || len(requestBody.Messages) == 0 { + return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON.")) + } + if requestBody.Model != homarrProviderModelID { + return event.JSON(http.StatusBadRequest, openAIError("The Homarr provider offers only DeepSeek V4 Flash Latest.")) + } + + requestUnits := countRequestUnits(requestBody.Messages) + snapshot, err := provider.quota(event.App, event.Auth, requestUnits) + if err != nil { + var quotaErr *quotaExceededError + if errors.As(err, "aErr) { + event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(quotaErr.Snapshot.Limit)) + event.Response.Header().Set("X-Homarr-Quota-Remaining", "0") + event.Response.Header().Set("X-Homarr-Quota-Reset", quotaErr.Snapshot.ResetsAt.Format(time.RFC3339)) + return event.JSON(http.StatusTooManyRequests, openAIError("Your daily Homarr provider allowance is exhausted.")) + } + return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider allowance could not be reserved.")) + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON.")) + } + payload["model"] = openRouterModelID + payload["usage"] = map[string]any{"include": true} + upstreamBody, err := json.Marshal(payload) + if err != nil { + return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared.")) + } + + privateRequest, publicActivity, err := provider.startActivity(event.App, event.Auth, requestUnits) + if err != nil { + return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be started.")) + } + startedAt := provider.now().UTC() + upstreamRequest, err := http.NewRequestWithContext( + event.Request.Context(), + http.MethodPost, + provider.baseURL+"/chat/completions", + bytes.NewReader(upstreamBody), + ) + if err != nil { + provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt) + return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared.")) + } + upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey) + upstreamRequest.Header.Set("Content-Type", "application/json") + upstreamRequest.Header.Set("Accept", "text/event-stream, application/json") + upstreamRequest.Header.Set("HTTP-Referer", "https://homarr.dev") + upstreamRequest.Header.Set("X-Title", "Homarr Provider") + upstreamRequest.Header.Set("X-OpenRouter-Metadata", "enabled") + + upstreamResponse, err := provider.httpClient.Do(upstreamRequest) + if err != nil { + provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt) + return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached.")) + } + defer upstreamResponse.Body.Close() + + event.Response.Header().Set("Cache-Control", "no-store") + event.Response.Header().Set("X-Accel-Buffering", "no") + event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(snapshot.Limit)) + event.Response.Header().Set("X-Homarr-Quota-Remaining", strconv.Itoa(snapshot.Remaining)) + event.Response.Header().Set("X-Homarr-Quota-Reset", snapshot.ResetsAt.Format(time.RFC3339)) + if requestID := upstreamResponse.Header.Get("X-Request-Id"); requestID != "" { + event.Response.Header().Set("X-Request-Id", requestID) + } + + contentType := upstreamResponse.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/json" + } + if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 { + errorBody, _ := io.ReadAll(io.LimitReader(upstreamResponse.Body, maxUpstreamErrorBytes)) + provider.finishActivity(event.App, privateRequest, publicActivity, "failed", requestUsage{}, startedAt) + return event.Blob(upstreamResponse.StatusCode, contentType, errorBody) + } + + if requestBody.Stream { + capture := newSSEUsageCapture() + event.Response.Header().Set("Content-Type", contentType) + event.Response.WriteHeader(upstreamResponse.StatusCode) + _, copyErr := io.Copy(flushingWriter{event.Response}, io.TeeReader(upstreamResponse.Body, capture)) + provider.finishActivity(event.App, privateRequest, publicActivity, statusForError(copyErr), capture.Usage(), startedAt) + return nil + } + + responseBody, readErr := io.ReadAll(upstreamResponse.Body) + usage := usageFromJSON(responseBody) + provider.finishActivity(event.App, privateRequest, publicActivity, statusForError(readErr), usage, startedAt) + if readErr != nil { + return readErr + } + return event.Blob(upstreamResponse.StatusCode, contentType, responseBody) +} + +func countRequestUnits(messages []providerMessage) int { + units := 1 + for index := len(messages) - 1; index >= 0 && messages[index].Role == "tool"; index-- { + units++ + } + return units +} + +func (provider *homarrProvider) quota(app core.App, user *core.Record, reserve int) (quotaSnapshot, error) { + now := provider.now().UTC() + today := now.Format(time.DateOnly) + resetsAt := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + var snapshot quotaSnapshot + err := app.RunInTransaction(func(txApp core.App) error { + quota, err := txApp.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id}) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if quota == nil { + collection, err := txApp.FindCollectionByNameOrId("assistant_quotas") + if err != nil { + return err + } + quota = core.NewRecord(collection) + quota.Set("user", user.Id) + quota.Set("dailyLimit", provider.dailyLimit) + } + if quota.GetString("day") != today { + quota.Set("day", today) + quota.Set("used", 0) + quota.Set("inputTokens", 0) + quota.Set("outputTokens", 0) + quota.Set("totalTokens", 0) + } + limit := quota.GetInt("dailyLimit") + used := quota.GetInt("used") + snapshot = quotaSnapshot{Limit: limit, Used: used, Remaining: max(0, limit-used), ResetsAt: resetsAt} + if reserve > snapshot.Remaining { + return "aExceededError{Snapshot: snapshot} + } + if reserve > 0 { + used += reserve + quota.Set("used", used) + snapshot.Used = used + snapshot.Remaining = max(0, limit-used) + } + return txApp.Save(quota) + }) + return snapshot, err +} + +func (provider *homarrProvider) startActivity(app core.App, user *core.Record, requestUnits int) (*core.Record, *core.Record, error) { + privateCollection, err := app.FindCollectionByNameOrId("assistant_requests") + if err != nil { + return nil, nil, err + } + publicCollection, err := app.FindCollectionByNameOrId("assistant_activity") + if err != nil { + return nil, nil, err + } + privateRequest := core.NewRecord(privateCollection) + publicActivity := core.NewRecord(publicCollection) + for _, record := range []*core.Record{privateRequest, publicActivity} { + record.Set("status", "processing") + record.Set("model", homarrProviderModelID) + record.Set("requestUnits", requestUnits) + } + privateRequest.Set("user", user.Id) + privateRequest.Set("publicActivity", publicActivity.Id) + + err = app.RunInTransaction(func(txApp core.App) error { + if err := txApp.Save(publicActivity); err != nil { + return err + } + privateRequest.Set("publicActivity", publicActivity.Id) + return txApp.Save(privateRequest) + }) + return privateRequest, publicActivity, err +} + +func (provider *homarrProvider) finishActivity( + app core.App, + privateRequest *core.Record, + publicActivity *core.Record, + status string, + usage requestUsage, + startedAt time.Time, +) { + durationMs := max(0, int(provider.now().UTC().Sub(startedAt).Milliseconds())) + _ = app.RunInTransaction(func(txApp core.App) error { + for _, record := range []*core.Record{privateRequest, publicActivity} { + record.Set("status", status) + record.Set("inputTokens", usage.InputTokens) + record.Set("outputTokens", usage.OutputTokens) + record.Set("totalTokens", usage.TotalTokens) + record.Set("durationMs", durationMs) + record.Set("cost", usage.Cost) + if err := txApp.Save(record); err != nil { + return err + } + } + quota, err := txApp.FindFirstRecordByFilter( + "assistant_quotas", + "user = {:user}", + dbx.Params{"user": privateRequest.GetString("user")}, + ) + if err != nil { + return err + } + quota.Set("inputTokens", quota.GetInt("inputTokens")+usage.InputTokens) + quota.Set("outputTokens", quota.GetInt("outputTokens")+usage.OutputTokens) + quota.Set("totalTokens", quota.GetInt("totalTokens")+usage.TotalTokens) + return txApp.Save(quota) + }) +} + +func statusForError(err error) string { + if err != nil { + return "failed" + } + return "completed" +} + +func openAIError(message string) map[string]any { + return map[string]any{"error": map[string]any{"message": message, "type": "homarr_provider_error"}} +} + +type flushingWriter struct { + http.ResponseWriter +} + +func (writer flushingWriter) Write(chunk []byte) (int, error) { + written, err := writer.ResponseWriter.Write(chunk) + if flusher, ok := writer.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } + return written, err +} + +type sseUsageCapture struct { + pending []byte + usage requestUsage +} + +func newSSEUsageCapture() *sseUsageCapture { return &sseUsageCapture{} } + +func (capture *sseUsageCapture) Write(chunk []byte) (int, error) { + capture.pending = append(capture.pending, chunk...) + for { + end := bytes.IndexByte(capture.pending, '\n') + if end < 0 { + break + } + line := bytes.TrimSuffix(capture.pending[:end], []byte{'\r'}) + if bytes.HasPrefix(line, []byte("data: ")) { + capture.mergeUsage(bytes.TrimPrefix(line, []byte("data: "))) + } + capture.pending = capture.pending[end+1:] + } + if len(capture.pending) > maxUpstreamErrorBytes { + capture.pending = capture.pending[len(capture.pending)-maxUpstreamErrorBytes:] + } + return len(chunk), nil +} + +func (capture *sseUsageCapture) mergeUsage(data []byte) { + if bytes.Equal(data, []byte("[DONE]")) || !bytes.Contains(data, []byte(`"usage"`)) { + return + } + usage := usageFromJSON(data) + if usage.InputTokens != 0 || usage.OutputTokens != 0 || usage.TotalTokens != 0 || usage.Cost != 0 { + capture.usage = usage + } +} + +func (capture *sseUsageCapture) Usage() requestUsage { return capture.usage } + +func usageFromJSON(data []byte) requestUsage { + var payload struct { + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Cost float64 `json:"cost"` + } `json:"usage"` + } + if json.Unmarshal(data, &payload) != nil { + return requestUsage{} + } + return requestUsage{ + InputTokens: payload.Usage.PromptTokens, + OutputTokens: payload.Usage.CompletionTokens, + TotalTokens: payload.Usage.TotalTokens, + Cost: payload.Usage.Cost, + } +} diff --git a/apps/workshop/homarr_provider_test.go b/apps/workshop/homarr_provider_test.go new file mode 100644 index 000000000..8c3b9cd07 --- /dev/null +++ b/apps/workshop/homarr_provider_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "math" + "testing" +) + +func TestCountRequestUnits(t *testing.T) { + tests := []struct { + name string + messages []providerMessage + want int + }{ + {name: "user request", messages: []providerMessage{{Role: "user"}}, want: 1}, + { + name: "parallel tool results", + messages: []providerMessage{ + {Role: "user"}, + {Role: "assistant"}, + {Role: "tool"}, + {Role: "tool"}, + }, + want: 3, + }, + { + name: "historical tools are not charged again", + messages: []providerMessage{ + {Role: "user"}, + {Role: "assistant"}, + {Role: "tool"}, + {Role: "assistant"}, + {Role: "user"}, + }, + want: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := countRequestUnits(test.messages); got != test.want { + t.Fatalf("countRequestUnits() = %d, want %d", got, test.want) + } + }) + } +} + +func TestSSEUsageCaptureHandlesFragmentedEvents(t *testing.T) { + capture := newSSEUsageCapture() + chunks := []string{ + "data: {\"id\":\"gen-test\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n", + "data: {\"usage\":{\"prompt_tokens\":12,\"completion_", + "tokens\":7,\"total_tokens\":19,\"cost\":0.00041}}\n\n", + "data: [DONE]\n\n", + } + for _, chunk := range chunks { + if _, err := capture.Write([]byte(chunk)); err != nil { + t.Fatal(err) + } + } + + usage := capture.Usage() + if usage.InputTokens != 12 || usage.OutputTokens != 7 || usage.TotalTokens != 19 { + t.Fatalf("unexpected usage: %+v", usage) + } + if math.Abs(usage.Cost-0.00041) > 0.0000001 { + t.Fatalf("unexpected cost: %f", usage.Cost) + } +} + +func TestProviderEnvironment(t *testing.T) { + t.Setenv("OPENROUTER_API_KEY", "test-key") + t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", "https://router.example/v1/") + t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "75") + provider, err := newHomarrProviderFromEnv() + if err != nil { + t.Fatal(err) + } + if provider.apiKey != "test-key" || provider.baseURL != "https://router.example/v1" || provider.dailyLimit != 75 { + t.Fatalf("unexpected provider config: %#v", provider) + } + + t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "0") + if _, err := newHomarrProviderFromEnv(); err == nil { + t.Fatal("expected invalid daily limit to fail") + } +} diff --git a/apps/workshop/main.go b/apps/workshop/main.go new file mode 100644 index 000000000..426ab0832 --- /dev/null +++ b/apps/workshop/main.go @@ -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") +} diff --git a/apps/workshop/pb_migrations/1786500000_homarr_provider.js b/apps/workshop/pb_migrations/1786500000_homarr_provider.js new file mode 100644 index 000000000..8a83df8c7 --- /dev/null +++ b/apps/workshop/pb_migrations/1786500000_homarr_provider.js @@ -0,0 +1,98 @@ +/// + +const requestFields = () => [ + { type: "select", name: "status", required: true, maxSelect: 1, values: ["processing", "completed", "failed"] }, + { type: "text", name: "model", required: true, min: 1, max: 128 }, + { type: "number", name: "requestUnits", required: true, onlyInt: true, min: 1, max: 1000 }, + { type: "number", name: "inputTokens", onlyInt: true, min: 0 }, + { type: "number", name: "outputTokens", onlyInt: true, min: 0 }, + { type: "number", name: "totalTokens", onlyInt: true, min: 0 }, + { type: "number", name: "durationMs", onlyInt: true, min: 0 }, + { type: "number", name: "cost", min: 0 }, + { type: "autodate", name: "created", onCreate: true }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, +]; + +migrate( + (app) => { + const users = app.findCollectionByNameOrId("users"); + const activity = new Collection({ + type: "base", + name: "assistant_activity", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields: requestFields(), + }); + activity.addIndex("idx_assistant_activity_created", false, "created", ""); + app.save(activity); + + const requests = new Collection({ + type: "base", + name: "assistant_requests", + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + ...requestFields(), + { + type: "relation", + name: "user", + required: true, + maxSelect: 1, + collectionId: users.id, + cascadeDelete: true, + }, + { + type: "relation", + name: "publicActivity", + required: true, + maxSelect: 1, + collectionId: activity.id, + cascadeDelete: true, + }, + ], + }); + requests.addIndex("idx_assistant_requests_user_created", false, "user, created", ""); + app.save(requests); + + const quotas = new Collection({ + type: "base", + name: "assistant_quotas", + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + type: "relation", + name: "user", + required: true, + maxSelect: 1, + collectionId: users.id, + cascadeDelete: true, + }, + { type: "number", name: "dailyLimit", required: true, onlyInt: true, min: 0, max: 100000 }, + { type: "text", name: "day", required: true, min: 10, max: 10 }, + { type: "number", name: "used", onlyInt: true, min: 0 }, + { type: "number", name: "inputTokens", onlyInt: true, min: 0 }, + { type: "number", name: "outputTokens", onlyInt: true, min: 0 }, + { type: "number", name: "totalTokens", onlyInt: true, min: 0 }, + { type: "autodate", name: "created", onCreate: true }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + }); + quotas.addIndex("idx_assistant_quotas_user", true, "user", ""); + app.save(quotas); + }, + (app) => { + app.delete(app.findCollectionByNameOrId("assistant_quotas")); + app.delete(app.findCollectionByNameOrId("assistant_requests")); + app.delete(app.findCollectionByNameOrId("assistant_activity")); + }, +); diff --git a/apps/workshop/test.sh b/apps/workshop/test.sh index 6b0f01461..cb4286e48 100644 --- a/apps/workshop/test.sh +++ b/apps/workshop/test.sh @@ -3,12 +3,15 @@ set -eu WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090} WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$" +export HOMARR_AI_OPENROUTER_BASE_URL=http://assistant-upstream:18091/api/v1 +export OPENROUTER_API_KEY=workshop-test-openrouter-key +docker build --target pocketbase-test -f apps/workshop/Dockerfile . node apps/workshop/tests/workshop-contracts.mjs node apps/workshop/tests/workshop-migration.mjs cleanup() { - PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ + PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose --profile test -p "$WORKSHOP_TEST_PROJECT" \ -f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true } trap cleanup EXIT @@ -16,7 +19,7 @@ trap cleanup EXIT HOMARR_WEBSITE_URL=https://preview.example.invalid \ WORKSHOP_API_URL=https://api.preview.example.invalid \ WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \ - PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \ + PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose --profile test -p "$WORKSHOP_TEST_PROJECT" \ -f apps/workshop/docker-compose.yml up --build -d for attempt in $(seq 1 60); do diff --git a/apps/workshop/tests/openrouter-mock.mjs b/apps/workshop/tests/openrouter-mock.mjs new file mode 100644 index 000000000..c6a52eff0 --- /dev/null +++ b/apps/workshop/tests/openrouter-mock.mjs @@ -0,0 +1,71 @@ +import { createServer } from "node:http"; + +const port = Number(process.env.PORT ?? 18091); + +const json = (response, status, body) => { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +}; + +const server = createServer(async (request, response) => { + if (request.method === "GET" && request.url === "/health") { + json(response, 200, { ok: true }); + return; + } + if (request.method !== "POST" || request.url !== "/api/v1/chat/completions") { + json(response, 404, { error: { message: "Not found" } }); + return; + } + if (request.headers.authorization !== "Bearer workshop-test-openrouter-key") { + json(response, 401, { error: { message: "Invalid upstream credentials" } }); + return; + } + + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (body.model !== "~deepseek/deepseek-v4-flash-latest") { + json(response, 400, { error: { message: "Unexpected upstream model" } }); + return; + } + + const completion = { + id: "gen-workshop-test", + object: "chat.completion", + model: body.model, + choices: [{ index: 0, message: { role: "assistant", content: "Hello from the Homarr provider" }, finish_reason: "stop" }], + usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19, cost: 0.00041 }, + }; + if (!body.stream) { + json(response, 200, completion); + return; + } + + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + "x-request-id": "mock-openrouter-request", + }); + response.write( + `data: ${JSON.stringify({ + id: completion.id, + object: "chat.completion.chunk", + model: body.model, + choices: [{ index: 0, delta: { role: "assistant", content: "Hello from " }, finish_reason: null }], + })}\n\n`, + ); + setTimeout(() => { + response.write( + `data: ${JSON.stringify({ + id: completion.id, + object: "chat.completion.chunk", + model: body.model, + choices: [{ index: 0, delta: { content: "the Homarr provider" }, finish_reason: "stop" }], + usage: completion.usage, + })}\n\n`, + ); + response.end("data: [DONE]\n\n"); + }, 150); +}); + +server.listen(port, "0.0.0.0"); diff --git a/apps/workshop/tests/workshop-contracts.mjs b/apps/workshop/tests/workshop-contracts.mjs index 3b72bb541..ef6d53369 100644 --- a/apps/workshop/tests/workshop-contracts.mjs +++ b/apps/workshop/tests/workshop-contracts.mjs @@ -73,6 +73,34 @@ const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_wi for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) { if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`); } + +const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js"); +for (const required of [ + 'name: "assistant_quotas"', + 'name: "assistant_requests"', + 'name: "assistant_activity"', + 'listRule: ""', + "idx_assistant_quotas_user", +]) { + if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`); +} +if (/assistant_activity[\s\S]*name: "user"/u.test(providerMigration.split("const requests")[0])) { + throw new Error("The public assistant activity collection must not contain user identity"); +} + +const providerBackend = await read("apps/workshop/homarr_provider.go"); +for (const required of [ + '"homarr/deepseek-v4-flash-latest"', + 'apis.RequireAuth("users")', + "DefaultActivityLoggerMiddlewareId", + 'os.Getenv("OPENROUTER_API_KEY")', + 'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")', +]) { + if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`); +} +if (/logger\(\)|\.Logger\(/u.test(providerBackend)) { + throw new Error("The Homarr provider must not log request content or user tokens"); +} for (const required of [ "workshop_migration_state", "addedUserFields", diff --git a/apps/workshop/tests/workshop.integration.mjs b/apps/workshop/tests/workshop.integration.mjs index 8dab3d099..6e6e8b029 100644 --- a/apps/workshop/tests/workshop.integration.mjs +++ b/apps/workshop/tests/workshop.integration.mjs @@ -1,5 +1,34 @@ const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090"; +const createSseReader = (body) => { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + return { + cancel: () => reader.cancel(), + async next() { + while (true) { + const boundary = buffer.indexOf("\n\n"); + if (boundary >= 0) { + const block = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const result = { event: "message", id: "", data: "" }; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) result.event = line.slice(6).trim(); + if (line.startsWith("id:")) result.id = line.slice(3).trim(); + if (line.startsWith("data:")) result.data += line.slice(5).trim(); + } + if (result.data || result.id) return result; + continue; + } + const chunk = await reader.read(); + if (chunk.done) throw new Error("PocketBase realtime stream ended unexpectedly"); + buffer += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n"); + } + }, + }; +}; + const request = async (path, init = {}) => { const response = await fetch(`${baseUrl}${path}`, { ...init, @@ -35,6 +64,9 @@ for (const required of [ "reports", "workshop_listings", "workshop_report_summaries", + "assistant_quotas", + "assistant_requests", + "assistant_activity", ]) { if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`); } @@ -100,6 +132,182 @@ const visitorPassword = "WorkshopVisitor123!"; const visitor = await createUser("widget-visitor@example.invalid", "widget-visitor", visitorPassword); const visitorSession = await signIn(visitor.email, visitorPassword); +const modelList = await request("/api/ai/v1/models"); +if ( + modelList.object !== "list" || + modelList.data.length !== 1 || + modelList.data[0].id !== "homarr/deepseek-v4-flash-latest" +) { + throw new Error("The Homarr provider must advertise exactly one model"); +} +await expectStatus("/api/ai/usage", {}, 401); + +const initialUsage = await request("/api/ai/usage", { headers: authorSession.headers }); +const initialReset = new Date(initialUsage.resetsAt); +if ( + initialUsage.limit !== 50 || + initialUsage.used !== 0 || + initialUsage.remaining !== 50 || + initialReset.getUTCHours() !== 0 || + initialReset <= new Date() +) { + throw new Error(`Unexpected initial Homarr provider allowance: ${JSON.stringify(initialUsage)}`); +} + +await expectStatus( + "/api/ai/v1/chat/completions", + { + method: "POST", + headers: authorSession.headers, + body: JSON.stringify({ model: "another-model", messages: [{ role: "user", content: "hello" }] }), + }, + 400, +); + +const realtimeResponse = await fetch(`${baseUrl}/api/realtime`); +if (!realtimeResponse.ok || !realtimeResponse.body) throw new Error("PocketBase realtime connection failed"); +const realtime = createSseReader(realtimeResponse.body); +const connectedEvent = await realtime.next(); +if (connectedEvent.event !== "PB_CONNECT" || !connectedEvent.id) { + throw new Error("PocketBase realtime did not provide a client ID"); +} +await request("/api/realtime", { + method: "POST", + body: JSON.stringify({ clientId: connectedEvent.id, subscriptions: ["assistant_activity/*"] }), +}); +const activityEvent = (async () => { + while (true) { + const event = await realtime.next(); + const payload = JSON.parse(event.data); + if (payload.action === "create") return payload.record; + } +})(); + +const streamStartedAt = performance.now(); +const streamResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", ...authorSession.headers }, + body: JSON.stringify({ + model: "homarr/deepseek-v4-flash-latest", + messages: [{ role: "user", content: "hello" }], + stream: true, + }), +}); +if (!streamResponse.ok || streamResponse.headers.get("x-homarr-quota-remaining") !== "49") { + throw new Error(`Streaming provider request failed: ${streamResponse.status}`); +} +const streamReader = streamResponse.body.getReader(); +const firstChunk = await streamReader.read(); +const firstChunkAt = performance.now(); +let streamText = new TextDecoder().decode(firstChunk.value ?? new Uint8Array()); +while (true) { + const chunk = await streamReader.read(); + if (chunk.done) break; + streamText += new TextDecoder().decode(chunk.value); +} +const streamFinishedAt = performance.now(); +if ( + firstChunk.done || + !streamText.includes("Hello from ") || + !streamText.includes("the Homarr provider") || + streamFinishedAt - firstChunkAt < 50 || + firstChunkAt - streamStartedAt > 5_000 +) { + throw new Error("The Homarr provider did not preserve the upstream SSE stream"); +} +const liveActivity = await Promise.race([ + activityEvent, + new Promise((_, reject) => setTimeout(() => reject(new Error("Assistant activity realtime event timed out")), 2_000)), +]); +await realtime.cancel(); +if (liveActivity.model !== "homarr/deepseek-v4-flash-latest" || liveActivity.requestUnits !== 1) { + throw new Error("The public assistant activity event is invalid"); +} + +const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", ...authorSession.headers }, + body: JSON.stringify({ + model: "homarr/deepseek-v4-flash-latest", + messages: [ + { role: "user", content: "inspect my dashboard" }, + { role: "assistant", content: null, tool_calls: [{ id: "one" }, { id: "two" }] }, + { role: "tool", tool_call_id: "one", content: "{}" }, + { role: "tool", tool_call_id: "two", content: "{}" }, + ], + stream: false, + }), +}); +if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "46") { + throw new Error("Tool results must each consume one Homarr provider request unit"); +} + +const afterToolsUsage = await request("/api/ai/usage", { headers: authorSession.headers }); +if (afterToolsUsage.used !== 4 || afterToolsUsage.remaining !== 46) { + throw new Error(`Tool request accounting is incorrect: ${JSON.stringify(afterToolsUsage)}`); +} + +const publicActivities = await request("/api/collections/assistant_activity/records?sort=-created&perPage=10"); +if ( + publicActivities.items.length < 2 || + publicActivities.items.some((item) => "user" in item) || + !publicActivities.items.some( + (item) => item.status === "completed" && item.inputTokens === 12 && item.outputTokens === 7, + ) +) { + throw new Error("Public assistant activity must be anonymous and include completed token totals"); +} +await expectStatus("/api/collections/assistant_requests/records", { headers: authorSession.headers }, 403); +await expectStatus("/api/collections/assistant_quotas/records", { headers: authorSession.headers }, 403); + +const privateRequests = await request("/api/collections/assistant_requests/records?perPage=20", { + headers: rootHeaders, +}); +if ( + privateRequests.items.length < 2 || + privateRequests.items.some((item) => item.user !== author.id) || + privateRequests.items.some((item) => "messages" in item || "prompt" in item || "body" in item) +) { + throw new Error("Private provider accounting must identify the user without storing request content"); +} +const quotas = await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders }); +const authorQuota = quotas.items.find((item) => item.user === author.id); +if (!authorQuota || authorQuota.dailyLimit !== 50 || authorQuota.used !== 4 || authorQuota.totalTokens !== 38) { + throw new Error(`Private provider quota is incorrect: ${JSON.stringify(authorQuota)}`); +} + +await request(`/api/collections/assistant_quotas/records/${authorQuota.id}`, { + method: "PATCH", + headers: rootHeaders, + body: JSON.stringify({ day: "2000-01-01", used: 49, dailyLimit: 2 }), +}); +const resetUsage = await request("/api/ai/usage", { headers: authorSession.headers }); +if (resetUsage.limit !== 2 || resetUsage.used !== 0 || resetUsage.remaining !== 2) { + throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`); +} + +const allowanceProbe = () => + fetch(`${baseUrl}/api/ai/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json", ...authorSession.headers }, + body: JSON.stringify({ + model: "homarr/deepseek-v4-flash-latest", + messages: [{ role: "user", content: "allowance probe" }], + stream: false, + }), + }); +if (!(await allowanceProbe()).ok || !(await allowanceProbe()).ok) { + throw new Error("The per-user allowance rejected a permitted request"); +} +const exhaustedResponse = await allowanceProbe(); +if ( + exhaustedResponse.status !== 429 || + exhaustedResponse.headers.get("x-homarr-quota-remaining") !== "0" || + !exhaustedResponse.headers.get("x-homarr-quota-reset") +) { + throw new Error("The per-user allowance was not enforced atomically"); +} + const widget = { $schema: "homarr-custom-widget-v2", name: "Workshop runtime probe",