Add Homarr provider proxy foundation
This commit is contained in:
1 parent
9143c15454
commit
bc59355eab
15 files changed
+1258
-2
No files matched your search
@@ -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=
|
||||
@@ -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=
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
var hooksDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(&hooksDir, "hooksDir", "", "the directory with the JS app hooks")
|
||||
|
||||
var hooksWatch bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(&hooksWatch, "hooksWatch", true, "auto restart on JS hook changes")
|
||||
|
||||
var hooksPool int
|
||||
app.RootCmd.PersistentFlags().IntVar(&hooksPool, "hooksPool", 15, "the number of prewarmed JS runtimes")
|
||||
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(&migrationsDir, "migrationsDir", "", "the directory with JS migrations")
|
||||
|
||||
var automigrate bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(&automigrate, "automigrate", true, "enable automatic migrations")
|
||||
|
||||
var publicDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(&publicDir, "publicDir", defaultPublicDir(), "the static files directory")
|
||||
|
||||
var indexFallback bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(&indexFallback, "indexFallback", true, "serve index.html for missing paths")
|
||||
|
||||
_ = app.RootCmd.ParseFlags(os.Args[1:])
|
||||
|
||||
jsvm.MustRegister(app, jsvm.Config{
|
||||
MigrationsDir: migrationsDir,
|
||||
HooksDir: hooksDir,
|
||||
HooksWatch: hooksWatch,
|
||||
HooksPoolSize: hooksPool,
|
||||
})
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
registerHomarrProvider(app)
|
||||
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||
Func: func(event *core.ServeEvent) error {
|
||||
if !event.Router.HasRoute(http.MethodGet, "/{path...}") {
|
||||
event.Router.GET("/{path...}", apis.Static(os.DirFS(publicDir), indexFallback))
|
||||
}
|
||||
return event.Next()
|
||||
},
|
||||
Priority: 999,
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPublicDir() string {
|
||||
if osutils.IsProbablyGoRun() {
|
||||
return "./pb_public"
|
||||
}
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
const requestFields = () => [
|
||||
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["processing", "completed", "failed"] },
|
||||
{ type: "text", name: "model", required: true, min: 1, max: 128 },
|
||||
{ type: "number", name: "requestUnits", required: true, onlyInt: true, min: 1, max: 1000 },
|
||||
{ type: "number", name: "inputTokens", onlyInt: true, min: 0 },
|
||||
{ type: "number", name: "outputTokens", onlyInt: true, min: 0 },
|
||||
{ type: "number", name: "totalTokens", onlyInt: true, min: 0 },
|
||||
{ type: "number", name: "durationMs", onlyInt: true, min: 0 },
|
||||
{ type: "number", name: "cost", min: 0 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
];
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
const activity = new Collection({
|
||||
type: "base",
|
||||
name: "assistant_activity",
|
||||
listRule: "",
|
||||
viewRule: "",
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: requestFields(),
|
||||
});
|
||||
activity.addIndex("idx_assistant_activity_created", false, "created", "");
|
||||
app.save(activity);
|
||||
|
||||
const requests = new Collection({
|
||||
type: "base",
|
||||
name: "assistant_requests",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
...requestFields(),
|
||||
{
|
||||
type: "relation",
|
||||
name: "user",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{
|
||||
type: "relation",
|
||||
name: "publicActivity",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: activity.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
requests.addIndex("idx_assistant_requests_user_created", false, "user, created", "");
|
||||
app.save(requests);
|
||||
|
||||
const quotas = new Collection({
|
||||
type: "base",
|
||||
name: "assistant_quotas",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "user",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ type: "number", name: "dailyLimit", required: true, onlyInt: true, min: 0, max: 100000 },
|
||||
{ type: "text", name: "day", required: true, min: 10, max: 10 },
|
||||
{ type: "number", name: "used", onlyInt: true, min: 0 },
|
||||
{ type: "number", name: "inputTokens", onlyInt: true, min: 0 },
|
||||
{ type: "number", name: "outputTokens", onlyInt: true, min: 0 },
|
||||
{ type: "number", name: "totalTokens", onlyInt: true, min: 0 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
|
||||
app.save(quotas);
|
||||
},
|
||||
(app) => {
|
||||
app.delete(app.findCollectionByNameOrId("assistant_quotas"));
|
||||
app.delete(app.findCollectionByNameOrId("assistant_requests"));
|
||||
app.delete(app.findCollectionByNameOrId("assistant_activity"));
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const port = Number(process.env.PORT ?? 18091);
|
||||
|
||||
const json = (response, status, body) => {
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method === "GET" && request.url === "/health") {
|
||||
json(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST" || request.url !== "/api/v1/chat/completions") {
|
||||
json(response, 404, { error: { message: "Not found" } });
|
||||
return;
|
||||
}
|
||||
if (request.headers.authorization !== "Bearer workshop-test-openrouter-key") {
|
||||
json(response, 401, { error: { message: "Invalid upstream credentials" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (body.model !== "~deepseek/deepseek-v4-flash-latest") {
|
||||
json(response, 400, { error: { message: "Unexpected upstream model" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const completion = {
|
||||
id: "gen-workshop-test",
|
||||
object: "chat.completion",
|
||||
model: body.model,
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "Hello from the Homarr provider" }, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19, cost: 0.00041 },
|
||||
};
|
||||
if (!body.stream) {
|
||||
json(response, 200, completion);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
"x-request-id": "mock-openrouter-request",
|
||||
});
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: completion.id,
|
||||
object: "chat.completion.chunk",
|
||||
model: body.model,
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "Hello from " }, finish_reason: null }],
|
||||
})}\n\n`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: completion.id,
|
||||
object: "chat.completion.chunk",
|
||||
model: body.model,
|
||||
choices: [{ index: 0, delta: { content: "the Homarr provider" }, finish_reason: "stop" }],
|
||||
usage: completion.usage,
|
||||
})}\n\n`,
|
||||
);
|
||||
response.end("data: [DONE]\n\n");
|
||||
}, 150);
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0");
|
||||
@@ -73,6 +73,34 @@ const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_wi
|
||||
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
|
||||
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
|
||||
}
|
||||
|
||||
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
|
||||
for (const required of [
|
||||
'name: "assistant_quotas"',
|
||||
'name: "assistant_requests"',
|
||||
'name: "assistant_activity"',
|
||||
'listRule: ""',
|
||||
"idx_assistant_quotas_user",
|
||||
]) {
|
||||
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
|
||||
}
|
||||
if (/assistant_activity[\s\S]*name: "user"/u.test(providerMigration.split("const requests")[0])) {
|
||||
throw new Error("The public assistant activity collection must not contain user identity");
|
||||
}
|
||||
|
||||
const providerBackend = await read("apps/workshop/homarr_provider.go");
|
||||
for (const required of [
|
||||
'"homarr/deepseek-v4-flash-latest"',
|
||||
'apis.RequireAuth("users")',
|
||||
"DefaultActivityLoggerMiddlewareId",
|
||||
'os.Getenv("OPENROUTER_API_KEY")',
|
||||
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
|
||||
]) {
|
||||
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
|
||||
}
|
||||
if (/logger\(\)|\.Logger\(/u.test(providerBackend)) {
|
||||
throw new Error("The Homarr provider must not log request content or user tokens");
|
||||
}
|
||||
for (const required of [
|
||||
"workshop_migration_state",
|
||||
"addedUserFields",
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
const baseUrl = process.env.WORKSHOP_TEST_URL ?? "http://127.0.0.1:18090";
|
||||
|
||||
const createSseReader = (body) => {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
return {
|
||||
cancel: () => reader.cancel(),
|
||||
async next() {
|
||||
while (true) {
|
||||
const boundary = buffer.indexOf("\n\n");
|
||||
if (boundary >= 0) {
|
||||
const block = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
const result = { event: "message", id: "", data: "" };
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) result.event = line.slice(6).trim();
|
||||
if (line.startsWith("id:")) result.id = line.slice(3).trim();
|
||||
if (line.startsWith("data:")) result.data += line.slice(5).trim();
|
||||
}
|
||||
if (result.data || result.id) return result;
|
||||
continue;
|
||||
}
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) throw new Error("PocketBase realtime stream ended unexpectedly");
|
||||
buffer += decoder.decode(chunk.value, { stream: true }).replaceAll("\r\n", "\n");
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const request = async (path, init = {}) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
@@ -35,6 +64,9 @@ for (const required of [
|
||||
"reports",
|
||||
"workshop_listings",
|
||||
"workshop_report_summaries",
|
||||
"assistant_quotas",
|
||||
"assistant_requests",
|
||||
"assistant_activity",
|
||||
]) {
|
||||
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
|
||||
}
|
||||
@@ -100,6 +132,182 @@ const visitorPassword = "WorkshopVisitor123!";
|
||||
const visitor = await createUser("widget-visitor@example.invalid", "widget-visitor", visitorPassword);
|
||||
const visitorSession = await signIn(visitor.email, visitorPassword);
|
||||
|
||||
const modelList = await request("/api/ai/v1/models");
|
||||
if (
|
||||
modelList.object !== "list" ||
|
||||
modelList.data.length !== 1 ||
|
||||
modelList.data[0].id !== "homarr/deepseek-v4-flash-latest"
|
||||
) {
|
||||
throw new Error("The Homarr provider must advertise exactly one model");
|
||||
}
|
||||
await expectStatus("/api/ai/usage", {}, 401);
|
||||
|
||||
const initialUsage = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
const initialReset = new Date(initialUsage.resetsAt);
|
||||
if (
|
||||
initialUsage.limit !== 50 ||
|
||||
initialUsage.used !== 0 ||
|
||||
initialUsage.remaining !== 50 ||
|
||||
initialReset.getUTCHours() !== 0 ||
|
||||
initialReset <= new Date()
|
||||
) {
|
||||
throw new Error(`Unexpected initial Homarr provider allowance: ${JSON.stringify(initialUsage)}`);
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ model: "another-model", messages: [{ role: "user", content: "hello" }] }),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const realtimeResponse = await fetch(`${baseUrl}/api/realtime`);
|
||||
if (!realtimeResponse.ok || !realtimeResponse.body) throw new Error("PocketBase realtime connection failed");
|
||||
const realtime = createSseReader(realtimeResponse.body);
|
||||
const connectedEvent = await realtime.next();
|
||||
if (connectedEvent.event !== "PB_CONNECT" || !connectedEvent.id) {
|
||||
throw new Error("PocketBase realtime did not provide a client ID");
|
||||
}
|
||||
await request("/api/realtime", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clientId: connectedEvent.id, subscriptions: ["assistant_activity/*"] }),
|
||||
});
|
||||
const activityEvent = (async () => {
|
||||
while (true) {
|
||||
const event = await realtime.next();
|
||||
const payload = JSON.parse(event.data);
|
||||
if (payload.action === "create") return payload.record;
|
||||
}
|
||||
})();
|
||||
|
||||
const streamStartedAt = performance.now();
|
||||
const streamResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/deepseek-v4-flash-latest",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
if (!streamResponse.ok || streamResponse.headers.get("x-homarr-quota-remaining") !== "49") {
|
||||
throw new Error(`Streaming provider request failed: ${streamResponse.status}`);
|
||||
}
|
||||
const streamReader = streamResponse.body.getReader();
|
||||
const firstChunk = await streamReader.read();
|
||||
const firstChunkAt = performance.now();
|
||||
let streamText = new TextDecoder().decode(firstChunk.value ?? new Uint8Array());
|
||||
while (true) {
|
||||
const chunk = await streamReader.read();
|
||||
if (chunk.done) break;
|
||||
streamText += new TextDecoder().decode(chunk.value);
|
||||
}
|
||||
const streamFinishedAt = performance.now();
|
||||
if (
|
||||
firstChunk.done ||
|
||||
!streamText.includes("Hello from ") ||
|
||||
!streamText.includes("the Homarr provider") ||
|
||||
streamFinishedAt - firstChunkAt < 50 ||
|
||||
firstChunkAt - streamStartedAt > 5_000
|
||||
) {
|
||||
throw new Error("The Homarr provider did not preserve the upstream SSE stream");
|
||||
}
|
||||
const liveActivity = await Promise.race([
|
||||
activityEvent,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("Assistant activity realtime event timed out")), 2_000)),
|
||||
]);
|
||||
await realtime.cancel();
|
||||
if (liveActivity.model !== "homarr/deepseek-v4-flash-latest" || liveActivity.requestUnits !== 1) {
|
||||
throw new Error("The public assistant activity event is invalid");
|
||||
}
|
||||
|
||||
const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/deepseek-v4-flash-latest",
|
||||
messages: [
|
||||
{ role: "user", content: "inspect my dashboard" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "one" }, { id: "two" }] },
|
||||
{ role: "tool", tool_call_id: "one", content: "{}" },
|
||||
{ role: "tool", tool_call_id: "two", content: "{}" },
|
||||
],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "46") {
|
||||
throw new Error("Tool results must each consume one Homarr provider request unit");
|
||||
}
|
||||
|
||||
const afterToolsUsage = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
if (afterToolsUsage.used !== 4 || afterToolsUsage.remaining !== 46) {
|
||||
throw new Error(`Tool request accounting is incorrect: ${JSON.stringify(afterToolsUsage)}`);
|
||||
}
|
||||
|
||||
const publicActivities = await request("/api/collections/assistant_activity/records?sort=-created&perPage=10");
|
||||
if (
|
||||
publicActivities.items.length < 2 ||
|
||||
publicActivities.items.some((item) => "user" in item) ||
|
||||
!publicActivities.items.some(
|
||||
(item) => item.status === "completed" && item.inputTokens === 12 && item.outputTokens === 7,
|
||||
)
|
||||
) {
|
||||
throw new Error("Public assistant activity must be anonymous and include completed token totals");
|
||||
}
|
||||
await expectStatus("/api/collections/assistant_requests/records", { headers: authorSession.headers }, 403);
|
||||
await expectStatus("/api/collections/assistant_quotas/records", { headers: authorSession.headers }, 403);
|
||||
|
||||
const privateRequests = await request("/api/collections/assistant_requests/records?perPage=20", {
|
||||
headers: rootHeaders,
|
||||
});
|
||||
if (
|
||||
privateRequests.items.length < 2 ||
|
||||
privateRequests.items.some((item) => item.user !== author.id) ||
|
||||
privateRequests.items.some((item) => "messages" in item || "prompt" in item || "body" in item)
|
||||
) {
|
||||
throw new Error("Private provider accounting must identify the user without storing request content");
|
||||
}
|
||||
const quotas = await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders });
|
||||
const authorQuota = quotas.items.find((item) => item.user === author.id);
|
||||
if (!authorQuota || authorQuota.dailyLimit !== 50 || authorQuota.used !== 4 || authorQuota.totalTokens !== 38) {
|
||||
throw new Error(`Private provider quota is incorrect: ${JSON.stringify(authorQuota)}`);
|
||||
}
|
||||
|
||||
await request(`/api/collections/assistant_quotas/records/${authorQuota.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ day: "2000-01-01", used: 49, dailyLimit: 2 }),
|
||||
});
|
||||
const resetUsage = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
if (resetUsage.limit !== 2 || resetUsage.used !== 0 || resetUsage.remaining !== 2) {
|
||||
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
|
||||
}
|
||||
|
||||
const allowanceProbe = () =>
|
||||
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/deepseek-v4-flash-latest",
|
||||
messages: [{ role: "user", content: "allowance probe" }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (!(await allowanceProbe()).ok || !(await allowanceProbe()).ok) {
|
||||
throw new Error("The per-user allowance rejected a permitted request");
|
||||
}
|
||||
const exhaustedResponse = await allowanceProbe();
|
||||
if (
|
||||
exhaustedResponse.status !== 429 ||
|
||||
exhaustedResponse.headers.get("x-homarr-quota-remaining") !== "0" ||
|
||||
!exhaustedResponse.headers.get("x-homarr-quota-reset")
|
||||
) {
|
||||
throw new Error("The per-user allowance was not enforced atomically");
|
||||
}
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Workshop runtime probe",
|
||||
|
||||
Reference in new issue
Block a user