diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index d8aaff7..6631d7a 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -7,6 +7,18 @@ on:
- master
tags:
- "v*"
+ paths:
+ - "Dockerfile"
+ - "docker-entrypoint.sh"
+ - "config.docker.json"
+ - "go.mod"
+ - "go.sum"
+ - "cmd/**"
+ - "internal/**"
+ - "static/**"
+ - "frontend/**"
+ - ".dockerignore"
+ - ".github/workflows/docker-image.yml"
workflow_dispatch:
permissions:
@@ -19,15 +31,39 @@ concurrency:
env:
REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository }}
jobs:
- build-and-push:
+ prep:
runs-on: ubuntu-latest
+ outputs:
+ image_name_lc: ${{ steps.norm.outputs.image_name_lc }}
+ steps:
+ - name: Normalize image name
+ id: norm
+ run: |
+ echo "image_name_lc=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
+
+ build:
+ runs-on: ubuntu-latest
+ needs: prep
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - platform: linux/amd64
+ arch: amd64
+ - platform: linux/arm64
+ arch: arm64
+
steps:
- name: Checkout
uses: actions/checkout@v4
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: arm64
+
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -42,21 +78,84 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ images: ${{ env.REGISTRY }}/${{ needs.prep.outputs.image_name_lc }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- - name: Build and push image
+ - name: Build and push by digest
+ id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
- platforms: linux/amd64,linux/arm64
- push: true
- tags: ${{ steps.meta.outputs.tags }}
+ platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ outputs: type=image,name=${{ env.REGISTRY }}/${{ needs.prep.outputs.image_name_lc }},push-by-digest=true,name-canonical=true,push=true
+ cache-from: |
+ type=gha,scope=notion2api-${{ matrix.arch }}
+ type=registry,ref=${{ env.REGISTRY }}/${{ needs.prep.outputs.image_name_lc }}:buildcache-${{ matrix.arch }}
+ cache-to: |
+ type=gha,mode=max,scope=notion2api-${{ matrix.arch }}
+ type=registry,ref=${{ env.REGISTRY }}/${{ needs.prep.outputs.image_name_lc }}:buildcache-${{ matrix.arch }},mode=max,oci-mediatypes=true,image-manifest=true
+
+ - name: Export digest
+ run: |
+ mkdir -p "${{ runner.temp }}/digests"
+ digest="${{ steps.build.outputs.digest }}"
+ touch "${{ runner.temp }}/digests/${digest#sha256:}"
+
+ - name: Upload digest artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: digests-${{ matrix.arch }}
+ path: ${{ runner.temp }}/digests/*
+ if-no-files-found: error
+ retention-days: 1
+
+ merge:
+ runs-on: ubuntu-latest
+ needs:
+ - prep
+ - build
+
+ steps:
+ - name: Download digest artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: ${{ runner.temp }}/digests
+ pattern: digests-*
+ merge-multiple: true
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract Docker metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ needs.prep.outputs.image_name_lc }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=sha,prefix=sha-
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Create and push manifest list
+ working-directory: ${{ runner.temp }}/digests
+ run: |
+ tags=$(jq -r '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}')
+ sources=$(printf '${{ env.REGISTRY }}/${{ needs.prep.outputs.image_name_lc }}@sha256:%s ' *)
+ docker buildx imagetools create $tags $sources
+
+ - name: Inspect image
+ run: docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ needs.prep.outputs.image_name_lc }}:${{ steps.meta.outputs.version }}
diff --git a/.gitignore b/.gitignore
index 1abd7ba..30a6578 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,8 +41,4 @@ frontend/out/
*.spec.tsx
__tests__/
WEBUI_DEVELOPMENT_GUIDE.md
-
-# Rust FFI build artifacts (v2 wreq-ffi)
-wreq-ffi/target/
-wreq-ffi/include/wreq_ffi.h
-wreq-ffi/Cargo.lock
+.serena/
diff --git a/Dockerfile b/Dockerfile
index a0321c7..9d32d66 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,89 +2,17 @@ FROM --platform=$BUILDPLATFORM node:22-bookworm AS frontend-builder
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
-RUN npm ci
+RUN --mount=type=cache,target=/root/.npm,sharing=locked \
+ npm ci
COPY frontend ./
RUN npm run build
-FROM --platform=$BUILDPLATFORM rust:1.86-bookworm AS rust-builder
-ARG BUILDPLATFORM
-ARG TARGETPLATFORM
-ARG TARGETARCH
-ARG TARGETOS=linux
-WORKDIR /src
-
-RUN apt-get update -o Acquire::Retries=5 \
- && apt-get install -y -o Acquire::Retries=5 --no-install-recommends \
- cmake perl build-essential libclang-dev clang lld file \
- gcc-x86-64-linux-gnu g++-x86-64-linux-gnu \
- gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
- && rm -rf /var/lib/apt/lists/*
-
-RUN set -eux; \
- case "${TARGETARCH}" in \
- amd64) RUST_TARGET=x86_64-unknown-linux-gnu ;; \
- arm64) RUST_TARGET=aarch64-unknown-linux-gnu ;; \
- *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
- esac; \
- rustup target add "${RUST_TARGET}"; \
- echo "${RUST_TARGET}" > /tmp/rust_target
-
-ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \
- CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \
- CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++ \
- AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar \
- CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
- CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
- CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \
- AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar
-
-ENV CARGO_TARGET_DIR=/cargo-target
-
-COPY wreq-ffi ./wreq-ffi
-
-RUN --mount=type=cache,target=/usr/local/cargo/registry \
- --mount=type=cache,target=/usr/local/cargo/git \
- --mount=type=cache,target=/cargo-target,id=cargo-target-${TARGETARCH},sharing=locked \
- set -eux; \
- RUST_TARGET=$(cat /tmp/rust_target); \
- case "${TARGETARCH}" in \
- amd64) CC=x86_64-linux-gnu-gcc; CXX=x86_64-linux-gnu-g++; AR=x86_64-linux-gnu-ar ;; \
- arm64) CC=aarch64-linux-gnu-gcc; CXX=aarch64-linux-gnu-g++; AR=aarch64-linux-gnu-ar ;; \
- *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
- esac; \
- export CC CXX AR; \
- echo "rust-builder toolchain: TARGETARCH=${TARGETARCH} RUST_TARGET=${RUST_TARGET} CC=${CC} CXX=${CXX} AR=${AR}"; \
- echo "rust-builder diag: BUILDPLATFORM=${BUILDPLATFORM} TARGETPLATFORM=${TARGETPLATFORM} TARGETARCH=${TARGETARCH} RUST_TARGET=${RUST_TARGET} host=$(uname -m)"; \
- cd wreq-ffi; \
- mkdir -p include; \
- touch src/lib.rs; \
- cargo build --release --target "${RUST_TARGET}"; \
- test -f include/wreq_ffi.h; \
- mkdir -p /out; \
- cp "${CARGO_TARGET_DIR}/${RUST_TARGET}/release/libwreq_ffi.a" /out/; \
- cp include/wreq_ffi.h /out/; \
- FIRST_MEMBER=$(ar t /out/libwreq_ffi.a | head -1); \
- AFILE=$(ar p /out/libwreq_ffi.a "$FIRST_MEMBER" | file -); \
- echo "rust-builder: first member ($FIRST_MEMBER) of /out/libwreq_ffi.a => ${AFILE}"; \
- case "${TARGETARCH}" in \
- amd64) echo "${AFILE}" | grep -q 'x86-64' || { echo "FATAL: /out/libwreq_ffi.a is not x86-64 (TARGETARCH=amd64). This usually means a cache mount got mixed up; try: docker buildx prune -af" >&2; exit 1; } ;; \
- arm64) echo "${AFILE}" | grep -q 'aarch64' || { echo "FATAL: /out/libwreq_ffi.a is not aarch64 (TARGETARCH=arm64). This usually means a cache mount got mixed up; try: docker buildx prune -af" >&2; exit 1; } ;; \
- esac; \
- echo "rust-builder: arch verified for TARGETARCH=${TARGETARCH}"
-
-FROM --platform=$BUILDPLATFORM golang:1.22-bookworm AS builder
+FROM --platform=$BUILDPLATFORM golang:1.25.0-bookworm AS builder
ARG BUILDPLATFORM
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
-RUN apt-get update -o Acquire::Retries=5 \
- && apt-get install -y -o Acquire::Retries=5 --no-install-recommends \
- file \
- gcc-x86-64-linux-gnu g++-x86-64-linux-gnu \
- gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
- && rm -rf /var/lib/apt/lists/*
-
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
@@ -95,48 +23,23 @@ COPY cmd ./cmd
COPY internal ./internal
COPY static ./static
COPY --from=frontend-builder /frontend/out /src/static/admin
-COPY --from=rust-builder /out/libwreq_ffi.a /src/wreq-ffi/target/release/libwreq_ffi.a
-COPY --from=rust-builder /out/wreq_ffi.h /src/wreq-ffi/include/wreq_ffi.h
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
- case "${TARGETARCH}" in \
- amd64) CC=x86_64-linux-gnu-gcc; CXX=x86_64-linux-gnu-g++ ;; \
- arm64) CC=aarch64-linux-gnu-gcc; CXX=aarch64-linux-gnu-g++ ;; \
- *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \
- esac; \
- echo "go-builder diag: BUILDPLATFORM=${BUILDPLATFORM} TARGETPLATFORM=${TARGETPLATFORM} TARGETARCH=${TARGETARCH} CC=${CC} host=$(uname -m)"; \
- FIRST_MEMBER=$(ar t /src/wreq-ffi/target/release/libwreq_ffi.a | head -1); \
- AFILE=$(ar p /src/wreq-ffi/target/release/libwreq_ffi.a "$FIRST_MEMBER" | file -); \
- echo "go-builder: first member ($FIRST_MEMBER) of libwreq_ffi.a => ${AFILE}"; \
- case "${TARGETARCH}" in \
- amd64) echo "${AFILE}" | grep -q 'x86-64' || { echo "FATAL: libwreq_ffi.a in builder stage is not x86-64; rust-builder produced wrong arch or COPY layer is stale. Run: docker buildx prune -af" >&2; exit 1; } ;; \
- arm64) echo "${AFILE}" | grep -q 'aarch64' || { echo "FATAL: libwreq_ffi.a in builder stage is not aarch64; rust-builder produced wrong arch or COPY layer is stale. Run: docker buildx prune -af" >&2; exit 1; } ;; \
- esac; \
- test -f ./cmd/notion2api/main.go; \
- CGO_ENABLED=1 GOOS=${TARGETOS} GOARCH=${TARGETARCH} CC=${CC} CXX=${CXX} \
- go build -v -trimpath -tags wreq_ffi \
+ CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
+ go build -v -trimpath \
-ldflags="-s -w" \
-o /out/notion2api ./cmd/notion2api
-FROM node:22-bookworm-slim
+FROM alpine:3.22
+ARG TARGETARCH
ENV TZ=Asia/Shanghai
-ENV NODE_PATH=/opt/notion2api-helper/node_modules
-ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WORKDIR /app
-RUN apt-get update \
- && apt-get install -y --no-install-recommends ca-certificates tzdata curl tini \
- && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb \
- && mkdir -p /opt/notion2api-helper /app/config /app/data/notion_accounts /app/static
-
-RUN cd /opt/notion2api-helper \
- && npm init -y >/dev/null 2>&1 \
- && npm install --omit=dev --no-package-lock node-wreq@2.2.1 \
- && test -d "$NODE_PATH/node-wreq" \
- && npm cache clean --force >/dev/null 2>&1
+RUN apk add --no-cache ca-certificates tzdata curl tini \
+ && mkdir -p /app/config /app/data/notion_accounts /app/static
COPY --from=builder /out/notion2api /app/notion2api
COPY --from=builder /src/static /app/static
@@ -150,5 +53,5 @@ EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD curl -fsS http://127.0.0.1:8787/healthz || exit 1
-ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
+ENTRYPOINT ["/sbin/tini", "--", "docker-entrypoint.sh"]
CMD ["./notion2api", "--config", "/app/config/config.json"]
diff --git a/README.md b/README.md
index a789cf4..07df53f 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,8 @@ docker compose up -d --build
docker compose -f docker-compose.prod.yml up -d --build
```
+本地从源码开发需 Go `1.25.0+`(`go.mod` 已声明)。
+
## 默认入口
- API:`http://127.0.0.1:8787/v1/*`
diff --git a/cmd/notion2api/main.go b/cmd/notion2api/main.go
index 0ebdd15..15912dd 100644
--- a/cmd/notion2api/main.go
+++ b/cmd/notion2api/main.go
@@ -1,13 +1,9 @@
package main
import (
- "log"
-
"notion2api/internal/app"
- "notion2api/internal/wreq"
)
func main() {
- log.Printf("notion2api: wreq backend = %s", wreq.Version())
app.Main()
}
diff --git a/config.docker.json b/config.docker.json
index 9bf604d..c18905f 100644
--- a/config.docker.json
+++ b/config.docker.json
@@ -29,6 +29,9 @@
"persist_continuation_sessions": true,
"persist_sillytavern_bindings": true
},
+ "limits": {
+ "max_request_body_bytes": 4194304
+ },
"admin": {
"enabled": true,
"password": "change-me-admin-password",
@@ -46,6 +49,16 @@
"retry_on_auth_error": true,
"auto_switch_account": true
},
+ "dispatch": {
+ "probe_cache_ttl_seconds": 45
+ },
+ "browser": {
+ "helper_pool_size": 0
+ },
+ "debug": {
+ "pprof_enabled": false,
+ "pprof_addr": "127.0.0.1:6060"
+ },
"features": {
"use_web_search": true,
"use_read_only_mode": false,
diff --git a/config.example.json b/config.example.json
index aebb2b4..d7651ea 100644
--- a/config.example.json
+++ b/config.example.json
@@ -43,6 +43,9 @@
"persist_continuation_sessions": true,
"persist_sillytavern_bindings": true
},
+ "limits": {
+ "max_request_body_bytes": 4194304
+ },
"features": {
"use_web_search": true,
"use_read_only_mode": false,
@@ -69,6 +72,16 @@
"retry_on_auth_error": true,
"auto_switch_account": true
},
+ "dispatch": {
+ "probe_cache_ttl_seconds": 45
+ },
+ "browser": {
+ "helper_pool_size": 0
+ },
+ "debug": {
+ "pprof_enabled": false,
+ "pprof_addr": "127.0.0.1:6060"
+ },
"accounts": [
{
"email": "alice@example.com",
diff --git a/go.mod b/go.mod
index fca8103..8c7a871 100644
--- a/go.mod
+++ b/go.mod
@@ -1,17 +1,35 @@
module notion2api
-go 1.22.0
+go 1.25.0
require modernc.org/sqlite v1.33.1
+require (
+ github.com/andybalholm/brotli v1.2.1 // indirect
+ github.com/enetx/g v1.0.224 // indirect
+ github.com/enetx/http v1.0.28 // indirect
+ github.com/enetx/http2 v1.0.26 // indirect
+ github.com/enetx/http3 v1.0.7 // indirect
+ github.com/enetx/iter v0.0.0-20250912135656-f1583323588f // indirect
+ github.com/klauspost/compress v1.18.5 // indirect
+ github.com/quic-go/qpack v0.6.0 // indirect
+ github.com/quic-go/quic-go v0.59.0 // indirect
+ github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
+ github.com/wzshiming/socks5 v0.7.0 // indirect
+ golang.org/x/crypto v0.41.0 // indirect
+ golang.org/x/net v0.43.0 // indirect
+ golang.org/x/text v0.35.0 // indirect
+)
+
require (
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/enetx/surf v1.0.199
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/sys v0.22.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 617fda4..c9ac762 100644
--- a/go.sum
+++ b/go.sum
@@ -1,26 +1,68 @@
+github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
+github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/enetx/g v1.0.224 h1:H/uonguFE4qG8YCn5bSpZX5Wh+wTSb+jgf3I2ZM25XM=
+github.com/enetx/g v1.0.224/go.mod h1:lxhby3LjP8jOTGbxJ/PCd+2Zq1gYiSBbtL/llPhAg5c=
+github.com/enetx/http v1.0.28 h1:IaNSSDFlAVVdHnYhNIR9wAN7GY4TWL/kkvYC3jOaueY=
+github.com/enetx/http v1.0.28/go.mod h1:1f4mytfF/SfjATEJnynpwGS6aa1ALjb8DtmYgFVblY0=
+github.com/enetx/http2 v1.0.26 h1:wy3lYGVwnIUY4Q+gyPPQCJ1a+BMXD1B7Unpyc/Csrxc=
+github.com/enetx/http2 v1.0.26/go.mod h1:t54ex5HIS8V1+2j6cvEOv6umlrHsbUPFKQ54nYB58Nk=
+github.com/enetx/http3 v1.0.7 h1:daFhveKBtv8rRallCjaHErzzSHIrq07ovoSvVkvhcMM=
+github.com/enetx/http3 v1.0.7/go.mod h1:sqpVGZ9F1/wCiW6sjBUS2errKAh3SUYn6VlWE7LL6KM=
+github.com/enetx/iter v0.0.0-20250912135656-f1583323588f h1:GUW+4AWfECIEJ9oAxgEAVGCpaozMCjRiUYnuR6Q0bCQ=
+github.com/enetx/iter v0.0.0-20250912135656-f1583323588f/go.mod h1:oMZN8hGLUpi7QBlMEUqailocNy0NFAO/7Lu+Nwh9HMM=
+github.com/enetx/surf v1.0.199 h1:RtqcwlyLM8O4U+43laNnNJwx5hALkH5cJRxDX1F2VjM=
+github.com/enetx/surf v1.0.199/go.mod h1:c6g53gi273RBiZFO4THWIqpn5n9RLC6vw5WpUwHrT4U=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
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/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
+github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
-golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/wzshiming/socks5 v0.7.0 h1:euJ+U48WrvVngi+opC8vAnpZ5sK12y1C2hPvb1f48Rg=
+github.com/wzshiming/socks5 v0.7.0/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
+go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
+golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
+golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
-golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
+golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
diff --git a/internal/app/account_discovery.go b/internal/app/account_discovery.go
index b6a8636..e33dada 100644
--- a/internal/app/account_discovery.go
+++ b/internal/app/account_discovery.go
@@ -165,7 +165,7 @@ func discoverImportedAccountMetadata(ctx context.Context, cfg AppConfig, account
}
upstream := cfg.NotionUpstream()
resolver := NewProxyResolver(cfg)
- session, err := newNotionLoginSession(helperTimeout(cfg), upstream, resolver, accountEmail)
+ session, err := newNotionLoginSession(helperTimeout(cfg), upstream, resolver, accountEmail, cfg)
if err != nil {
return meta, err
}
diff --git a/internal/app/account_pool.go b/internal/app/account_pool.go
index a287faf..10fcdb2 100644
--- a/internal/app/account_pool.go
+++ b/internal/app/account_pool.go
@@ -156,8 +156,10 @@ func sortDispatchCandidates(cfg AppConfig, accounts []NotionAccount, now time.Ti
sort.Slice(accounts, func(i, j int) bool {
left := accounts[i]
right := accounts[j]
- leftActive := canonicalEmailKey(left.Email) == activeKey
- rightActive := canonicalEmailKey(right.Email) == activeKey
+ leftKey := getAccountEmailKey(left)
+ rightKey := getAccountEmailKey(right)
+ leftActive := leftKey == activeKey
+ rightActive := rightKey == activeKey
if leftActive != rightActive {
return leftActive
}
@@ -183,11 +185,11 @@ func sortDispatchCandidates(cfg AppConfig, accounts []NotionAccount, now time.Ti
if !leftUsed.Equal(rightUsed) {
return leftUsed.Before(rightUsed)
}
- return canonicalEmailKey(left.Email) < canonicalEmailKey(right.Email)
+ return leftKey < rightKey
})
}
-func pickDispatchCandidates(cfg AppConfig, now time.Time) []NotionAccount {
+func buildDispatchCandidateOrder(cfg AppConfig, now time.Time) []NotionAccount {
candidates := make([]NotionAccount, 0, len(cfg.Accounts))
for _, account := range cfg.Accounts {
account = ensureAccountPaths(cfg, account)
@@ -199,6 +201,16 @@ func pickDispatchCandidates(cfg AppConfig, now time.Time) []NotionAccount {
return candidates
}
+func pickDispatchCandidatesFromSnapshot(bundle *snapshotBundle, now time.Time) []NotionAccount {
+ if bundle == nil {
+ return nil
+ }
+ if len(bundle.DispatchOrder) > 0 {
+ return bundle.DispatchOrder
+ }
+ return buildDispatchCandidateOrder(bundle.Config, now)
+}
+
func applyAccountUpdate(cfg AppConfig, account NotionAccount, makeActive bool) AppConfig {
account = ensureAccountPaths(cfg, account)
cfg.UpsertAccount(account)
@@ -241,8 +253,10 @@ func (a *App) runPromptWithSession(ctx context.Context, cfg AppConfig, session S
if a.runPromptWithSessionOverride != nil {
return a.runPromptWithSessionOverride(ctx, cfg, session, request, onDelta)
}
+ transportClientNewTotalMetric.Add("standard", 1)
client := newNotionAIClient(session, cfg, accountEmail)
if onDelta != nil {
+ transportClientNewTotalMetric.Add("streaming", 1)
client = newNotionAIStreamingClient(session, cfg, accountEmail)
}
execute := func(ctx context.Context, current PromptRunRequest, forward func(string) error) (InferenceResult, error) {
@@ -261,8 +275,10 @@ func (a *App) runPromptWithSessionWithSink(ctx context.Context, cfg AppConfig, s
if a.runPromptWithSessionOverride != nil {
return a.runPromptWithSessionOverride(ctx, cfg, session, request, sink.Text)
}
+ transportClientNewTotalMetric.Add("streaming", 1)
client := newNotionAIStreamingClient(session, cfg, accountEmail)
if sink.Text == nil && sink.Reasoning == nil && sink.ReasoningWarmup == nil && sink.KeepAlive == nil {
+ transportClientNewTotalMetric.Add("standard", 1)
client = newNotionAIClient(session, cfg, accountEmail)
}
if sink.Reasoning != nil || sink.ReasoningWarmup != nil || sink.KeepAlive != nil {
diff --git a/internal/app/accounts.go b/internal/app/accounts.go
index a1c7331..021a2a6 100644
--- a/internal/app/accounts.go
+++ b/internal/app/accounts.go
@@ -10,6 +10,11 @@ import (
"strings"
)
+var (
+ accountPathSlugPattern = regexp.MustCompile(`[^a-z0-9]+`)
+ windowsAbsolutePathPattern = regexp.MustCompile(`^[A-Za-z]:[\\/].*`)
+)
+
type ResolvedLoginHelper struct {
SessionsDir string `json:"sessions_dir"`
TimeoutSec int `json:"timeout_sec"`
@@ -50,13 +55,19 @@ func canonicalEmailKey(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
+func getAccountEmailKey(account NotionAccount) string {
+ if account.emailKey != "" {
+ return account.emailKey
+ }
+ return canonicalEmailKey(account.Email)
+}
+
func accountPathSlug(email string) string {
clean := canonicalEmailKey(email)
if clean == "" {
return "account"
}
- re := regexp.MustCompile(`[^a-z0-9]+`)
- clean = re.ReplaceAllString(clean, "_")
+ clean = accountPathSlugPattern.ReplaceAllString(clean, "_")
clean = strings.Trim(clean, "_")
if clean == "" {
return "account"
@@ -99,7 +110,7 @@ func pathLooksAbsoluteAnyOS(value string) bool {
if filepath.IsAbs(clean) {
return true
}
- if matched, _ := regexp.MatchString(`^[A-Za-z]:[\\/].*`, clean); matched {
+ if windowsAbsolutePathPattern.MatchString(clean) {
return true
}
if strings.HasPrefix(clean, `\\`) {
@@ -119,7 +130,7 @@ func isForeignAbsolutePath(value string) bool {
if runtime.GOOS == "windows" {
return strings.HasPrefix(clean, "/")
}
- if matched, _ := regexp.MatchString(`^[A-Za-z]:[\\/].*`, clean); matched {
+ if windowsAbsolutePathPattern.MatchString(clean) {
return true
}
if strings.HasPrefix(clean, `\\`) {
@@ -150,7 +161,7 @@ func (cfg AppConfig) FindAccount(email string) (NotionAccount, int, bool) {
return NotionAccount{}, -1, false
}
for i, account := range cfg.Accounts {
- if canonicalEmailKey(account.Email) == target {
+ if getAccountEmailKey(account) == target {
return account, i, true
}
}
@@ -215,6 +226,7 @@ func (helper ResolvedLoginHelper) ProbePath(profileDir string) string {
}
func ensureAccountPaths(cfg AppConfig, account NotionAccount) NotionAccount {
+ account.emailKey = canonicalEmailKey(account.Email)
helper := cfg.ResolveLoginHelper()
if strings.TrimSpace(account.ProfileDir) == "" || isForeignAbsolutePath(account.ProfileDir) {
account.ProfileDir = helper.ProfileDirFor(account.Email)
@@ -328,12 +340,13 @@ func (cfg *AppConfig) UpsertAccount(account NotionAccount) (NotionAccount, int)
}
func (cfg *AppConfig) DeleteAccount(email string) bool {
- _, index, ok := cfg.FindAccount(email)
+ target := canonicalEmailKey(email)
+ _, index, ok := cfg.FindAccount(target)
if !ok {
return false
}
cfg.Accounts = append(cfg.Accounts[:index], cfg.Accounts[index+1:]...)
- if canonicalEmailKey(cfg.ActiveAccount) == canonicalEmailKey(email) {
+ if canonicalEmailKey(cfg.ActiveAccount) == target {
cfg.ActiveAccount = ""
cfg.ProbeJSON = ""
}
diff --git a/internal/app/admin.go b/internal/app/admin.go
index 6888344..19f8ea0 100644
--- a/internal/app/admin.go
+++ b/internal/app/admin.go
@@ -426,9 +426,9 @@ func (a *App) handleAdminLogin(w http.ResponseWriter, r *http.Request) {
})
return
}
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
if !securePasswordEqual(password, stringValue(payload["password"])) {
@@ -671,9 +671,9 @@ func (a *App) handleAdminTest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"detail": "method not allowed"})
return
}
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
cfg, _, registry := a.State.Snapshot()
diff --git a/internal/app/admin_accounts.go b/internal/app/admin_accounts.go
index 279e811..32a9127 100644
--- a/internal/app/admin_accounts.go
+++ b/internal/app/admin_accounts.go
@@ -87,7 +87,7 @@ func (a *App) accountRuntimeSummary(cfg AppConfig, account NotionAccount) map[st
"consecutive_failures": account.ConsecutiveFailures,
"total_successes": account.TotalSuccesses,
"total_failures": account.TotalFailures,
- "active": canonicalEmailKey(cfg.ActiveAccount) == canonicalEmailKey(account.Email),
+ "active": canonicalEmailKey(cfg.ActiveAccount) == getAccountEmailKey(account),
}
if status, err := readLoginStatusFile(account.PendingStatePath); err == nil {
item["login_status"] = status
@@ -262,9 +262,9 @@ func (a *App) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
case http.MethodGet:
writeJSON(w, http.StatusOK, a.buildAccountsPayload())
case http.MethodPost:
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
account, makeActive, err := decodeAccountPayload(payload)
@@ -286,11 +286,12 @@ func (a *App) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
+ a.invalidateDispatchProbeCache()
writeJSON(w, http.StatusOK, a.buildAccountsPayload())
case http.MethodPut:
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
email := accountEmailFromPayload(payload)
@@ -314,7 +315,7 @@ func (a *App) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
return
}
cfg.Accounts[index] = ensureAccountPaths(cfg, next)
- if canonicalEmailKey(cfg.ActiveAccount) == canonicalEmailKey(next.Email) && next.Disabled {
+ if canonicalEmailKey(cfg.ActiveAccount) == getAccountEmailKey(next) && next.Disabled {
cfg.ActiveAccount = ""
cfg.ProbeJSON = ""
}
@@ -330,6 +331,7 @@ func (a *App) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
+ a.invalidateDispatchProbeCache()
writeJSON(w, http.StatusOK, a.buildAccountsPayload())
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"detail": "method not allowed"})
@@ -362,6 +364,7 @@ func (a *App) handleAdminAccountDelete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
+ a.invalidateDispatchProbeCache()
writeJSON(w, http.StatusOK, a.buildAccountsPayload())
}
@@ -373,9 +376,9 @@ func (a *App) handleAdminAccountsActivate(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"detail": "method not allowed"})
return
}
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
email := strings.TrimSpace(stringValue(payload["email"]))
@@ -400,6 +403,7 @@ func (a *App) handleAdminAccountsActivate(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
+ a.invalidateDispatchProbeCache()
writeJSON(w, http.StatusOK, a.buildAccountsPayload())
}
@@ -411,9 +415,9 @@ func (a *App) handleAdminAccountsTest(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"detail": "method not allowed"})
return
}
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
cfg, _, registry := a.State.Snapshot()
@@ -676,9 +680,9 @@ func (a *App) handleAdminAccountManualImport(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"detail": "method not allowed"})
return
}
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
req, err := decodeManualImportRequest(payload)
@@ -750,6 +754,7 @@ func (a *App) handleAdminAccountManualImport(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
+ a.invalidateDispatchProbeCache()
cfg, _, _ = a.State.Snapshot()
account, _, _ = cfg.FindAccount(accountEmail)
writeJSON(w, http.StatusOK, map[string]any{
@@ -767,9 +772,9 @@ func (a *App) handleAdminAccountLoginStart(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"detail": "method not allowed"})
return
}
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
email := strings.TrimSpace(stringValue(payload["email"]))
@@ -819,6 +824,7 @@ func (a *App) handleAdminAccountLoginStart(w http.ResponseWriter, r *http.Reques
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
+ a.invalidateDispatchProbeCache()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"account": a.accountRuntimeSummary(cfg, account),
@@ -834,9 +840,9 @@ func (a *App) handleAdminAccountLoginVerify(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"detail": "method not allowed"})
return
}
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
+ writeInvalidBodyError(w, err)
return
}
email := strings.TrimSpace(stringValue(payload["email"]))
@@ -893,6 +899,7 @@ func (a *App) handleAdminAccountLoginVerify(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
return
}
+ a.invalidateDispatchProbeCache()
writeJSON(w, http.StatusOK, map[string]any{
"success": true,
"account": a.accountRuntimeSummary(cfg, account),
diff --git a/internal/app/admin_conversations.go b/internal/app/admin_conversations.go
index fe1721c..da94b3f 100644
--- a/internal/app/admin_conversations.go
+++ b/internal/app/admin_conversations.go
@@ -334,7 +334,7 @@ func (a *App) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
- w.Header().Set("Access-Control-Allow-Origin", "*")
+ applyCORSHeaders(w)
w.WriteHeader(http.StatusOK)
subID, events := a.State.conversations().Subscribe()
diff --git a/internal/app/config.go b/internal/app/config.go
index d54b314..7c9eeec 100644
--- a/internal/app/config.go
+++ b/internal/app/config.go
@@ -22,6 +22,7 @@ type FeatureConfig struct {
UseReadOnlyMode bool `json:"use_read_only_mode"`
ForceDisableUpstreamEdits bool `json:"force_disable_upstream_edits"`
ForceFreshThreadPerRequest bool `json:"force_fresh_thread_per_request"`
+ UseSurfHelperTransport bool `json:"use_surf_helper_transport,omitempty"`
WriterMode bool `json:"writer_mode"`
EnableGenerateImage bool `json:"enable_generate_image"`
EnableCsvAttachmentSupport bool `json:"enable_csv_attachment_support"`
@@ -47,6 +48,19 @@ type SessionRefreshConfig struct {
AutoSwitch bool `json:"auto_switch_account"`
}
+type DispatchConfig struct {
+ ProbeCacheTTLSeconds int `json:"probe_cache_ttl_seconds,omitempty"`
+}
+
+type BrowserConfig struct {
+ HelperPoolSize int `json:"helper_pool_size,omitempty"`
+}
+
+type DebugConfig struct {
+ PprofEnabled bool `json:"pprof_enabled"`
+ PprofAddr string `json:"pprof_addr,omitempty"`
+}
+
type StorageConfig struct {
SQLitePath string `json:"sqlite_path,omitempty"`
PersistConversations bool `json:"persist_conversations"`
@@ -56,6 +70,10 @@ type StorageConfig struct {
PersistSillyTavernBindings *bool `json:"persist_sillytavern_bindings,omitempty"`
}
+type LimitsConfig struct {
+ MaxRequestBodyBytes int64 `json:"max_request_body_bytes,omitempty"`
+}
+
type PromptConfig struct {
Profile string `json:"profile,omitempty"`
CustomPrefix string `json:"custom_prefix,omitempty"`
@@ -67,10 +85,12 @@ type PromptConfig struct {
CodingRetryPrefixes []string `json:"coding_retry_prefixes,omitempty"`
GeneralRetryPrefixes []string `json:"general_retry_prefixes,omitempty"`
DirectAnswerRetryPrefixes []string `json:"direct_answer_retry_prefixes,omitempty"`
+ precomputedAllRetryPrefixes []string `json:"-"`
}
type NotionAccount struct {
Email string `json:"email"`
+ emailKey string `json:"-"`
ProbeJSON string `json:"probe_json,omitempty"`
ProfileDir string `json:"profile_dir,omitempty"`
StorageStatePath string `json:"storage_state_path,omitempty"`
@@ -153,10 +173,14 @@ type AppConfig struct {
Admin AdminConfig `json:"admin"`
Responses ResponsesConfig `json:"responses"`
Storage StorageConfig `json:"storage"`
+ Limits LimitsConfig `json:"limits,omitempty"`
Prompt PromptConfig `json:"prompt"`
Features FeatureConfig `json:"features"`
LoginHelper LoginHelperConfig `json:"login_helper"`
SessionRefresh SessionRefreshConfig `json:"session_refresh"`
+ Dispatch DispatchConfig `json:"dispatch"`
+ Browser BrowserConfig `json:"browser,omitempty"`
+ Debug DebugConfig `json:"debug"`
Accounts []NotionAccount `json:"accounts,omitempty"`
Models []ModelDefinition `json:"models,omitempty"`
ModelAliases map[string]string `json:"model_aliases,omitempty"`
@@ -418,6 +442,9 @@ func defaultConfig() AppConfig {
Storage: StorageConfig{
PersistConversations: true,
},
+ Limits: LimitsConfig{
+ MaxRequestBodyBytes: 4 * 1024 * 1024,
+ },
Prompt: PromptConfig{
Profile: "cognitive_reframing",
FallbackProfiles: []string{"toolbox_capability_expansion"},
@@ -440,11 +467,19 @@ func defaultConfig() AppConfig {
RetryOnAuthError: true,
AutoSwitch: true,
},
+ Dispatch: DispatchConfig{
+ ProbeCacheTTLSeconds: 45,
+ },
+ Debug: DebugConfig{
+ PprofEnabled: false,
+ PprofAddr: "127.0.0.1:6060",
+ },
Features: FeatureConfig{
UseWebSearch: true,
UseReadOnlyMode: false,
ForceDisableUpstreamEdits: false,
ForceFreshThreadPerRequest: false,
+ UseSurfHelperTransport: false,
WriterMode: false,
EnableGenerateImage: true,
EnableCsvAttachmentSupport: true,
@@ -500,6 +535,10 @@ func normalizeConfig(cfg AppConfig) AppConfig {
if cfg.PollMaxRounds <= 0 {
cfg.PollMaxRounds = 40
}
+ cfg.Debug.PprofAddr = strings.TrimSpace(cfg.Debug.PprofAddr)
+ if cfg.Debug.PprofAddr == "" {
+ cfg.Debug.PprofAddr = "127.0.0.1:6060"
+ }
if cfg.StreamChunkRunes <= 0 {
cfg.StreamChunkRunes = 24
}
@@ -512,6 +551,9 @@ func normalizeConfig(cfg AppConfig) AppConfig {
if cfg.Responses.StoreTTLSeconds <= 0 {
cfg.Responses.StoreTTLSeconds = 3600
}
+ if cfg.Limits.MaxRequestBodyBytes <= 0 {
+ cfg.Limits.MaxRequestBodyBytes = 4 * 1024 * 1024
+ }
cfg.Prompt.Profile = strings.TrimSpace(cfg.Prompt.Profile)
if cfg.Prompt.Profile == "" {
cfg.Prompt.Profile = "cognitive_reframing"
@@ -535,6 +577,7 @@ func normalizeConfig(cfg AppConfig) AppConfig {
cfg.Prompt.CodingRetryPrefixes = normalizePromptTextList(cfg.Prompt.CodingRetryPrefixes)
cfg.Prompt.GeneralRetryPrefixes = normalizePromptTextList(cfg.Prompt.GeneralRetryPrefixes)
cfg.Prompt.DirectAnswerRetryPrefixes = normalizePromptTextList(cfg.Prompt.DirectAnswerRetryPrefixes)
+ cfg.Prompt.precomputedAllRetryPrefixes = buildPromptGuardAllRetryPrefixes(cfg.Prompt)
cfg.Storage.SQLitePath = strings.TrimSpace(cfg.Storage.SQLitePath)
if cfg.Storage.SQLitePath == "" && strings.TrimSpace(cfg.ConfigPath) != "" {
cfg.Storage.SQLitePath = "data/notion2api.sqlite"
@@ -548,6 +591,15 @@ func normalizeConfig(cfg AppConfig) AppConfig {
if cfg.SessionRefresh.IntervalSec <= 0 {
cfg.SessionRefresh.IntervalSec = 900
}
+ if cfg.Dispatch.ProbeCacheTTLSeconds < 0 {
+ cfg.Dispatch.ProbeCacheTTLSeconds = 0
+ }
+ if cfg.Browser.HelperPoolSize < 0 {
+ cfg.Browser.HelperPoolSize = 0
+ }
+ if cfg.Browser.HelperPoolSize > 8 {
+ cfg.Browser.HelperPoolSize = 8
+ }
cfg.Features.SearchScopes = normalizeStringList(cfg.Features.SearchScopes)
cfg.Features.AISurface = strings.TrimSpace(cfg.Features.AISurface)
if cfg.Features.AISurface == "" {
@@ -566,6 +618,7 @@ func normalizeConfig(cfg AppConfig) AppConfig {
cfg.ActiveAccount = strings.TrimSpace(cfg.ActiveAccount)
for i := range cfg.Accounts {
cfg.Accounts[i].Email = strings.TrimSpace(cfg.Accounts[i].Email)
+ cfg.Accounts[i].emailKey = canonicalEmailKey(cfg.Accounts[i].Email)
cfg.Accounts[i].ProbeJSON = strings.TrimSpace(cfg.Accounts[i].ProbeJSON)
cfg.Accounts[i].ProfileDir = strings.TrimSpace(cfg.Accounts[i].ProfileDir)
cfg.Accounts[i].StorageStatePath = strings.TrimSpace(cfg.Accounts[i].StorageStatePath)
@@ -797,6 +850,9 @@ func parseCLI() AppConfig {
timeoutSec := flag.Int("timeout-sec", 0, "request timeout sec")
pollIntervalSec := flag.Float64("poll-interval-sec", 0, "poll interval sec")
pollMaxRounds := flag.Int("poll-max-rounds", 0, "poll max rounds")
+ pprofEnabled := flag.Bool("pprof-enabled", false, "enable pprof debug server")
+ pprofAddr := flag.String("pprof-addr", "", "pprof listen address")
+ maxRequestBodyBytes := flag.Int64("max-request-body-bytes", 0, "max request body size in bytes for JSON API endpoints")
userName := flag.String("user-name", "", "override user name")
spaceName := flag.String("space-name", "", "override space name")
flag.Parse()
@@ -870,6 +926,15 @@ func parseCLI() AppConfig {
if *pollMaxRounds > 0 {
cfg.PollMaxRounds = *pollMaxRounds
}
+ if *pprofEnabled {
+ cfg.Debug.PprofEnabled = true
+ }
+ if strings.TrimSpace(*pprofAddr) != "" {
+ cfg.Debug.PprofAddr = strings.TrimSpace(*pprofAddr)
+ }
+ if *maxRequestBodyBytes > 0 {
+ cfg.Limits.MaxRequestBodyBytes = *maxRequestBodyBytes
+ }
if strings.TrimSpace(*userName) != "" {
cfg.UserName = *userName
}
diff --git a/internal/app/conversations.go b/internal/app/conversations.go
index affb6d7..767a529 100644
--- a/internal/app/conversations.go
+++ b/internal/app/conversations.go
@@ -58,6 +58,7 @@ type ConversationEntry struct {
InputAttachments []ConversationAttachment `json:"input_attachments,omitempty"`
OutputAttachments []UploadedAttachment `json:"output_attachments,omitempty"`
Messages []ConversationMessage `json:"messages,omitempty"`
+ cachedPreview string `json:"-"`
}
type ConversationSummary struct {
@@ -133,6 +134,7 @@ func newConversationStoreFromEntries(entries []ConversationEntry) *ConversationS
store := newConversationStore()
for _, entry := range entries {
cloned := cloneConversationEntry(&entry)
+ refreshConversationDerivedFields(&cloned)
store.items[cloned.ID] = &cloned
store.order = append(store.order, cloned.ID)
}
@@ -285,17 +287,17 @@ func cloneConversationEntry(entry *ConversationEntry) ConversationEntry {
return out
}
+func copyConversationEntryValue(entry *ConversationEntry) ConversationEntry {
+ if entry == nil {
+ return ConversationEntry{}
+ }
+ return *entry
+}
+
func buildConversationSummary(entry *ConversationEntry) ConversationSummary {
- preview := ""
- for i := len(entry.Messages) - 1; i >= 0; i-- {
- text := collapseWhitespace(entry.Messages[i].Content)
- if text == "" && len(entry.Messages[i].Attachments) > 0 {
- text = fmt.Sprintf("%d attachments", len(entry.Messages[i].Attachments))
- }
- if text != "" {
- preview = truncateRunes(text, 96)
- break
- }
+ preview := entry.cachedPreview
+ if preview == "" && len(entry.Messages) > 0 {
+ preview = conversationPreviewFromMessages(entry.Messages)
}
return ConversationSummary{
ID: entry.ID,
@@ -327,6 +329,26 @@ func buildConversationSummary(entry *ConversationEntry) ConversationSummary {
}
}
+func conversationPreviewFromMessages(messages []ConversationMessage) string {
+ for i := len(messages) - 1; i >= 0; i-- {
+ text := collapseWhitespace(messages[i].Content)
+ if text == "" && len(messages[i].Attachments) > 0 {
+ text = fmt.Sprintf("%d attachments", len(messages[i].Attachments))
+ }
+ if text != "" {
+ return truncateRunes(text, 96)
+ }
+ }
+ return ""
+}
+
+func refreshConversationDerivedFields(entry *ConversationEntry) {
+ if entry == nil {
+ return
+ }
+ entry.cachedPreview = conversationPreviewFromMessages(entry.Messages)
+}
+
func conversationMessageSegments(entry *ConversationEntry) []conversationPromptSegment {
if entry == nil || len(entry.Messages) == 0 {
return nil
@@ -410,7 +432,7 @@ func (s *ConversationStore) Create(req ConversationCreateRequest) ConversationEn
if id == "" {
id = "conv_" + strings.ReplaceAll(randomUUID(), "-", "")
}
- entry := &ConversationEntry{
+ entry := ConversationEntry{
ID: id,
Title: conversationTitle(req.Prompt, req.InputAttachments),
Origin: "local",
@@ -440,17 +462,19 @@ func (s *ConversationStore) Create(req ConversationCreateRequest) ConversationEn
Attachments: cloneConversationAttachments(entry.InputAttachments),
})
}
+ refreshConversationDerivedFields(&entry)
s.mu.Lock()
if s.items[id] != nil {
id = "conv_" + strings.ReplaceAll(randomUUID(), "-", "")
entry.ID = id
}
- s.items[id] = entry
+ entryPtr := &entry
+ s.items[id] = entryPtr
s.order = append([]string{id}, s.order...)
s.trimLocked()
- cloned := cloneConversationEntry(entry)
- summary := buildConversationSummary(entry)
+ cloned := copyConversationEntryValue(entryPtr)
+ summary := buildConversationSummary(entryPtr)
s.mu.Unlock()
s.broadcast(ConversationEvent{
@@ -458,7 +482,7 @@ func (s *ConversationStore) Create(req ConversationCreateRequest) ConversationEn
ConversationID: id,
At: now,
Summary: &summary,
- Conversation: &cloned,
+ Conversation: entryPtr,
})
return cloned
}
@@ -469,39 +493,41 @@ func (s *ConversationStore) Continue(conversationID string, req ConversationCrea
cloned ConversationEntry
summary ConversationSummary
ok bool
+ entry *ConversationEntry
)
s.mu.Lock()
- entry := s.items[conversationID]
- if entry != nil {
- entry.Source = firstNonEmpty(req.Source, entry.Source)
- entry.Transport = firstNonEmpty(req.Transport, entry.Transport)
+ current := s.items[conversationID]
+ if current != nil {
+ next := cloneConversationEntry(current)
+ next.Source = firstNonEmpty(req.Source, next.Source)
+ next.Transport = firstNonEmpty(req.Transport, next.Transport)
if req.Ephemeral {
- entry.Ephemeral = true
- entry.EphemeralReason = firstNonEmpty(strings.TrimSpace(req.EphemeralReason), entry.EphemeralReason)
+ next.Ephemeral = true
+ next.EphemeralReason = firstNonEmpty(strings.TrimSpace(req.EphemeralReason), next.EphemeralReason)
if !req.AutoDeleteAt.IsZero() {
- entry.AutoDeleteAt = timePointer(req.AutoDeleteAt)
+ next.AutoDeleteAt = timePointer(req.AutoDeleteAt)
}
}
if clean := strings.TrimSpace(req.Model); clean != "" {
- entry.Model = clean
+ next.Model = clean
}
if clean := strings.TrimSpace(req.NotionModel); clean != "" {
- entry.NotionModel = clean
+ next.NotionModel = clean
}
- entry.UseWebSearch = req.UseWebSearch
- entry.Status = "running"
- entry.Error = ""
- entry.InputAttachments = cloneConversationAttachments(req.InputAttachments)
- entry.UpdatedAt = now
- if len(entry.Messages) > 0 {
- last := &entry.Messages[len(entry.Messages)-1]
+ next.UseWebSearch = req.UseWebSearch
+ next.Status = "running"
+ next.Error = ""
+ next.InputAttachments = cloneConversationAttachments(req.InputAttachments)
+ next.UpdatedAt = now
+ if len(next.Messages) > 0 {
+ last := &next.Messages[len(next.Messages)-1]
if last.Role == "assistant" && last.Status != "completed" {
last.Status = "failed"
last.UpdatedAt = now
}
}
if strings.TrimSpace(req.Prompt) != "" || len(req.InputAttachments) > 0 {
- entry.Messages = append(entry.Messages, ConversationMessage{
+ next.Messages = append(next.Messages, ConversationMessage{
ID: "msg_user_" + strings.ReplaceAll(randomUUID(), "-", ""),
Role: "user",
Status: "completed",
@@ -511,8 +537,11 @@ func (s *ConversationStore) Continue(conversationID string, req ConversationCrea
Attachments: cloneConversationAttachments(req.InputAttachments),
})
}
+ refreshConversationDerivedFields(&next)
+ entry = &next
+ s.items[conversationID] = entry
s.moveToFrontLocked(conversationID)
- cloned = cloneConversationEntry(entry)
+ cloned = copyConversationEntryValue(entry)
summary = buildConversationSummary(entry)
ok = true
}
@@ -525,7 +554,7 @@ func (s *ConversationStore) Continue(conversationID string, req ConversationCrea
ConversationID: conversationID,
At: now,
Summary: &summary,
- Conversation: &cloned,
+ Conversation: entry,
})
return cloned, nil
}
@@ -553,17 +582,22 @@ func (s *ConversationStore) SetEnvelopeIDs(conversationID string, responseID str
var (
summary ConversationSummary
ok bool
+ entry *ConversationEntry
)
s.mu.Lock()
- entry := s.items[conversationID]
- if entry != nil {
+ current := s.items[conversationID]
+ if current != nil {
+ next := cloneConversationEntry(current)
if strings.TrimSpace(responseID) != "" {
- entry.ResponseID = strings.TrimSpace(responseID)
+ next.ResponseID = strings.TrimSpace(responseID)
}
if strings.TrimSpace(completionID) != "" {
- entry.CompletionID = strings.TrimSpace(completionID)
+ next.CompletionID = strings.TrimSpace(completionID)
}
- entry.UpdatedAt = now
+ next.UpdatedAt = now
+ refreshConversationDerivedFields(&next)
+ entry = &next
+ s.items[conversationID] = entry
s.moveToFrontLocked(conversationID)
summary = buildConversationSummary(entry)
ok = true
@@ -575,6 +609,7 @@ func (s *ConversationStore) SetEnvelopeIDs(conversationID string, responseID str
ConversationID: conversationID,
At: now,
Summary: &summary,
+ Conversation: entry,
})
}
}
@@ -587,21 +622,26 @@ func (s *ConversationStore) AppendAssistantDelta(conversationID string, delta st
now := time.Now().UTC()
var (
summary ConversationSummary
- msg ConversationMessage
+ msg *ConversationMessage
ok bool
+ entry *ConversationEntry
)
s.mu.Lock()
- entry := s.items[conversationID]
- if entry != nil {
- assistant := s.ensureAssistantMessageLocked(entry, now)
+ current := s.items[conversationID]
+ if current != nil {
+ next := cloneConversationEntry(current)
+ assistant := s.ensureAssistantMessageLocked(&next, now)
assistant.Content += delta
assistant.Status = "streaming"
assistant.UpdatedAt = now
- entry.Status = "running"
- entry.UpdatedAt = now
+ next.Status = "running"
+ next.UpdatedAt = now
+ refreshConversationDerivedFields(&next)
+ entry = &next
+ s.items[conversationID] = entry
s.moveToFrontLocked(conversationID)
summary = buildConversationSummary(entry)
- msg = cloneConversationMessage(*assistant)
+ msg = assistant
ok = true
}
s.mu.Unlock()
@@ -612,7 +652,8 @@ func (s *ConversationStore) AppendAssistantDelta(conversationID string, delta st
At: now,
Delta: delta,
Summary: &summary,
- Message: &msg,
+ Conversation: entry,
+ Message: msg,
})
}
}
@@ -620,46 +661,49 @@ func (s *ConversationStore) AppendAssistantDelta(conversationID string, delta st
func (s *ConversationStore) Complete(conversationID string, result InferenceResult) {
now := time.Now().UTC()
var (
- cloned ConversationEntry
summary ConversationSummary
ok bool
+ entry *ConversationEntry
)
s.mu.Lock()
- entry := s.items[conversationID]
- if entry != nil {
- entry.Status = "completed"
- entry.UpdatedAt = now
- if entry.Ephemeral {
- entry.AutoDeleteAt = timePointer(now.Add(sillyTavernQuietConversationTTL))
+ current := s.items[conversationID]
+ if current != nil {
+ next := cloneConversationEntry(current)
+ next.Status = "completed"
+ next.UpdatedAt = now
+ if next.Ephemeral {
+ next.AutoDeleteAt = timePointer(now.Add(sillyTavernQuietConversationTTL))
}
- entry.ThreadID = strings.TrimSpace(result.ThreadID)
- entry.TraceID = strings.TrimSpace(result.TraceID)
- entry.MessageID = strings.TrimSpace(result.MessageID)
- entry.AccountEmail = strings.TrimSpace(result.AccountEmail)
- entry.Error = ""
- entry.OutputAttachments = cloneUploadedAttachments(result.Attachments)
- assistant := s.ensureAssistantMessageLocked(entry, now)
+ next.ThreadID = strings.TrimSpace(result.ThreadID)
+ next.TraceID = strings.TrimSpace(result.TraceID)
+ next.MessageID = strings.TrimSpace(result.MessageID)
+ next.AccountEmail = strings.TrimSpace(result.AccountEmail)
+ next.Error = ""
+ next.OutputAttachments = cloneUploadedAttachments(result.Attachments)
+ assistant := s.ensureAssistantMessageLocked(&next, now)
assistant.Status = "completed"
assistant.Content = sanitizeAssistantVisibleText(result.Text)
assistant.Attachments = summarizeUploadedAttachments(result.Attachments)
assistant.UpdatedAt = now
- if len(entry.Messages) > 0 {
- entry.Messages[len(entry.Messages)-1] = cloneConversationMessage(*assistant)
+ if len(next.Messages) > 0 {
+ next.Messages[len(next.Messages)-1] = cloneConversationMessage(*assistant)
}
+ refreshConversationDerivedFields(&next)
+ entry = &next
+ s.items[conversationID] = entry
s.moveToFrontLocked(conversationID)
- cloned = cloneConversationEntry(entry)
summary = buildConversationSummary(entry)
ok = true
}
s.mu.Unlock()
if ok {
s.broadcast(ConversationEvent{
- Type: "conversation.completed",
- ConversationID: conversationID,
- At: now,
- Summary: &summary,
- Conversation: &cloned,
- })
+ Type: "conversation.completed",
+ ConversationID: conversationID,
+ At: now,
+ Summary: &summary,
+ Conversation: entry,
+ })
}
}
@@ -670,41 +714,44 @@ func (s *ConversationStore) Fail(conversationID string, err error) {
now := time.Now().UTC()
message := strings.TrimSpace(err.Error())
var (
- cloned ConversationEntry
summary ConversationSummary
ok bool
+ entry *ConversationEntry
)
s.mu.Lock()
- entry := s.items[conversationID]
- if entry != nil {
- entry.Status = "failed"
- entry.Error = message
- entry.UpdatedAt = now
- if entry.Ephemeral {
- entry.AutoDeleteAt = timePointer(now.Add(sillyTavernQuietConversationTTL))
+ current := s.items[conversationID]
+ if current != nil {
+ next := cloneConversationEntry(current)
+ next.Status = "failed"
+ next.Error = message
+ next.UpdatedAt = now
+ if next.Ephemeral {
+ next.AutoDeleteAt = timePointer(now.Add(sillyTavernQuietConversationTTL))
}
- if len(entry.Messages) > 0 {
- last := &entry.Messages[len(entry.Messages)-1]
+ if len(next.Messages) > 0 {
+ last := &next.Messages[len(next.Messages)-1]
if last.Role == "assistant" && last.Status != "completed" {
last.Status = "failed"
last.UpdatedAt = now
}
}
+ refreshConversationDerivedFields(&next)
+ entry = &next
+ s.items[conversationID] = entry
s.moveToFrontLocked(conversationID)
- cloned = cloneConversationEntry(entry)
summary = buildConversationSummary(entry)
ok = true
}
s.mu.Unlock()
if ok {
s.broadcast(ConversationEvent{
- Type: "conversation.failed",
- ConversationID: conversationID,
- At: now,
- Error: message,
- Summary: &summary,
- Conversation: &cloned,
- })
+ Type: "conversation.failed",
+ ConversationID: conversationID,
+ At: now,
+ Error: message,
+ Summary: &summary,
+ Conversation: entry,
+ })
}
}
@@ -761,7 +808,7 @@ func (s *ConversationStore) ListExpiredEphemeral(now time.Time, limit int) []Con
if entry.AutoDeleteAt == nil || entry.AutoDeleteAt.After(now) {
continue
}
- items = append(items, cloneConversationEntry(entry))
+ items = append(items, copyConversationEntryValue(entry))
if len(items) >= limit {
break
}
@@ -776,8 +823,7 @@ func (s *ConversationStore) Get(conversationID string) (ConversationEntry, bool)
if entry == nil {
return ConversationEntry{}, false
}
- cloned := cloneConversationEntry(entry)
- return cloned, true
+ return copyConversationEntryValue(entry), true
}
func (s *ConversationStore) FindByThreadID(threadID string) (ConversationEntry, bool) {
@@ -795,8 +841,7 @@ func (s *ConversationStore) FindByThreadID(threadID string) (ConversationEntry,
if strings.TrimSpace(entry.ThreadID) != threadID {
continue
}
- cloned := cloneConversationEntry(entry)
- return cloned, true
+ return copyConversationEntryValue(entry), true
}
return ConversationEntry{}, false
}
@@ -820,8 +865,7 @@ func (s *ConversationStore) FindContinuationBySegments(history []conversationPro
if !conversationSegmentsMatchSuffix(entrySegments, normalizedHistory) {
continue
}
- cloned := cloneConversationEntry(entry)
- return cloned, true
+ return copyConversationEntryValue(entry), true
}
return ConversationEntry{}, false
}
@@ -881,16 +925,18 @@ func (s *ServerState) deleteResponsesByConversationOrThread(conversationID strin
return
}
s.mu.Lock()
- for id, item := range s.ResponsesByID {
- if (conversationID != "" && strings.TrimSpace(item.ConversationID) == conversationID) ||
- (threadID != "" && strings.TrimSpace(item.ThreadID) == threadID) {
- delete(s.ResponsesByID, id)
- }
+ if s.ResponseStore != nil {
+ s.ResponseStore.deleteByConversationOrThread(conversationID, threadID)
}
+ sqliteWriter := s.sqliteWriter
store := s.Store
storeEnabled := store != nil && responsesPersistenceEnabled(s.Config)
s.mu.Unlock()
if storeEnabled {
+ if sqliteWriter != nil {
+ sqliteWriter.EnqueueDeleteResponsesByConversationOrThread(conversationID, threadID)
+ return
+ }
if err := store.DeleteResponsesByConversationOrThread(conversationID, threadID); err != nil {
log.Printf("[sqlite] delete responses conversation=%s thread=%s failed: %v", conversationID, threadID, err)
}
diff --git a/internal/app/httpclient_audit.md b/internal/app/httpclient_audit.md
new file mode 100644
index 0000000..34880ef
--- /dev/null
+++ b/internal/app/httpclient_audit.md
@@ -0,0 +1,69 @@
+# T-4-2 HTTP Client / Transport Audit
+
+Date: 2026-05-02
+
+## Scope checked
+
+- `internal/app/notion_client.go`
+- `internal/app/login_helper.go`
+- `internal/app/notion_client_login_transport.go`
+- `internal/app/account_discovery.go`
+- `internal/app/session_refresh.go`
+
+## Findings
+
+### 1) NotionAI request path creates new `http.Client`/`Transport` per `NotionAIClient`
+
+- Location: `internal/app/notion_client.go:newNotionAIClientWithMode`
+- Behavior:
+ - Builds a fresh `http.Transport` and `http.Client` every time a `NotionAIClient` is created.
+ - In dispatch paths (`runPromptWithSession*`) this can happen frequently, so connection pools are not reused across those client instances.
+- Impact:
+ - Potentially higher connect/TLS handshake overhead under sustained traffic.
+ - Extra pressure on upstream and local sockets due to fragmented pools.
+
+### 2) Login helper path also creates fresh `http.Client`
+
+- Location: `internal/app/login_helper.go:newNotionLoginSession`
+- Behavior:
+ - Creates a new cookie jar and `http.Client` per login session call.
+- Notes:
+ - This path is less hot than inference path, but still relevant for repeated refresh/login workflows.
+
+### 3) Proxy/header behavior correctness constraints
+
+- Proxy resolution and resin headers are request/account dependent:
+ - `ProxyResolver.ResolveProxyForRequest(accountEmail, targetURL)` can vary by account/policy.
+ - `postJSONResponse` overlays per-request proxy headers (e.g. resin account header).
+- Any reuse strategy must preserve:
+ - account-aware proxy resolution
+ - per-request header injection behavior
+ - stream vs non-stream timeout difference
+
+## Recommendation
+
+Introduce a transport cache in `internal/app/notion_client.go`:
+
+- Cache key dimensions:
+ - normalized upstream base/origin/host/tls server name
+ - proxy mode + proxy urls + resin settings
+ - account email key (for account-specific proxy routing)
+ - streaming flag is **not** required in transport key (timeout is on `http.Client`, not transport)
+- Cache value:
+ - reusable `*http.Transport`
+- Then construct short-lived `http.Client` wrappers over cached transport:
+ - standard client timeout = request timeout
+ - streaming client timeout = 0
+- Add evidence:
+ - metric for transport/client creation count
+ - benchmark around repeated client creation path if needed
+
+## Current status
+
+- Updated 2026-05-02 follow-up:
+ - Implemented transport cache in `newNotionAIClientWithMode` via keyed map + RWMutex.
+ - Added runtime visibility metric: `notion2api_http_transport_cache_total` (`hit_rlock`, `hit_lock`, `miss_new`) exposed through `/debug/vars`.
+ - Added tests validating:
+ - same account/config => transport reuse
+ - different account proxy policy => transport separation
+ - Added benchmark `BenchmarkNewNotionAIClientWithModeTransportCache` showing warm-cache path lower alloc/op and ns/op than forced cold-cache path.
diff --git a/internal/app/login_helper.go b/internal/app/login_helper.go
index b2e3a02..2d967a5 100644
--- a/internal/app/login_helper.go
+++ b/internal/app/login_helper.go
@@ -162,17 +162,18 @@ func writeLoginStorageState(path string, payload loginStorageState) error {
return writePrettyJSONFile(path, payload)
}
-func newNotionLoginSession(timeout time.Duration, upstream NotionUpstream, resolver *ProxyResolver, accountEmail string) (*loginHTTPSession, error) {
+func newNotionLoginSession(timeout time.Duration, upstream NotionUpstream, resolver *ProxyResolver, accountEmail string, cfg AppConfig) (*loginHTTPSession, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &loginHTTPSession{
- Client: &http.Client{Timeout: timeout, Jar: jar},
- ProxyResolver: resolver,
- AccountEmail: strings.TrimSpace(accountEmail),
- Timeout: timeout,
- Upstream: upstream,
+ Client: &http.Client{Timeout: timeout, Jar: jar},
+ ProxyResolver: resolver,
+ AccountEmail: strings.TrimSpace(accountEmail),
+ Timeout: timeout,
+ Upstream: upstream,
+ UseSurfHelperTransport: cfg.Features.UseSurfHelperTransport,
}, nil
}
@@ -348,7 +349,7 @@ func fetchLoginBootstrap(ctx context.Context, session *loginHTTPSession, upstrea
"accept-language": "zh-CN,zh;q=0.9",
"user-agent": notionLoginUA,
}
- status, respHeaders, body, err := loginWreqDoRequest(ctx, session, http.MethodGet, upstream.LoginURL(), headers, nil)
+ status, respHeaders, body, err := loginTransportDoRequest(ctx, session, http.MethodGet, upstream.LoginURL(), headers, nil)
if err != nil {
return loginBootstrap{}, err
}
@@ -395,7 +396,7 @@ func postNotionLoginJSON(ctx context.Context, session *loginHTTPSession, upstrea
"notion-audit-log-platform": "web",
"x-notion-active-user-header": strings.TrimSpace(activeUserID),
}
- status, respHeaders, respBody, err := loginWreqDoRequest(ctx, session, http.MethodPost, targetURL, headers, body)
+ status, respHeaders, respBody, err := loginTransportDoRequest(ctx, session, http.MethodPost, targetURL, headers, body)
if err != nil {
return nil, err
}
@@ -613,7 +614,7 @@ func StartEmailLogin(ctx context.Context, cfg AppConfig, req LoginStartRequest)
upstream := cfg.NotionUpstream()
resolver := NewProxyResolver(cfg)
- session, err := newNotionLoginSession(helperTimeout(cfg), upstream, resolver, firstNonEmpty(req.AccountEmail, req.Email))
+ session, err := newNotionLoginSession(helperTimeout(cfg), upstream, resolver, firstNonEmpty(req.AccountEmail, req.Email), cfg)
if err != nil {
return failLoginState(req.PendingPath, state, err)
}
@@ -682,7 +683,7 @@ func VerifyEmailLogin(ctx context.Context, cfg AppConfig, req LoginVerifyRequest
upstream := cfg.NotionUpstream()
resolver := NewProxyResolver(cfg)
- session, err := newNotionLoginSession(helperTimeout(cfg), upstream, resolver, firstNonEmpty(req.AccountEmail, req.Email))
+ session, err := newNotionLoginSession(helperTimeout(cfg), upstream, resolver, firstNonEmpty(req.AccountEmail, req.Email), cfg)
if err != nil {
return failLoginState(req.PendingPath, pending, err)
}
diff --git a/internal/app/main.go b/internal/app/main.go
index 5e96d4a..278b4bc 100644
--- a/internal/app/main.go
+++ b/internal/app/main.go
@@ -1,14 +1,20 @@
package app
import (
+ "bytes"
"context"
"encoding/json"
+ "errors"
+ "expvar"
"fmt"
+ "io"
"log"
"net/http"
+ _ "net/http/pprof"
"runtime/debug"
"strings"
"sync"
+ "sync/atomic"
"time"
)
@@ -20,21 +26,35 @@ type StoredResponse struct {
AccountEmail string
}
+type snapshotBundle struct {
+ Config AppConfig
+ Session SessionInfo
+ ModelRegistry ModelRegistry
+ DispatchOrder []NotionAccount
+}
+
type ServerState struct {
- mu sync.RWMutex
- refreshMu sync.Mutex
- Config AppConfig
- Session SessionInfo
- Client *NotionAIClient
- Store *SQLiteStore
- ModelRegistry ModelRegistry
- ResponsesByID map[string]StoredResponse
- Conversations *ConversationStore
- AdminTokens map[string]time.Time
- AdminLoginAttempts map[string]AdminLoginAttempt
- AccountDispatchSlots map[string]accountDispatchState
- LastSessionRefresh time.Time
- LastSessionRefreshError string
+ mu sync.RWMutex
+ refreshMu sync.Mutex
+ Config AppConfig
+ Session SessionInfo
+ Client *NotionAIClient
+ Store *SQLiteStore
+ ModelRegistry ModelRegistry
+ ResponseStore *responseStore
+ Conversations *ConversationStore
+ AdminTokens map[string]time.Time
+ AdminLoginAttempts map[string]AdminLoginAttempt
+ DispatchProbeCache *probeCache
+ LastSessionRefresh time.Time
+ LastSessionRefreshError string
+ responseStoreCleanupCancel context.CancelFunc
+ sqliteWriter *SQLiteWriter
+ snap atomic.Pointer[snapshotBundle]
+ slots atomic.Pointer[map[string]*accountSlot]
+ cachedHealthzStaticJSON atomic.Pointer[[]byte]
+ cachedModelsListJSON atomic.Pointer[[]byte]
+ cachedModelByIDJSON atomic.Pointer[map[string][]byte]
}
type accountDispatchState struct {
@@ -42,6 +62,38 @@ type accountDispatchState struct {
InFlight int
}
+type accountSlot struct {
+ max atomic.Int32
+ inflight atomic.Int32
+}
+
+type healthzStaticPayload struct {
+ OK bool `json:"ok"`
+ DefaultModel string `json:"default_model"`
+ ModelCount int `json:"model_count"`
+ UserEmail string `json:"user_email"`
+ SpaceID string `json:"space_id"`
+ ActiveAccount string `json:"active_account"`
+ SessionRefreshEnable bool `json:"session_refresh_enabled"`
+}
+
+type publicModelPayload struct {
+ ID string `json:"id"`
+ Object string `json:"object"`
+ Created int `json:"created"`
+ OwnedBy string `json:"owned_by"`
+ Name string `json:"name"`
+ Family string `json:"family"`
+ Group string `json:"group"`
+ Beta bool `json:"beta"`
+ NotionModel string `json:"notion_model"`
+}
+
+type publicModelsListPayload struct {
+ Object string `json:"object"`
+ Data []publicModelPayload `json:"data"`
+}
+
type App struct {
State *ServerState
runPromptOverride func(*http.Request, PromptRunRequest) (InferenceResult, error)
@@ -56,8 +108,15 @@ const (
ephemeralConversationCleanupInterval = time.Minute
ephemeralConversationCleanupBatchSize = 24
sillyTavernQuietConversationTTL = 10 * time.Minute
+ corsAllowOrigin = "*"
+ corsAllowHeaders = "Authorization, Content-Type, X-Admin-Token"
+ corsAllowMethods = "GET, POST, PUT, DELETE, OPTIONS"
)
+var errRequestTooLarge = errors.New("request body too large")
+var responseStorePruneTotalMetric = expvar.NewMap("notion2api_response_store_prune_total")
+var testHookResponseStoreCleanupInterval time.Duration
+
type continuationTarget struct {
Conversation ConversationEntry
Session *conversationContinuationState
@@ -112,28 +171,68 @@ func normalizeAccountMaxConcurrency(raw int) int {
return raw
}
-func (s *ServerState) initializeAccountDispatchSlotsLocked() {
- if s.AccountDispatchSlots == nil {
- s.AccountDispatchSlots = map[string]accountDispatchState{}
+func clampSlotInFlight(slot *accountSlot, max int32) int32 {
+ if slot == nil {
+ return 0
+ }
+ if max <= 0 {
+ max = 1
+ }
+ for {
+ current := slot.inflight.Load()
+ if current < 0 {
+ if slot.inflight.CompareAndSwap(current, 0) {
+ return 0
+ }
+ continue
+ }
+ if current <= max {
+ return current
+ }
+ if slot.inflight.CompareAndSwap(current, max) {
+ return max
+ }
}
- next := map[string]accountDispatchState{}
+}
+
+func (s *ServerState) rebuildAccountSlotsLocked() {
+ if s == nil {
+ return
+ }
+ var previous map[string]*accountSlot
+ if loaded := s.slots.Load(); loaded != nil {
+ previous = *loaded
+ }
+ next := make(map[string]*accountSlot, len(s.Config.Accounts))
for _, account := range s.Config.Accounts {
- emailKey := canonicalEmailKey(account.Email)
+ emailKey := getAccountEmailKey(account)
if emailKey == "" {
continue
}
- maxConcurrency := normalizeAccountMaxConcurrency(account.MaxConcurrency)
- state := s.AccountDispatchSlots[emailKey]
- state.MaxConcurrency = maxConcurrency
- if state.InFlight < 0 {
- state.InFlight = 0
- }
- if state.InFlight > state.MaxConcurrency {
- state.InFlight = state.MaxConcurrency
+ maxConcurrency := int32(normalizeAccountMaxConcurrency(account.MaxConcurrency))
+ if existing := previous[emailKey]; existing != nil {
+ existing.max.Store(maxConcurrency)
+ clampSlotInFlight(existing, maxConcurrency)
+ next[emailKey] = existing
+ continue
}
- next[emailKey] = state
+ slot := &accountSlot{}
+ slot.max.Store(maxConcurrency)
+ next[emailKey] = slot
+ }
+ s.slots.Store(&next)
+ syncDispatchSlotInflightFromSlots(next)
+}
+
+func (s *ServerState) loadAccountSlots() map[string]*accountSlot {
+ if s == nil {
+ return nil
}
- s.AccountDispatchSlots = next
+ loaded := s.slots.Load()
+ if loaded == nil {
+ return nil
+ }
+ return *loaded
}
func (s *ServerState) TryAcquireAccountDispatchSlot(email string) bool {
@@ -141,19 +240,24 @@ func (s *ServerState) TryAcquireAccountDispatchSlot(email string) bool {
if emailKey == "" {
return false
}
- s.mu.Lock()
- defer s.mu.Unlock()
- s.initializeAccountDispatchSlotsLocked()
- state, ok := s.AccountDispatchSlots[emailKey]
- if !ok {
+ slot := s.loadAccountSlots()[emailKey]
+ if slot == nil {
return false
}
- if state.InFlight >= state.MaxConcurrency {
- return false
+ for {
+ maxConcurrency := slot.max.Load()
+ if maxConcurrency <= 0 {
+ maxConcurrency = 1
+ }
+ inflight := slot.inflight.Load()
+ if inflight >= maxConcurrency {
+ return false
+ }
+ if slot.inflight.CompareAndSwap(inflight, inflight+1) {
+ setDispatchSlotInflight(emailKey, int(inflight+1))
+ return true
+ }
}
- state.InFlight++
- s.AccountDispatchSlots[emailKey] = state
- return true
}
func (s *ServerState) ReleaseAccountDispatchSlot(email string) {
@@ -161,19 +265,21 @@ func (s *ServerState) ReleaseAccountDispatchSlot(email string) {
if emailKey == "" {
return
}
- s.mu.Lock()
- defer s.mu.Unlock()
- if s.AccountDispatchSlots == nil {
- return
- }
- state, ok := s.AccountDispatchSlots[emailKey]
- if !ok {
+ slot := s.loadAccountSlots()[emailKey]
+ if slot == nil {
return
}
- if state.InFlight > 0 {
- state.InFlight--
+ for {
+ inflight := slot.inflight.Load()
+ if inflight <= 0 {
+ setDispatchSlotInflight(emailKey, 0)
+ return
+ }
+ if slot.inflight.CompareAndSwap(inflight, inflight-1) {
+ setDispatchSlotInflight(emailKey, int(inflight-1))
+ return
+ }
}
- s.AccountDispatchSlots[emailKey] = state
}
func (s *ServerState) RemainingAccountDispatchSlots(email string) int {
@@ -181,24 +287,27 @@ func (s *ServerState) RemainingAccountDispatchSlots(email string) int {
if emailKey == "" {
return 0
}
- s.mu.Lock()
- defer s.mu.Unlock()
- s.initializeAccountDispatchSlotsLocked()
- state, ok := s.AccountDispatchSlots[emailKey]
- if !ok {
+ slot := s.loadAccountSlots()[emailKey]
+ if slot == nil {
return 0
}
- remaining := state.MaxConcurrency - state.InFlight
+ maxConcurrency := slot.max.Load()
+ if maxConcurrency <= 0 {
+ maxConcurrency = 1
+ }
+ inflight := slot.inflight.Load()
+ remaining := int(maxConcurrency - inflight)
if remaining < 0 {
- remaining = 0
+ return 0
}
return remaining
}
func (s *ServerState) AvailableDispatchCapacity(emails []string) int {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.initializeAccountDispatchSlotsLocked()
+ slots := s.loadAccountSlots()
+ if len(slots) == 0 {
+ return 0
+ }
total := 0
seen := map[string]struct{}{}
for _, email := range emails {
@@ -210,11 +319,16 @@ func (s *ServerState) AvailableDispatchCapacity(emails []string) int {
continue
}
seen[emailKey] = struct{}{}
- state, ok := s.AccountDispatchSlots[emailKey]
- if !ok {
+ slot := slots[emailKey]
+ if slot == nil {
continue
}
- remaining := state.MaxConcurrency - state.InFlight
+ maxConcurrency := slot.max.Load()
+ if maxConcurrency <= 0 {
+ maxConcurrency = 1
+ }
+ inflight := slot.inflight.Load()
+ remaining := int(maxConcurrency - inflight)
if remaining > 0 {
total += remaining
}
@@ -223,17 +337,31 @@ func (s *ServerState) AvailableDispatchCapacity(emails []string) int {
}
func (s *ServerState) AccountDispatchSnapshot() map[string]accountDispatchState {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.initializeAccountDispatchSlotsLocked()
- out := make(map[string]accountDispatchState, len(s.AccountDispatchSlots))
- for key, value := range s.AccountDispatchSlots {
- out[key] = value
+ slots := s.loadAccountSlots()
+ out := make(map[string]accountDispatchState, len(slots))
+ for key, slot := range slots {
+ if slot == nil {
+ continue
+ }
+ maxConcurrency := int(slot.max.Load())
+ if maxConcurrency <= 0 {
+ maxConcurrency = 1
+ }
+ inflight := int(slot.inflight.Load())
+ if inflight < 0 {
+ inflight = 0
+ }
+ if inflight > maxConcurrency {
+ inflight = maxConcurrency
+ }
+ out[key] = accountDispatchState{
+ MaxConcurrency: maxConcurrency,
+ InFlight: inflight,
+ }
}
return out
}
-
func maxFloat(a float64, b float64) float64 {
if a > b {
return a
@@ -265,13 +393,13 @@ func newServerState(cfg AppConfig) (*ServerState, error) {
return nil, err
}
state := &ServerState{
- ResponsesByID: map[string]StoredResponse{},
Conversations: newConversationStore(),
AdminTokens: map[string]time.Time{},
AdminLoginAttempts: map[string]AdminLoginAttempt{},
- AccountDispatchSlots: map[string]accountDispatchState{},
+ DispatchProbeCache: newProbeCache(),
Store: store,
}
+ state.ResponseStore = newResponseStore(time.Duration(maxInt(cfg.Responses.StoreTTLSeconds, 1)) * time.Second)
persistedAccountsLoaded := false
if store != nil {
accounts, activeAccount, ok, loadErr := store.LoadAccounts()
@@ -305,7 +433,10 @@ func newServerState(cfg AppConfig) (*ServerState, error) {
_ = store.Close()
return nil, loadErr
}
- state.ResponsesByID = responses
+ if state.ResponseStore == nil {
+ state.ResponseStore = newResponseStore(time.Duration(maxInt(state.Config.Responses.StoreTTLSeconds, 1)) * time.Second)
+ }
+ state.ResponseStore.replaceAll(responses)
}
if conversationSnapshotsPersistenceEnabled(state.Config) {
conversations, loadErr := store.LoadConversations()
@@ -321,7 +452,9 @@ func newServerState(cfg AppConfig) (*ServerState, error) {
return nil, saveErr
}
}
+ state.sqliteWriter = newSQLiteWriter(store, time.Duration(maxInt(state.Config.Responses.StoreTTLSeconds, 1))*time.Second)
}
+ state.startResponseStoreCleanupLoop(context.Background())
return state, nil
}
@@ -352,15 +485,42 @@ func (s *ServerState) ApplyConfig(cfg AppConfig) error {
s.Session = session
s.ModelRegistry = registry
s.Client = client
+ if s.sqliteWriter != nil {
+ s.sqliteWriter.SetTTL(time.Duration(maxInt(cfg.Responses.StoreTTLSeconds, 1)) * time.Second)
+ }
+ s.rebuildAccountSlotsLocked()
+ s.updateSnapshotBundleLocked()
+ s.rebuildStaticJSONCachesLocked()
return nil
}
func (s *ServerState) Snapshot() (AppConfig, SessionInfo, ModelRegistry) {
+ if s == nil {
+ return AppConfig{}, SessionInfo{}, ModelRegistry{}
+ }
+ if snap := s.snap.Load(); snap != nil {
+ return snap.Config, snap.Session, snap.ModelRegistry
+ }
s.mu.RLock()
defer s.mu.RUnlock()
return s.Config, s.Session, s.ModelRegistry
}
+func (s *ServerState) updateSnapshotBundleLocked() {
+ if s == nil {
+ return
+ }
+ now := time.Now()
+ dispatchOrder := buildDispatchCandidateOrder(s.Config, now)
+ bundle := &snapshotBundle{
+ Config: s.Config,
+ Session: s.Session,
+ ModelRegistry: s.ModelRegistry,
+ DispatchOrder: dispatchOrder,
+ }
+ s.snap.Store(bundle)
+}
+
func (s *ServerState) SaveAndApply(cfg AppConfig) error {
cfg = normalizeConfig(cfg)
if err := validateConfiguredAPIKey(cfg); err != nil {
@@ -382,6 +542,17 @@ func (s *ServerState) SaveAndApply(cfg AppConfig) error {
return err
}
}
+ s.mu.Lock()
+ if s.ResponseStore == nil {
+ s.ResponseStore = newResponseStore(time.Duration(maxInt(cfg.Responses.StoreTTLSeconds, 1)) * time.Second)
+ } else {
+ s.ResponseStore.setTTL(time.Duration(maxInt(cfg.Responses.StoreTTLSeconds, 1)) * time.Second)
+ }
+ s.updateSnapshotBundleLocked()
+ s.mu.Unlock()
+ if canonicalEmailKey(current.ActiveAccount) != canonicalEmailKey(cfg.ActiveAccount) && s.DispatchProbeCache != nil {
+ s.DispatchProbeCache.invalidateAll()
+ }
return nil
}
@@ -394,16 +565,6 @@ func (s *ServerState) conversationPersistenceStore() *SQLiteStore {
return s.Store
}
-func (s *ServerState) cleanupExpiredResponsesLocked(now time.Time) {
- ttlSeconds := maxInt(s.Config.Responses.StoreTTLSeconds, 1)
- ttl := time.Duration(ttlSeconds) * time.Second
- for id, item := range s.ResponsesByID {
- if now.Sub(item.CreatedAt) > ttl {
- delete(s.ResponsesByID, id)
- }
- }
-}
-
func (s *ServerState) saveResponse(responseID string, payload map[string]any, conversationID string, threadID string) {
s.saveResponseWithAccount(responseID, payload, conversationID, threadID, "")
}
@@ -411,24 +572,33 @@ func (s *ServerState) saveResponse(responseID string, payload map[string]any, co
func (s *ServerState) saveResponseWithAccount(responseID string, payload map[string]any, conversationID string, threadID string, accountEmail string) {
now := time.Now().UTC()
s.mu.Lock()
- s.cleanupExpiredResponsesLocked(now)
- s.ResponsesByID[responseID] = StoredResponse{
+ store := s.ResponseStore
+ if store == nil {
+ store = newResponseStore(time.Duration(maxInt(s.Config.Responses.StoreTTLSeconds, 1)) * time.Second)
+ s.ResponseStore = store
+ }
+ store.save(responseID, StoredResponse{
Payload: payload,
CreatedAt: now,
ConversationID: strings.TrimSpace(conversationID),
ThreadID: strings.TrimSpace(threadID),
AccountEmail: strings.TrimSpace(accountEmail),
- }
- store := s.Store
+ }, now)
+ sqliteWriter := s.sqliteWriter
+ sqliteStore := s.Store
ttl := time.Duration(maxInt(s.Config.Responses.StoreTTLSeconds, 1)) * time.Second
- storeEnabled := store != nil && responsesPersistenceEnabled(s.Config)
+ storeEnabled := sqliteStore != nil && responsesPersistenceEnabled(s.Config)
s.mu.Unlock()
if storeEnabled {
- if err := store.SaveResponse(responseID, payload, now, conversationID, threadID, accountEmail); err != nil {
+ if sqliteWriter != nil {
+ sqliteWriter.EnqueueSaveResponse(responseID, payload, now, conversationID, threadID, accountEmail)
+ return
+ }
+ if err := sqliteStore.SaveResponse(responseID, payload, now, conversationID, threadID, accountEmail); err != nil {
log.Printf("[sqlite] save response %s failed: %v", responseID, err)
return
}
- if err := store.DeleteExpiredResponses(ttl); err != nil {
+ if err := sqliteStore.DeleteExpiredResponses(ttl); err != nil {
log.Printf("[sqlite] cleanup responses failed: %v", err)
}
}
@@ -445,12 +615,10 @@ func (s *ServerState) getResponse(responseID string) (map[string]any, bool) {
func (s *ServerState) getStoredResponse(responseID string) (StoredResponse, bool) {
s.mu.Lock()
defer s.mu.Unlock()
- s.cleanupExpiredResponsesLocked(time.Now())
- payload, ok := s.ResponsesByID[responseID]
- if !ok {
+ if s.ResponseStore == nil {
return StoredResponse{}, false
}
- return payload, true
+ return s.ResponseStore.get(responseID, time.Now().UTC())
}
func (s *ServerState) loadConversationContinuationStateByConversationID(conversationID string) (*conversationContinuationState, error) {
@@ -548,24 +716,204 @@ func (s *ServerState) invalidateConversationSession(sessionID string, status str
func (s *ServerState) Close() error {
s.mu.RLock()
store := s.Store
+ cancelCleanup := s.responseStoreCleanupCancel
+ sqliteWriter := s.sqliteWriter
s.mu.RUnlock()
+ if cancelCleanup != nil {
+ cancelCleanup()
+ }
+ if sqliteWriter != nil {
+ sqliteWriter.Close()
+ }
if store == nil {
return nil
}
return store.Close()
}
+func (s *ServerState) startResponseStoreCleanupLoop(parent context.Context) {
+ if s == nil {
+ return
+ }
+ if parent == nil {
+ parent = context.Background()
+ }
+ interval := responseStoreCleanupInterval
+ if testHookResponseStoreCleanupInterval > 0 {
+ interval = testHookResponseStoreCleanupInterval
+ }
+ ctx, cancel := context.WithCancel(parent)
+ s.mu.Lock()
+ s.responseStoreCleanupCancel = cancel
+ s.mu.Unlock()
+ go func() {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ s.runResponseStoreCleanupOnce(time.Now().UTC())
+ }
+ }
+ }()
+}
+
+func (s *ServerState) runResponseStoreCleanupOnce(now time.Time) int {
+ if s == nil {
+ return 0
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.ResponseStore == nil {
+ return 0
+ }
+ removed := s.ResponseStore.pruneExpired(now)
+ if removed > 0 {
+ responseStorePruneTotalMetric.Add("expired_entries", int64(removed))
+ }
+ return removed
+}
+
+func buildPublicModelPayload(entry ModelDefinition) publicModelPayload {
+ return publicModelPayload{
+ ID: entry.ID,
+ Object: "model",
+ Created: 0,
+ OwnedBy: "notion2api",
+ Name: entry.Name,
+ Family: entry.Family,
+ Group: entry.Group,
+ Beta: entry.Beta,
+ NotionModel: entry.NotionModel,
+ }
+}
+
+func buildPublicModelsListPayload(registry ModelRegistry) publicModelsListPayload {
+ items := make([]publicModelPayload, 0, len(registry.Entries))
+ for _, entry := range registry.Entries {
+ if !entry.Enabled {
+ continue
+ }
+ items = append(items, buildPublicModelPayload(entry))
+ }
+ return publicModelsListPayload{
+ Object: "list",
+ Data: items,
+ }
+}
+
+func cloneBytes(src []byte) []byte {
+ if len(src) == 0 {
+ return nil
+ }
+ dst := make([]byte, len(src))
+ copy(dst, src)
+ return dst
+}
+
+func cloneBytesMap(src map[string][]byte) map[string][]byte {
+ if len(src) == 0 {
+ return nil
+ }
+ dst := make(map[string][]byte, len(src))
+ for key, value := range src {
+ dst[key] = cloneBytes(value)
+ }
+ return dst
+}
+
+func (s *ServerState) rebuildStaticJSONCachesLocked() {
+ healthPayload := healthzStaticPayload{
+ OK: true,
+ DefaultModel: s.Config.DefaultPublicModel(),
+ ModelCount: len(s.ModelRegistry.Entries),
+ UserEmail: s.Session.UserEmail,
+ SpaceID: s.Session.SpaceID,
+ ActiveAccount: s.Config.ActiveAccount,
+ SessionRefreshEnable: s.Config.ResolveSessionRefresh().Enabled,
+ }
+ healthBody, err := json.Marshal(healthPayload)
+ if err == nil {
+ healthBodyCopy := cloneBytes(healthBody)
+ s.cachedHealthzStaticJSON.Store(&healthBodyCopy)
+ } else {
+ s.cachedHealthzStaticJSON.Store(nil)
+ }
+
+ modelsPayload := buildPublicModelsListPayload(s.ModelRegistry)
+ modelsBody, err := json.Marshal(modelsPayload)
+ if err == nil {
+ modelsBodyCopy := cloneBytes(modelsBody)
+ s.cachedModelsListJSON.Store(&modelsBodyCopy)
+ } else {
+ s.cachedModelsListJSON.Store(nil)
+ }
+
+ modelByID := make(map[string][]byte, len(s.ModelRegistry.Entries))
+ for _, entry := range s.ModelRegistry.Entries {
+ if !entry.Enabled {
+ continue
+ }
+ body, marshalErr := json.Marshal(buildPublicModelPayload(entry))
+ if marshalErr != nil {
+ continue
+ }
+ modelByID[normalizeLookupKey(entry.ID)] = cloneBytes(body)
+ }
+ modelByIDCopy := cloneBytesMap(modelByID)
+ s.cachedModelByIDJSON.Store(&modelByIDCopy)
+}
+
+func writeJSONBytes(w http.ResponseWriter, status int, body []byte) {
+ applyCORSHeaders(w)
+ w.Header().Set("X-Notion2API", "1")
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write(body)
+}
+
+func appendHealthzRuntimeFields(body []byte, sessionReady bool, lastRefresh time.Time, lastRefreshError string) []byte {
+ trimmed := bytes.TrimSpace(body)
+ if len(trimmed) == 0 || trimmed[len(trimmed)-1] != '}' {
+ trimmed = []byte(`{"ok":true}`)
+ }
+ trimmed = bytes.TrimSuffix(trimmed, []byte("}"))
+ tail := map[string]any{
+ "session_ready": sessionReady,
+ "last_session_refresh": formatTimeOrEmpty(lastRefresh),
+ "last_session_refresh_error": lastRefreshError,
+ }
+ tailBody, err := json.Marshal(tail)
+ if err != nil {
+ return body
+ }
+ tailBody = bytes.TrimPrefix(tailBody, []byte("{"))
+ out := make([]byte, 0, len(trimmed)+1+len(tailBody))
+ out = append(out, trimmed...)
+ if len(trimmed) > 1 {
+ out = append(out, ',')
+ }
+ out = append(out, tailBody...)
+ return out
+}
+
+func applyCORSHeaders(w http.ResponseWriter) {
+ w.Header().Set("Access-Control-Allow-Origin", corsAllowOrigin)
+ w.Header().Set("Access-Control-Allow-Headers", corsAllowHeaders)
+ w.Header().Set("Access-Control-Allow-Methods", corsAllowMethods)
+}
+
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ applyCORSHeaders(w)
w.Header().Set("X-Notion2API", "1")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
- w.Header().Set("Access-Control-Allow-Origin", "*")
- w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Admin-Token")
- w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.WriteHeader(status)
_, _ = w.Write(body)
}
@@ -581,13 +929,28 @@ func writeOpenAIError(w http.ResponseWriter, status int, message string, errorTy
})
}
+func writeInvalidBodyError(w http.ResponseWriter, err error) {
+ if errors.Is(err, errRequestTooLarge) {
+ writeOpenAIError(w, http.StatusRequestEntityTooLarge, "request body exceeds configured limit", "invalid_request_error", "request_too_large")
+ return
+ }
+ writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", nilString())
+}
+
func nilString() string {
return ""
}
-func decodeBody(r *http.Request) (map[string]any, error) {
- defer r.Body.Close()
- decoder := json.NewDecoder(r.Body)
+func decodeBodyWithLimit(w http.ResponseWriter, r *http.Request, maxBytes int64) (map[string]any, error) {
+ raw, err := decodeBodyRawWithLimit(w, r, maxBytes)
+ if err != nil {
+ return nil, err
+ }
+ return decodeBodyMapFromRaw(raw)
+}
+
+func decodeBodyMapFromRaw(raw []byte) (map[string]any, error) {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var payload map[string]any
if err := decoder.Decode(&payload); err != nil {
@@ -599,6 +962,65 @@ func decodeBody(r *http.Request) (map[string]any, error) {
return payload, nil
}
+func decodeBodyRawWithLimit(w http.ResponseWriter, r *http.Request, maxBytes int64) ([]byte, error) {
+ if maxBytes > 0 && w != nil {
+ r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
+ }
+ defer r.Body.Close()
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ return nil, errRequestTooLarge
+ }
+ return nil, fmt.Errorf("invalid json: %w", err)
+ }
+ trimmed := bytes.TrimSpace(body)
+ if len(trimmed) == 0 {
+ return []byte("{}"), nil
+ }
+ var raw json.RawMessage
+ if err := json.Unmarshal(trimmed, &raw); err != nil {
+ var maxErr *http.MaxBytesError
+ if errors.As(err, &maxErr) {
+ return nil, errRequestTooLarge
+ }
+ return nil, fmt.Errorf("invalid json: %w", err)
+ }
+ normalized := bytes.TrimSpace(raw)
+ if len(normalized) == 0 {
+ return []byte("{}"), nil
+ }
+ return normalized, nil
+}
+
+func (a *App) decodeBody(w http.ResponseWriter, r *http.Request) (map[string]any, error) {
+ raw, err := a.decodeBodyRaw(w, r)
+ if err != nil {
+ return nil, err
+ }
+ return decodeBodyMapFromRaw(raw)
+}
+
+func (a *App) decodeBodyRaw(w http.ResponseWriter, r *http.Request) ([]byte, error) {
+ maxBytes := int64(0)
+ if a != nil && a.State != nil {
+ cfg, _, _ := a.State.Snapshot()
+ maxBytes = cfg.Limits.MaxRequestBodyBytes
+ }
+ return decodeBodyRawWithLimit(w, r, maxBytes)
+}
+
+func decodeTypedBodyFromRaw[T any](raw []byte) (T, error) {
+ var typed T
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.UseNumber()
+ if err := decoder.Decode(&typed); err != nil {
+ return typed, fmt.Errorf("invalid json: %w", err)
+ }
+ return typed, nil
+}
+
func (a *App) authOK(w http.ResponseWriter, r *http.Request) bool {
cfg, _, _ := a.State.Snapshot()
expected := strings.TrimSpace(cfg.APIKey)
@@ -614,12 +1036,18 @@ func (a *App) authOK(w http.ResponseWriter, r *http.Request) bool {
}
func (a *App) serveHealthz(w http.ResponseWriter) {
- cfg, session, registry := a.State.Snapshot()
a.State.mu.RLock()
sessionReady := a.State.Client != nil
lastRefresh := a.State.LastSessionRefresh
lastRefreshError := a.State.LastSessionRefreshError
+ cached := a.State.cachedHealthzStaticJSON.Load()
a.State.mu.RUnlock()
+ if cached != nil {
+ body := appendHealthzRuntimeFields(*cached, sessionReady, lastRefresh, lastRefreshError)
+ writeJSONBytes(w, http.StatusOK, body)
+ return
+ }
+ cfg, session, registry := a.State.Snapshot()
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"default_model": cfg.DefaultPublicModel(),
@@ -635,28 +1063,13 @@ func (a *App) serveHealthz(w http.ResponseWriter) {
}
func (a *App) serveModels(w http.ResponseWriter) {
- _, _, registry := a.State.Snapshot()
- items := make([]map[string]any, 0, len(registry.Entries))
- for _, entry := range registry.Entries {
- if !entry.Enabled {
- continue
- }
- items = append(items, map[string]any{
- "id": entry.ID,
- "object": "model",
- "created": 0,
- "owned_by": "notion2api",
- "name": entry.Name,
- "family": entry.Family,
- "group": entry.Group,
- "beta": entry.Beta,
- "notion_model": entry.NotionModel,
- })
+ cached := a.State.cachedModelsListJSON.Load()
+ if cached != nil {
+ writeJSONBytes(w, http.StatusOK, *cached)
+ return
}
- writeJSON(w, http.StatusOK, map[string]any{
- "object": "list",
- "data": items,
- })
+ _, _, registry := a.State.Snapshot()
+ writeJSON(w, http.StatusOK, buildPublicModelsListPayload(registry))
}
func (a *App) serveModelByID(w http.ResponseWriter, path string) {
@@ -667,17 +1080,13 @@ func (a *App) serveModelByID(w http.ResponseWriter, path string) {
writeOpenAIError(w, http.StatusNotFound, "model not found", "invalid_request_error", "model_not_found")
return
}
- writeJSON(w, http.StatusOK, map[string]any{
- "id": entry.ID,
- "object": "model",
- "created": 0,
- "owned_by": "notion2api",
- "name": entry.Name,
- "family": entry.Family,
- "group": entry.Group,
- "beta": entry.Beta,
- "notion_model": entry.NotionModel,
- })
+ if cached := a.State.cachedModelByIDJSON.Load(); cached != nil {
+ if body, ok := (*cached)[normalizeLookupKey(entry.ID)]; ok && len(body) > 0 {
+ writeJSONBytes(w, http.StatusOK, body)
+ return
+ }
+ }
+ writeJSON(w, http.StatusOK, buildPublicModelPayload(entry))
}
func (a *App) serveResponseByID(w http.ResponseWriter, path string) {
@@ -913,8 +1322,13 @@ func attachConversationResponseMetadata(payload map[string]any, conversationID s
}
func (a *App) resolveContinuationConversation(r *http.Request, payload map[string]any, previousResponseID string, hiddenPrompt string, segments []conversationPromptSegment) (continuationTarget, bool) {
- rawCount := sessionRawMessageCount(segments)
explicitConversationID := requestedConversationID(r, payload)
+ explicitThreadID := requestedThreadID(r, payload)
+ return a.resolveContinuationConversationWithExplicit(previousResponseID, hiddenPrompt, segments, explicitConversationID, explicitThreadID)
+}
+
+func (a *App) resolveContinuationConversationWithExplicit(previousResponseID string, hiddenPrompt string, segments []conversationPromptSegment, explicitConversationID string, explicitThreadID string) (continuationTarget, bool) {
+ rawCount := sessionRawMessageCount(segments)
validateState := func(state *conversationContinuationState) bool {
if state == nil {
return true
@@ -986,9 +1400,9 @@ func (a *App) resolveContinuationConversation(r *http.Request, payload map[strin
}
}
}
- if threadID := requestedThreadID(r, payload); threadID != "" {
- if entry, ok := a.State.conversations().FindByThreadID(threadID); ok {
- state, err := a.State.loadConversationContinuationStateByThreadID(threadID)
+ if explicitThreadID != "" {
+ if entry, ok := a.State.conversations().FindByThreadID(explicitThreadID); ok {
+ state, err := a.State.loadConversationContinuationStateByThreadID(explicitThreadID)
if err == nil && !validateState(state) {
return continuationTarget{}, false
}
@@ -998,9 +1412,9 @@ func (a *App) resolveContinuationConversation(r *http.Request, payload map[strin
return continuationTarget{Conversation: entry}, true
}
target := continuationTarget{Conversation: ConversationEntry{
- ThreadID: threadID,
+ ThreadID: explicitThreadID,
}}
- if state, err := a.State.loadConversationContinuationStateByThreadID(threadID); err == nil {
+ if state, err := a.State.loadConversationContinuationStateByThreadID(explicitThreadID); err == nil {
if !validateState(state) {
return continuationTarget{}, false
}
@@ -1111,6 +1525,70 @@ func includeUsageInStream(payload map[string]any) bool {
return includeUsage
}
+func decodeChatCompletionsRequestBodyFromRaw(raw []byte) (chatCompletionsRequestBody, map[string]any, error) {
+ typed, err := decodeTypedBodyFromRaw[chatCompletionsRequestBody](raw)
+ if err == nil {
+ return normalizeTypedChatCompletionsRequestBody(typed), nil, nil
+ }
+ payload, mapErr := decodeBodyMapFromRaw(raw)
+ if mapErr != nil {
+ return chatCompletionsRequestBody{}, nil, mapErr
+ }
+ return extractChatCompletionsRequestBody(payload), payload, nil
+}
+
+func decodeResponsesRequestBodyFromRaw(raw []byte) (responsesRequestBody, map[string]any, error) {
+ typed, err := decodeTypedBodyFromRaw[responsesRequestBody](raw)
+ if err == nil {
+ return normalizeTypedResponsesRequestBody(typed), nil, nil
+ }
+ payload, mapErr := decodeBodyMapFromRaw(raw)
+ if mapErr != nil {
+ return responsesRequestBody{}, nil, mapErr
+ }
+ return extractResponsesRequestBody(payload), payload, nil
+}
+
+func maybeSillyTavernByTypedMessages(rawMessages any) bool {
+ items := sliceValue(rawMessages)
+ if len(items) == 0 {
+ return false
+ }
+ systemPrompts := make([]string, 0, len(items))
+ for _, raw := range items {
+ msg := mapValue(raw)
+ if msg == nil {
+ continue
+ }
+ if strings.TrimSpace(strings.ToLower(stringValue(msg["role"]))) != "system" {
+ continue
+ }
+ text := collapseWhitespace(flattenContent(msg["content"]))
+ if text != "" {
+ systemPrompts = append(systemPrompts, text)
+ }
+ }
+ if len(systemPrompts) == 0 {
+ return false
+ }
+ if looksLikeSillyTavernImpersonate(systemPrompts) || looksLikeSillyTavernQuiet(systemPrompts, nil) {
+ return true
+ }
+ for _, prompt := range systemPrompts {
+ lower := strings.ToLower(collapseWhitespace(prompt))
+ if strings.Contains(lower, "fictional chat between") ||
+ strings.Contains(lower, "[start a new chat]") ||
+ strings.Contains(lower, "[continue your last message without repeating its original content.]") {
+ return true
+ }
+ }
+ return false
+}
+
+func rawMayNeedSillyTavernPayloadFallback(raw []byte) bool {
+ return bytes.Contains(raw, []byte(`"continue_prefill"`)) || bytes.Contains(raw, []byte(`"show_thoughts"`))
+}
+
func chatCompletionInitialFlushDelayForRequest(request PromptRunRequest) time.Duration {
if request.ClientProfile == sillyTavernClientProfile || request.StreamReasoningWarmup {
return 0
@@ -1152,16 +1630,33 @@ func (a *App) runPromptStreamWithSink(r *http.Request, request PromptRunRequest,
}
func (a *App) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
- payload, err := decodeBody(r)
+ raw, err := a.decodeBodyRaw(w, r)
if err != nil {
- writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", nilString())
+ writeInvalidBodyError(w, err)
return
}
- if isLikelySillyTavernPayload(payload) {
+ typed, payload, err := decodeChatCompletionsRequestBodyFromRaw(raw)
+ if err != nil {
+ writeInvalidBodyError(w, err)
+ return
+ }
+ if payload == nil && (typed.likelySillyTavernByEnvelope() || maybeSillyTavernByTypedMessages(typed.Messages) || rawMayNeedSillyTavernPayloadFallback(raw)) {
+ payload, err = decodeBodyMapFromRaw(raw)
+ if err != nil {
+ writeInvalidBodyError(w, err)
+ return
+ }
+ }
+ if payload != nil && (typed.likelySillyTavernByEnvelope() || isLikelySillyTavernPayload(payload)) {
a.handleSillyTavernChatCompletionsPayload(w, r, payload)
return
}
- normalized, err := normalizeChatInput(payload)
+ messages := sliceValue(typed.Messages)
+ if len(messages) == 0 {
+ writeOpenAIError(w, http.StatusBadRequest, "messages must be an array", "invalid_request_error", nilString())
+ return
+ }
+ normalized, err := normalizeChatInputFromParts(messages, typed.Attachments)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", nilString())
return
@@ -1171,7 +1666,12 @@ func (a *App) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
return
}
cfg, _, registry := a.State.Snapshot()
- entry, err := registry.Resolve(requestedModel(payload, cfg.DefaultPublicModel()), cfg.DefaultPublicModel())
+ requestedModelID := requestedModelFromTyped(typed.Model, cfg.DefaultPublicModel())
+ useWebSearch := requestedWebSearchFromTyped(typed.UseWebSearch, typed.Metadata, typed.Tools, cfg.Features.UseWebSearch)
+ preferredConversationID := requestedConversationIDFromTyped(r, typed.ConversationID, typed.Conversation, typed.Metadata)
+ explicitThreadID := requestedThreadIDFromTyped(r, typed.ThreadID, typed.Thread, typed.NotionThreadID, typed.Metadata)
+ requestedAccount := requestedAccountEmailFromTyped(r, typed.AccountEmail, typed.NotionAccountEmail, typed.Metadata)
+ entry, err := registry.Resolve(requestedModelID, cfg.DefaultPublicModel())
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "model_not_found")
return
@@ -1187,17 +1687,16 @@ func (a *App) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
HiddenPrompt: hiddenPrompt,
PublicModel: entry.ID,
NotionModel: entry.NotionModel,
- UseWebSearch: requestedWebSearch(payload, cfg.Features.UseWebSearch),
+ UseWebSearch: useWebSearch,
Attachments: normalized.Attachments,
SessionFingerprint: originalFingerprint,
RawMessageCount: originalRawMessageCount,
}
freshThreadMode := forceFreshThreadPerRequest(cfg)
- preferredConversationID := requestedConversationID(r, payload)
conversation := ConversationEntry{}
- if matched, ok := a.resolveContinuationConversation(r, payload, "", hiddenPrompt, normalized.Segments); ok {
+ if matched, ok := a.resolveContinuationConversationWithExplicit("", hiddenPrompt, normalized.Segments, preferredConversationID, explicitThreadID); ok {
conversation = matched.Conversation
- request.PinnedAccountEmail = firstNonEmpty(strings.TrimSpace(conversation.AccountEmail), requestedAccountEmail(r, payload))
+ request.PinnedAccountEmail = firstNonEmpty(strings.TrimSpace(conversation.AccountEmail), requestedAccount)
if freshThreadMode {
request.ForceLocalConversationContinue = strings.TrimSpace(conversation.ID) != ""
request.Prompt = buildFreshThreadReplayPromptFromConversation(conversation, latestPrompt, normalized.Attachments, promptText)
@@ -1210,14 +1709,18 @@ func (a *App) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
request.Prompt = latestPrompt
}
} else {
- request.PinnedAccountEmail = requestedAccountEmail(r, payload)
+ request.PinnedAccountEmail = requestedAccount
}
request.ConversationID = firstNonEmpty(strings.TrimSpace(conversation.ID), preferredConversationID)
conversationID := a.startConversationTurn(conversation.ID, preferredConversationID, "api", "chat_completions", resolveRequestPromptForContinuation(normalized), request)
setConversationIDHeader(w, conversationID)
- stream, _ := payload["stream"].(bool)
+ stream := typed.Stream
if stream {
- a.writeChatCompletionLiveStream(w, r, request, entry.ID, includeUsageInStream(payload), conversationID)
+ includeUsage := false
+ if typed.StreamIncludeUsage != nil {
+ includeUsage = *typed.StreamIncludeUsage
+ }
+ a.writeChatCompletionLiveStream(w, r, request, entry.ID, includeUsage, conversationID)
return
}
result, err := a.runPrompt(r, request)
@@ -1237,9 +1740,9 @@ func (a *App) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
func (a *App) handleSillyTavernChatCompletions(w http.ResponseWriter, r *http.Request) {
- payload, err := decodeBody(r)
+ payload, err := a.decodeBody(w, r)
if err != nil {
- writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", nilString())
+ writeInvalidBodyError(w, err)
return
}
a.handleSillyTavernChatCompletionsPayload(w, r, payload)
@@ -1349,14 +1852,19 @@ func (a *App) handleSillyTavernChatCompletionsPayload(w http.ResponseWriter, r *
}
func (a *App) handleResponses(w http.ResponseWriter, r *http.Request) {
- payload, err := decodeBody(r)
+ raw, err := a.decodeBodyRaw(w, r)
if err != nil {
- writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", nilString())
+ writeInvalidBodyError(w, err)
return
}
- stream, _ := payload["stream"].(bool)
+ typed, _, err := decodeResponsesRequestBodyFromRaw(raw)
+ if err != nil {
+ writeInvalidBodyError(w, err)
+ return
+ }
+ stream := typed.Stream
var previousResponse map[string]any
- previousResponseID := strings.TrimSpace(stringValue(payload["previous_response_id"]))
+ previousResponseID := strings.TrimSpace(typed.PreviousResponseID)
if previousResponseID != "" {
var ok bool
previousResponse, ok = a.State.getResponse(previousResponseID)
@@ -1365,7 +1873,7 @@ func (a *App) handleResponses(w http.ResponseWriter, r *http.Request) {
return
}
}
- normalized, err := normalizeResponsesInput(payload, previousResponse)
+ normalized, err := normalizeResponsesInputFromParts(typed.Input, typed.Attachments, previousResponse)
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", nilString())
return
@@ -1375,7 +1883,12 @@ func (a *App) handleResponses(w http.ResponseWriter, r *http.Request) {
return
}
cfg, _, registry := a.State.Snapshot()
- entry, err := registry.Resolve(requestedModel(payload, cfg.DefaultPublicModel()), cfg.DefaultPublicModel())
+ requestedModelID := requestedModelFromTyped(typed.Model, cfg.DefaultPublicModel())
+ useWebSearch := requestedWebSearchFromTyped(typed.UseWebSearch, typed.Metadata, typed.Tools, cfg.Features.UseWebSearch)
+ preferredConversationID := requestedConversationIDFromTyped(r, typed.ConversationID, typed.Conversation, typed.Metadata)
+ explicitThreadID := requestedThreadIDFromTyped(r, typed.ThreadID, typed.Thread, typed.NotionThreadID, typed.Metadata)
+ requestedAccount := requestedAccountEmailFromTyped(r, typed.AccountEmail, typed.NotionAccountEmail, typed.Metadata)
+ entry, err := registry.Resolve(requestedModelID, cfg.DefaultPublicModel())
if err != nil {
writeOpenAIError(w, http.StatusBadRequest, err.Error(), "invalid_request_error", "model_not_found")
return
@@ -1391,17 +1904,16 @@ func (a *App) handleResponses(w http.ResponseWriter, r *http.Request) {
HiddenPrompt: hiddenPrompt,
PublicModel: entry.ID,
NotionModel: entry.NotionModel,
- UseWebSearch: requestedWebSearch(payload, cfg.Features.UseWebSearch),
+ UseWebSearch: useWebSearch,
Attachments: normalized.Attachments,
SessionFingerprint: originalFingerprint,
RawMessageCount: originalRawMessageCount,
}
freshThreadMode := forceFreshThreadPerRequest(cfg)
- preferredConversationID := requestedConversationID(r, payload)
conversation := ConversationEntry{}
- if matched, ok := a.resolveContinuationConversation(r, payload, previousResponseID, hiddenPrompt, normalized.Segments); ok {
+ if matched, ok := a.resolveContinuationConversationWithExplicit(previousResponseID, hiddenPrompt, normalized.Segments, preferredConversationID, explicitThreadID); ok {
conversation = matched.Conversation
- request.PinnedAccountEmail = firstNonEmpty(strings.TrimSpace(conversation.AccountEmail), requestedAccountEmail(r, payload))
+ request.PinnedAccountEmail = firstNonEmpty(strings.TrimSpace(conversation.AccountEmail), requestedAccount)
if freshThreadMode {
request.ForceLocalConversationContinue = strings.TrimSpace(conversation.ID) != ""
request.Prompt = buildFreshThreadReplayPromptFromConversation(conversation, latestPrompt, normalized.Attachments, promptText)
@@ -1414,7 +1926,7 @@ func (a *App) handleResponses(w http.ResponseWriter, r *http.Request) {
request.Prompt = latestPrompt
}
} else {
- request.PinnedAccountEmail = requestedAccountEmail(r, payload)
+ request.PinnedAccountEmail = requestedAccount
}
if freshThreadMode && strings.TrimSpace(conversation.ID) == "" {
request.Prompt = buildFreshThreadReplayPromptFromStoredResponse(normalized.PreviousResponsePrompt, latestPrompt, normalized.Attachments, request.Prompt)
@@ -1468,11 +1980,11 @@ func (a *App) writeUpstreamError(w http.ResponseWriter, err error) {
}
func prepareOpenAISSEHeaders(w http.ResponseWriter) {
+ applyCORSHeaders(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
- w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
}
@@ -2106,7 +2618,13 @@ func (a *App) writeResponsesStream(w http.ResponseWriter, r *http.Request, resul
}
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ startedAt := time.Now()
+ statusCode := http.StatusOK
+ defer func() {
+ observeRequestDuration(r.URL.Path, r.Method, statusCode, time.Since(startedAt))
+ }()
safeWriter := &panicSafeResponseWriter{ResponseWriter: w}
+ applyCORSHeaders(safeWriter)
defer func() {
if recovered := recover(); recovered != nil {
stack := strings.TrimSpace(string(debug.Stack()))
@@ -2139,10 +2657,8 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}()
if r.Method == http.MethodOptions {
- safeWriter.Header().Set("Access-Control-Allow-Origin", "*")
- safeWriter.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Admin-Token")
- safeWriter.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
safeWriter.WriteHeader(http.StatusNoContent)
+ statusCode = safeWriter.status
return
}
@@ -2150,16 +2666,20 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && path == "/":
a.serveIndex(safeWriter)
+ statusCode = safeWriter.status
return
case strings.HasPrefix(path, "/admin"):
a.handleAdmin(safeWriter, r)
+ statusCode = safeWriter.status
return
case r.Method == http.MethodGet && path == "/healthz":
a.serveHealthz(safeWriter)
+ statusCode = safeWriter.status
return
}
if !a.authOK(safeWriter, r) {
+ statusCode = safeWriter.status
return
}
@@ -2168,6 +2688,10 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.serveModels(safeWriter)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/models/"):
a.serveModelByID(safeWriter, path)
+ case r.Method == http.MethodGet && path == "/debug/vars":
+ expvar.Handler().ServeHTTP(safeWriter, r)
+ case r.Method == http.MethodGet && path == "/metrics":
+ writePrometheusMetrics(safeWriter)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/v1/responses/"):
a.serveResponseByID(safeWriter, path)
case r.Method == http.MethodPost && path == "/v1/st/chat/completions":
@@ -2179,6 +2703,7 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default:
writeOpenAIError(safeWriter, http.StatusNotFound, "route not found", "invalid_request_error", "not_found")
}
+ statusCode = safeWriter.status
}
func Main() {
@@ -2190,6 +2715,14 @@ func Main() {
app := &App{State: state}
state.StartSessionRefreshLoop(context.Background())
app.StartEphemeralConversationCleanupLoop(context.Background())
+ if cfg.Debug.PprofEnabled {
+ go func(addr string) {
+ log.Printf("[pprof] listening on http://%s/debug/pprof/ (local debug endpoint; avoid public exposure)", addr)
+ if err := http.ListenAndServe(addr, nil); err != nil {
+ log.Printf("[pprof] server stopped: %v", err)
+ }
+ }(cfg.Debug.PprofAddr)
+ }
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
server := &http.Server{
Addr: addr,
diff --git a/internal/app/main_fresh_thread_test.go b/internal/app/main_fresh_thread_test.go
index fd2a3ad..7906df0 100644
--- a/internal/app/main_fresh_thread_test.go
+++ b/internal/app/main_fresh_thread_test.go
@@ -2,15 +2,21 @@ package app
import (
"bytes"
+ "context"
"encoding/json"
"errors"
+ "expvar"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
"strings"
"testing"
+ "time"
)
func newFreshThreadTestApp(t *testing.T) *App {
@@ -244,6 +250,1252 @@ func TestHandleSillyTavernFreshThreadReplaysLocalConversation(t *testing.T) {
assertConversationContinued(t, app, seeded.ID, "thread-new-st", "The story continues.")
}
+func TestNormalizeConfigSetsPprofDefaults(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{})
+ if cfg.Debug.PprofEnabled {
+ t.Fatalf("expected pprof disabled by default")
+ }
+ if cfg.Debug.PprofAddr != "127.0.0.1:6060" {
+ t.Fatalf("unexpected default pprof addr: %q", cfg.Debug.PprofAddr)
+ }
+}
+
+func TestDefaultConfigSurfHelperTransportDisabled(t *testing.T) {
+ cfg := defaultConfig()
+ if cfg.Features.UseSurfHelperTransport {
+ t.Fatalf("expected default use_surf_helper_transport=false")
+ }
+}
+
+func TestNormalizeConfigKeepsSurfHelperTransportEnabled(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ Features: FeatureConfig{UseSurfHelperTransport: true},
+ })
+ if !cfg.Features.UseSurfHelperTransport {
+ t.Fatalf("expected normalizeConfig to preserve use_surf_helper_transport=true")
+ }
+}
+
+func TestDefaultConfigSetsDispatchProbeCacheTTLDefault(t *testing.T) {
+ cfg := defaultConfig()
+ if cfg.Dispatch.ProbeCacheTTLSeconds != 45 {
+ t.Fatalf("unexpected default dispatch probe cache ttl: %d", cfg.Dispatch.ProbeCacheTTLSeconds)
+ }
+}
+
+func TestNormalizeConfigClampsNegativeDispatchProbeCacheTTL(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ Dispatch: DispatchConfig{ProbeCacheTTLSeconds: -3},
+ })
+ if cfg.Dispatch.ProbeCacheTTLSeconds != 0 {
+ t.Fatalf("expected negative dispatch probe cache ttl to clamp to 0, got %d", cfg.Dispatch.ProbeCacheTTLSeconds)
+ }
+}
+
+func TestDefaultConfigBrowserHelperPoolSizeDefaultZero(t *testing.T) {
+ cfg := defaultConfig()
+ if got := cfg.Browser.HelperPoolSize; got != 0 {
+ t.Fatalf("unexpected default browser helper pool size: got %d want %d", got, 0)
+ }
+}
+
+func TestNormalizeConfigClampsBrowserHelperPoolSizeBounds(t *testing.T) {
+ negative := normalizeConfig(AppConfig{
+ Browser: BrowserConfig{HelperPoolSize: -2},
+ })
+ if got := negative.Browser.HelperPoolSize; got != 0 {
+ t.Fatalf("expected negative helper pool size clamp to 0, got %d", got)
+ }
+ tooLarge := normalizeConfig(AppConfig{
+ Browser: BrowserConfig{HelperPoolSize: 99},
+ })
+ if got := tooLarge.Browser.HelperPoolSize; got != 8 {
+ t.Fatalf("expected oversized helper pool size clamp to 8, got %d", got)
+ }
+}
+
+func TestEmbeddedBrowserHelperAssetsRemoved(t *testing.T) {
+ _, err1 := os.Stat("internal/app/assets/browser-helper.cjs")
+ _, err2 := os.Stat("internal/app/assets/browser-login-helper.cjs")
+ if !errors.Is(err1, os.ErrNotExist) || !errors.Is(err2, os.ErrNotExist) {
+ t.Fatalf("node helper assets still exist")
+ }
+}
+
+func TestSurfHelperTransportFeatureEnabledUsesSurfPath(t *testing.T) {
+ cfg := defaultConfig()
+ cfg.Features.UseSurfHelperTransport = true
+ if !cfg.Features.UseSurfHelperTransport {
+ t.Fatalf("expected surf flag enabled")
+ }
+}
+
+func TestNormalizeConfigPrecomputesRetryPrefixes(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ Prompt: PromptConfig{
+ CodingRetryPrefixes: []string{"custom-coding-prefix"},
+ GeneralRetryPrefixes: []string{"custom-general-prefix"},
+ DirectAnswerRetryPrefixes: []string{"custom-direct-prefix"},
+ },
+ })
+ if len(cfg.Prompt.precomputedAllRetryPrefixes) == 0 {
+ t.Fatalf("expected precomputed retry prefixes")
+ }
+ joined := strings.Join(cfg.Prompt.precomputedAllRetryPrefixes, "\n")
+ for _, required := range []string{
+ "custom-coding-prefix",
+ "custom-general-prefix",
+ "custom-direct-prefix",
+ } {
+ if !strings.Contains(joined, required) {
+ t.Fatalf("precomputed retry prefixes missing %q", required)
+ }
+ }
+}
+
+func TestEnsureAccountPathsSetsEmailKey(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ LoginHelper: LoginHelperConfig{SessionsDir: "probe_files/notion_accounts"},
+ })
+ account := ensureAccountPaths(cfg, NotionAccount{Email: " Alice@Example.COM "})
+ if account.emailKey != "alice@example.com" {
+ t.Fatalf("unexpected cached email key: %q", account.emailKey)
+ }
+}
+
+func BenchmarkPromptGuardLooksLikeCodingRequest(b *testing.B) {
+ text := "Please help debug this golang function and refactor the docker deployment script."
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _ = promptGuardLooksLikeCodingRequest(text)
+ }
+}
+
+func BenchmarkPromptGuardStripRetryPrefixes(b *testing.B) {
+ cfg := normalizeConfig(AppConfig{
+ Prompt: PromptConfig{
+ CodingRetryPrefixes: []string{"custom-coding-prefix"},
+ GeneralRetryPrefixes: []string{"custom-general-prefix"},
+ DirectAnswerRetryPrefixes: []string{"custom-direct-prefix"},
+ },
+ })
+ base := "this is a coding request body"
+ input := cfg.Prompt.CodingRetryPrefixes[0] + cfg.Prompt.GeneralRetryPrefixes[0] + base
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ _ = promptGuardStripRetryPrefixes(cfg, input)
+ }
+}
+
+func BenchmarkServeModelsCaching(b *testing.B) {
+ cfg := defaultConfig()
+ cfg.APIKey = "bench-api-key"
+ cfg.Storage.SQLitePath = ""
+ state, err := newServerState(cfg)
+ if err != nil {
+ b.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+ app := &App{State: state}
+ req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ req.Header.Set("Authorization", "Bearer bench-api-key")
+
+ b.Run("cached", func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ b.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusOK)
+ }
+ }
+ })
+
+ b.Run("uncached_fallback", func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ state.cachedModelsListJSON.Store(nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ b.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusOK)
+ }
+ }
+ })
+}
+
+func BenchmarkDecodeChatCompletionsTypedFirst(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "stream":true,
+ "stream_options":{"include_usage":"1"},
+ "messages":[
+ {"role":"system","content":"You are helpful."},
+ {"role":"user","content":"请总结这段文本并给出要点。"}
+ ],
+ "metadata":{"use_web_search":false}
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ typed, payload, err := decodeChatCompletionsRequestBodyFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeChatCompletionsRequestBodyFromRaw failed: %v", err)
+ }
+ if payload != nil {
+ b.Fatalf("unexpected map fallback on typed benchmark path")
+ }
+ if len(sliceValue(typed.Messages)) == 0 {
+ b.Fatalf("expected typed messages")
+ }
+ }
+}
+
+func BenchmarkDecodeChatCompletionsMapOnly(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "stream":true,
+ "stream_options":{"include_usage":"1"},
+ "messages":[
+ {"role":"system","content":"You are helpful."},
+ {"role":"user","content":"请总结这段文本并给出要点。"}
+ ],
+ "metadata":{"use_web_search":false}
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ payload, err := decodeBodyMapFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeBodyMapFromRaw failed: %v", err)
+ }
+ typed := extractChatCompletionsRequestBody(payload)
+ if len(sliceValue(typed.Messages)) == 0 {
+ b.Fatalf("expected map-extracted messages")
+ }
+ }
+}
+
+func BenchmarkNormalizeChatInputFromTypedMessages(b *testing.B) {
+ raw := []byte(`{
+ "messages":[
+ {"role":"system","content":"You are helpful."},
+ {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}
+ ],
+ "attachments":[{"type":"image_url","url":"https://example.com/a.png"}]
+ }`)
+ typed, _, err := decodeChatCompletionsRequestBodyFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeChatCompletionsRequestBodyFromRaw failed: %v", err)
+ }
+ messages := sliceValue(typed.Messages)
+ if len(messages) == 0 {
+ b.Fatalf("expected typed messages")
+ }
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ normalized, err := normalizeChatInputFromParts(messages, typed.Attachments)
+ if err != nil {
+ b.Fatalf("normalizeChatInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func BenchmarkNormalizeChatInputFromMapMessages(b *testing.B) {
+ raw := []byte(`{
+ "messages":[
+ {"role":"system","content":"You are helpful."},
+ {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}
+ ],
+ "attachments":[{"type":"image_url","url":"https://example.com/a.png"}]
+ }`)
+ payload, err := decodeBodyMapFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeBodyMapFromRaw failed: %v", err)
+ }
+ messages := sliceValue(payload["messages"])
+ if len(messages) == 0 {
+ b.Fatalf("expected map messages")
+ }
+ attachments := payload["attachments"]
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ normalized, err := normalizeChatInputFromParts(messages, attachments)
+ if err != nil {
+ b.Fatalf("normalizeChatInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func BenchmarkDecodeResponsesTypedFirst(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "stream":false,
+ "previous_response_id":"resp_123",
+ "input":[
+ {"type":"input_text","text":"hello"},
+ {"type":"input_text","text":"world"}
+ ],
+ "metadata":{"use_web_search":"1"},
+ "attachments":[{"type":"file","file_url":"https://example.com/f.txt"}]
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ typed, payload, err := decodeResponsesRequestBodyFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeResponsesRequestBodyFromRaw failed: %v", err)
+ }
+ if payload != nil {
+ b.Fatalf("unexpected map fallback on typed benchmark path")
+ }
+ if len(sliceValue(typed.Input)) == 0 {
+ b.Fatalf("expected typed input items")
+ }
+ }
+}
+
+func BenchmarkDecodeResponsesMapOnly(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "stream":false,
+ "previous_response_id":"resp_123",
+ "input":[
+ {"type":"input_text","text":"hello"},
+ {"type":"input_text","text":"world"}
+ ],
+ "metadata":{"use_web_search":"1"},
+ "attachments":[{"type":"file","file_url":"https://example.com/f.txt"}]
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ payload, err := decodeBodyMapFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeBodyMapFromRaw failed: %v", err)
+ }
+ typed := extractResponsesRequestBody(payload)
+ if len(sliceValue(typed.Input)) == 0 {
+ b.Fatalf("expected map-extracted responses input")
+ }
+ }
+}
+
+func BenchmarkNormalizeResponsesInputFromTyped(b *testing.B) {
+ raw := []byte(`{
+ "input":[
+ {"type":"input_text","text":"hello"},
+ {"type":"input_text","text":"world"}
+ ],
+ "attachments":[{"type":"file","file_url":"https://example.com/f.txt"}]
+ }`)
+ typed, _, err := decodeResponsesRequestBodyFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeResponsesRequestBodyFromRaw failed: %v", err)
+ }
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ normalized, err := normalizeResponsesInputFromParts(typed.Input, typed.Attachments, nil)
+ if err != nil {
+ b.Fatalf("normalizeResponsesInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func BenchmarkNormalizeResponsesInputFromMap(b *testing.B) {
+ raw := []byte(`{
+ "input":[
+ {"type":"input_text","text":"hello"},
+ {"type":"input_text","text":"world"}
+ ],
+ "attachments":[{"type":"file","file_url":"https://example.com/f.txt"}]
+ }`)
+ payload, err := decodeBodyMapFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeBodyMapFromRaw failed: %v", err)
+ }
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ normalized, err := normalizeResponsesInputFromParts(payload["input"], payload["attachments"], nil)
+ if err != nil {
+ b.Fatalf("normalizeResponsesInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func BenchmarkChatDecodeAndNormalizeTypedFirst(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "stream":false,
+ "messages":[
+ {"role":"system","content":"You are helpful."},
+ {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}
+ ],
+ "attachments":[{"type":"image_url","url":"https://example.com/a.png"}]
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ typed, payload, err := decodeChatCompletionsRequestBodyFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeChatCompletionsRequestBodyFromRaw failed: %v", err)
+ }
+ if payload != nil {
+ b.Fatalf("unexpected map fallback on typed benchmark path")
+ }
+ normalized, err := normalizeChatInputFromParts(sliceValue(typed.Messages), typed.Attachments)
+ if err != nil {
+ b.Fatalf("normalizeChatInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func BenchmarkChatDecodeAndNormalizeMapOnly(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "stream":false,
+ "messages":[
+ {"role":"system","content":"You are helpful."},
+ {"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":"world"}]}
+ ],
+ "attachments":[{"type":"image_url","url":"https://example.com/a.png"}]
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ payload, err := decodeBodyMapFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeBodyMapFromRaw failed: %v", err)
+ }
+ typed := extractChatCompletionsRequestBody(payload)
+ normalized, err := normalizeChatInputFromParts(sliceValue(typed.Messages), typed.Attachments)
+ if err != nil {
+ b.Fatalf("normalizeChatInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func BenchmarkResponsesDecodeAndNormalizeTypedFirst(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "input":[
+ {"type":"input_text","text":"hello"},
+ {"type":"input_text","text":"world"}
+ ],
+ "attachments":[{"type":"file","file_url":"https://example.com/f.txt"}]
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ typed, payload, err := decodeResponsesRequestBodyFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeResponsesRequestBodyFromRaw failed: %v", err)
+ }
+ if payload != nil {
+ b.Fatalf("unexpected map fallback on typed benchmark path")
+ }
+ normalized, err := normalizeResponsesInputFromParts(typed.Input, typed.Attachments, nil)
+ if err != nil {
+ b.Fatalf("normalizeResponsesInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func BenchmarkResponsesDecodeAndNormalizeMapOnly(b *testing.B) {
+ raw := []byte(`{
+ "model":"gpt-5.4",
+ "input":[
+ {"type":"input_text","text":"hello"},
+ {"type":"input_text","text":"world"}
+ ],
+ "attachments":[{"type":"file","file_url":"https://example.com/f.txt"}]
+ }`)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ payload, err := decodeBodyMapFromRaw(raw)
+ if err != nil {
+ b.Fatalf("decodeBodyMapFromRaw failed: %v", err)
+ }
+ typed := extractResponsesRequestBody(payload)
+ normalized, err := normalizeResponsesInputFromParts(typed.Input, typed.Attachments, nil)
+ if err != nil {
+ b.Fatalf("normalizeResponsesInputFromParts failed: %v", err)
+ }
+ if normalized.Prompt == "" {
+ b.Fatalf("expected normalized prompt")
+ }
+ }
+}
+
+func TestServeModelsUsesStaticJSONCache(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ raw := []byte(`{"object":"list","data":[{"id":"cached-model","object":"model"}]}`)
+ ready := append([]byte(nil), raw...)
+ app.State.cachedModelsListJSON.Store(&ready)
+ req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ rec := httptest.NewRecorder()
+
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusOK)
+ }
+ if got := strings.TrimSpace(rec.Body.String()); got != string(raw) {
+ t.Fatalf("expected cached body, got %s", got)
+ }
+}
+
+func TestServeModelByIDUsesStaticJSONCache(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ _, _, registry := app.State.Snapshot()
+ entry, err := registry.Resolve("gpt-5.4", "auto")
+ if err != nil {
+ t.Fatalf("resolve model failed: %v", err)
+ }
+ body := []byte(`{"id":"gpt-5.4","object":"model","cached":true}`)
+ cache := map[string][]byte{
+ normalizeLookupKey(entry.ID): append([]byte(nil), body...),
+ }
+ app.State.cachedModelByIDJSON.Store(&cache)
+ req := httptest.NewRequest(http.MethodGet, "/v1/models/"+entry.ID, nil)
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ rec := httptest.NewRecorder()
+
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusOK)
+ }
+ if got := strings.TrimSpace(rec.Body.String()); got != string(body) {
+ t.Fatalf("expected cached body, got %s", got)
+ }
+}
+
+func TestServeHealthzIncludesRefreshRuntimeFieldsWhenStaticCacheExists(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ static := []byte(`{"ok":true,"default_model":"gpt-5.4","model_count":3,"user_email":"user@example.com","space_id":"space-id","active_account":"acc@example.com","session_refresh_enabled":true}`)
+ staticCopy := append([]byte(nil), static...)
+ app.State.cachedHealthzStaticJSON.Store(&staticCopy)
+ app.State.mu.Lock()
+ app.State.LastSessionRefresh = time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
+ app.State.LastSessionRefreshError = "refresh failed"
+ app.State.mu.Unlock()
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ rec := httptest.NewRecorder()
+
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusOK)
+ }
+ var payload map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("unmarshal healthz failed: %v", err)
+ }
+ if got, _ := payload["default_model"].(string); got != "gpt-5.4" {
+ t.Fatalf("unexpected default_model: %q", got)
+ }
+ if got, ok := payload["session_ready"].(bool); !ok || got {
+ t.Fatalf("unexpected session_ready: %#v", payload["session_ready"])
+ }
+ if got, _ := payload["last_session_refresh"].(string); got != "2026-01-02T03:04:05Z" {
+ t.Fatalf("unexpected last_session_refresh: %q", got)
+ }
+ if got, _ := payload["last_session_refresh_error"].(string); got != "refresh failed" {
+ t.Fatalf("unexpected last_session_refresh_error: %q", got)
+ }
+}
+
+func TestServeHTTPDebugVarsExposesWreqClientMetric(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ before := int64(0)
+ if value := transportClientNewTotalMetric.Get("standard"); value != nil {
+ before = value.(*expvar.Int).Value()
+ }
+ transportClientNewTotalMetric.Add("standard", 1)
+ req := httptest.NewRequest(http.MethodGet, "/debug/vars", nil)
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ rec := httptest.NewRecorder()
+
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `"notion2api_transport_client_new_total"`) {
+ t.Fatalf("expected metrics payload to include wreq client metric, got %s", body)
+ }
+ if !strings.Contains(body, `"notion2api_http_transport_cache_total"`) {
+ t.Fatalf("expected metrics payload to include transport cache metric, got %s", body)
+ }
+ after := int64(0)
+ if value := transportClientNewTotalMetric.Get("standard"); value != nil {
+ after = value.(*expvar.Int).Value()
+ }
+ if after < before+1 {
+ t.Fatalf("expected metric value to be incremented, before=%d after=%d", before, after)
+ }
+}
+
+func TestServeHTTPMetricsExposesCorePrometheusSeries(t *testing.T) {
+ resetMetricsForTest()
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = ""
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+ app := &App{State: state}
+
+ setDispatchSlotInflight("alice@example.com", 2)
+ observeTransportCallDuration(25 * time.Millisecond)
+ observeSQLiteOpDuration("save_response", 2*time.Millisecond)
+ addBrowserHelperSpawn()
+ addBrowserHelperPoolWorkerSpawn()
+
+ warmReq := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ warmRec := httptest.NewRecorder()
+ app.ServeHTTP(warmRec, warmReq)
+ if warmRec.Code != http.StatusOK {
+ t.Fatalf("unexpected warm-up status: got %d want %d", warmRec.Code, http.StatusOK)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d want %d body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "notion2api_request_duration_seconds_bucket",
+ "notion2api_dispatch_slot_inflight",
+ "notion2api_transport_call_duration_seconds_bucket",
+ "notion2api_browser_helper_spawn_total",
+ "notion2api_browser_helper_pool_worker_spawn_total",
+ "notion2api_sqlite_op_duration_seconds_bucket",
+ "notion2api_response_store_prune_total",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("expected /metrics output to include %q, got: %s", want, body)
+ }
+ }
+ if !strings.Contains(body, "notion2api_browser_helper_pool_worker_spawn_total 1") {
+ t.Fatalf("expected pool worker spawn counter value to be 1, got: %s", body)
+ }
+}
+
+func TestSnapshotReadsFromAtomicBundle(t *testing.T) {
+ state := &ServerState{}
+ cfg := defaultConfig()
+ cfg.APIKey = "snapshot-api-key"
+ session := SessionInfo{UserID: "user-1", SpaceID: "space-1"}
+ registry := ModelRegistry{
+ Entries: []ModelDefinition{
+ {ID: "gpt-5.4", Enabled: true},
+ },
+ }
+
+ state.mu.Lock()
+ state.Config = cfg
+ state.Session = session
+ state.ModelRegistry = registry
+ state.updateSnapshotBundleLocked()
+ state.mu.Unlock()
+
+ gotCfg, gotSession, gotRegistry := state.Snapshot()
+ if gotCfg.APIKey != cfg.APIKey {
+ t.Fatalf("snapshot cfg mismatch: got %q want %q", gotCfg.APIKey, cfg.APIKey)
+ }
+ if gotSession.UserID != session.UserID || gotSession.SpaceID != session.SpaceID {
+ t.Fatalf("snapshot session mismatch: got %+v want %+v", gotSession, session)
+ }
+ if len(gotRegistry.Entries) != 1 || gotRegistry.Entries[0].ID != "gpt-5.4" {
+ t.Fatalf("snapshot registry mismatch: %+v", gotRegistry.Entries)
+ }
+ if len(state.snap.Load().DispatchOrder) != 0 {
+ t.Fatalf("expected empty dispatch order for empty accounts")
+ }
+}
+
+func TestSnapshotDispatchOrderPrecomputed(t *testing.T) {
+ tempDir := t.TempDir()
+ aliceProbe := filepath.Join(tempDir, "alice-probe.json")
+ bobProbe := filepath.Join(tempDir, "bob-probe.json")
+ if err := os.WriteFile(aliceProbe, []byte(`{"ok":true}`), 0o600); err != nil {
+ t.Fatalf("write alice probe failed: %v", err)
+ }
+ if err := os.WriteFile(bobProbe, []byte(`{"ok":true}`), 0o600); err != nil {
+ t.Fatalf("write bob probe failed: %v", err)
+ }
+
+ cfg := defaultConfig()
+ cfg.APIKey = "snapshot-dispatch-order-api-key"
+ cfg.ActiveAccount = "bob@example.com"
+ cfg.Accounts = []NotionAccount{
+ {Email: "alice@example.com", Priority: 10, MaxConcurrency: 1, ProbeJSON: aliceProbe},
+ {Email: "bob@example.com", Priority: 1, MaxConcurrency: 1, ProbeJSON: bobProbe},
+ {Email: "carol@example.com", Priority: 50, MaxConcurrency: 1, Disabled: true},
+ }
+ cfg = normalizeConfig(cfg)
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+
+ snap := state.snap.Load()
+ if snap == nil {
+ t.Fatalf("expected non-nil snapshot bundle")
+ }
+ if len(snap.DispatchOrder) != 2 {
+ t.Fatalf("unexpected dispatch order length: got %d want 2", len(snap.DispatchOrder))
+ }
+ if getAccountEmailKey(snap.DispatchOrder[0]) != "bob@example.com" {
+ t.Fatalf("expected active account first in precomputed dispatch order, got %q", snap.DispatchOrder[0].Email)
+ }
+ if getAccountEmailKey(snap.DispatchOrder[1]) != "alice@example.com" {
+ t.Fatalf("expected second candidate to be alice, got %q", snap.DispatchOrder[1].Email)
+ }
+}
+
+func TestResolveDispatchCandidatesFromSnapshotUsesPrecomputedOrder(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ APIKey: "test-api-key",
+ Accounts: []NotionAccount{
+ {Email: "first@example.com", Priority: 10, MaxConcurrency: 1},
+ {Email: "second@example.com", Priority: 20, MaxConcurrency: 1},
+ },
+ ActiveAccount: "first@example.com",
+ })
+ now := time.Now()
+ bundle := &snapshotBundle{
+ Config: cfg,
+ DispatchOrder: []NotionAccount{
+ {Email: "second@example.com", Priority: 20, MaxConcurrency: 1},
+ {Email: "first@example.com", Priority: 10, MaxConcurrency: 1},
+ },
+ }
+ candidates, err := resolveDispatchCandidatesFromSnapshot(bundle, PromptRunRequest{}, now)
+ if err != nil {
+ t.Fatalf("resolveDispatchCandidatesFromSnapshot failed: %v", err)
+ }
+ if len(candidates) != 2 {
+ t.Fatalf("unexpected candidates length: got %d want 2", len(candidates))
+ }
+ if getAccountEmailKey(candidates[0]) != "second@example.com" || getAccountEmailKey(candidates[1]) != "first@example.com" {
+ t.Fatalf("unexpected candidate order from snapshot: %+v", candidates)
+ }
+}
+
+func TestConversationStoreGetReturnsValueSnapshotAfterMutation(t *testing.T) {
+ store := newConversationStore()
+ created := store.Create(ConversationCreateRequest{
+ PreferredID: "conv-value-snapshot",
+ Source: "api",
+ Transport: "chat_completions",
+ Model: "gpt-5.4",
+ Prompt: "hello",
+ })
+ got1, ok := store.Get(created.ID)
+ if !ok {
+ t.Fatalf("expected created conversation to exist")
+ }
+ if got1.Status != "running" {
+ t.Fatalf("unexpected initial status: %q", got1.Status)
+ }
+
+ store.Complete(created.ID, InferenceResult{
+ Text: "done",
+ ThreadID: "thread-1",
+ AccountEmail: "alice@example.com",
+ })
+
+ got2, ok := store.Get(created.ID)
+ if !ok {
+ t.Fatalf("expected conversation after completion")
+ }
+ if got2.Status != "completed" {
+ t.Fatalf("unexpected status after complete: %q", got2.Status)
+ }
+ if got1.Status == got2.Status {
+ t.Fatalf("expected old value snapshot to remain unchanged, got1=%q got2=%q", got1.Status, got2.Status)
+ }
+}
+
+func TestConversationStoreSummaryUsesCachedPreviewAfterMutations(t *testing.T) {
+ store := newConversationStore()
+ created := store.Create(ConversationCreateRequest{
+ PreferredID: "conv-preview-cache",
+ Source: "api",
+ Transport: "chat_completions",
+ Model: "gpt-5.4",
+ Prompt: "first question",
+ })
+ list1 := store.List()
+ if len(list1) == 0 {
+ t.Fatalf("expected list to have one entry")
+ }
+ if !strings.Contains(list1[0].Preview, "first question") {
+ t.Fatalf("unexpected initial preview: %q", list1[0].Preview)
+ }
+
+ store.AppendAssistantDelta(created.ID, "assistant draft")
+ list2 := store.List()
+ if len(list2) == 0 {
+ t.Fatalf("expected list to have one entry after delta")
+ }
+ if !strings.Contains(list2[0].Preview, "assistant draft") {
+ t.Fatalf("expected preview to reflect assistant delta, got %q", list2[0].Preview)
+ }
+
+ store.Complete(created.ID, InferenceResult{
+ Text: "final assistant reply",
+ ThreadID: "thread-preview",
+ AccountEmail: "preview@example.com",
+ })
+ list3 := store.List()
+ if len(list3) == 0 {
+ t.Fatalf("expected list to have one entry after complete")
+ }
+ if !strings.Contains(list3[0].Preview, "final assistant reply") {
+ t.Fatalf("expected preview to reflect completed assistant text, got %q", list3[0].Preview)
+ }
+}
+
+func TestRequestedWebSearchFromTypedMetadataAndTools(t *testing.T) {
+ if got := requestedWebSearchFromTyped(nil, json.RawMessage(`{"use_web_search": true}`), nil, false); !got {
+ t.Fatalf("expected use_web_search=true from metadata to enable web search")
+ }
+ if got := requestedWebSearchFromTyped(nil, json.RawMessage(`{"notion_use_web_search":"false"}`), nil, true); got {
+ t.Fatalf("expected notion_use_web_search=false metadata to disable web search")
+ }
+ if got := requestedWebSearchFromTyped(nil, nil, json.RawMessage(`[{"type":"web_search_preview"}]`), false); !got {
+ t.Fatalf("expected web_search tool to enable web search")
+ }
+ if got := requestedWebSearchFromTyped(nil, map[string]any{"use_web_search": "1"}, nil, false); !got {
+ t.Fatalf("expected use_web_search=1 map metadata to enable web search")
+ }
+ if got := requestedWebSearchFromTyped(nil, nil, []map[string]any{{"type": "web_search_legacy"}}, false); !got {
+ t.Fatalf("expected web_search tool map slice to enable web search")
+ }
+}
+
+func TestExtractTypedRequestBodies(t *testing.T) {
+ chatPayload := map[string]any{
+ "model": "gpt-5.4",
+ "stream": true,
+ "stream_options": map[string]any{"include_usage": true},
+ "conversation_id": "conv-typed-chat",
+ "account_email": "typed@example.com",
+ "use_web_search": "true",
+ "metadata": map[string]any{"notion_use_web_search": false},
+ "attachments": []any{map[string]any{"type": "image_url", "url": "https://example.com/image.png"}},
+ "messages": []any{map[string]any{"role": "user", "content": "hello"}},
+ "type": "continue",
+ "user_name": "user",
+ "char_name": "char",
+ "group_names": []any{"g1"},
+ "continue_prefill": "next",
+ "show_thoughts": true,
+ "notion_account_email": "typed2@example.com",
+ }
+ chatTyped := extractChatCompletionsRequestBody(chatPayload)
+ if chatTyped.Model != "gpt-5.4" || !chatTyped.Stream {
+ t.Fatalf("unexpected typed chat body: %+v", chatTyped)
+ }
+ if chatTyped.UseWebSearch == nil || !*chatTyped.UseWebSearch {
+ t.Fatalf("expected typed chat use_web_search=true")
+ }
+ if chatTyped.StreamIncludeUsage == nil || !*chatTyped.StreamIncludeUsage {
+ t.Fatalf("expected typed chat stream include_usage=true")
+ }
+ if _, ok := chatTyped.Attachments.([]any); !ok {
+ t.Fatalf("expected typed chat attachments to keep raw array type")
+ }
+ if _, ok := chatTyped.Messages.([]any); !ok {
+ t.Fatalf("expected typed chat messages to keep raw array type")
+ }
+ if !chatTyped.likelySillyTavernByEnvelope() {
+ t.Fatalf("expected chat body to be identified as likely sillytavern by envelope")
+ }
+
+ respPayload := map[string]any{
+ "model": "gpt-5.4",
+ "stream": false,
+ "previous_response_id": "resp_123",
+ "conversation_id": "conv-typed-responses",
+ "thread_id": "thread-typed",
+ "account_email": "resp@example.com",
+ "use_web_search": true,
+ "metadata": map[string]any{"use_web_search": true},
+ "input": []any{map[string]any{"type": "text", "text": "input payload"}},
+ "attachments": []any{map[string]any{"type": "file", "file_url": "https://example.com/file.txt"}},
+ }
+ respTyped := extractResponsesRequestBody(respPayload)
+ if respTyped.Model != "gpt-5.4" || respTyped.Stream {
+ t.Fatalf("unexpected typed responses body: %+v", respTyped)
+ }
+ if respTyped.PreviousResponseID != "resp_123" || respTyped.ConversationID != "conv-typed-responses" {
+ t.Fatalf("unexpected typed responses ids: %+v", respTyped)
+ }
+ if respTyped.UseWebSearch == nil || !*respTyped.UseWebSearch {
+ t.Fatalf("expected typed responses use_web_search=true")
+ }
+ if _, ok := respTyped.Input.([]any); !ok {
+ t.Fatalf("expected typed responses input to keep raw array type")
+ }
+ if _, ok := respTyped.Attachments.([]any); !ok {
+ t.Fatalf("expected typed responses attachments to keep raw array type")
+ }
+}
+
+func TestExtractChatTypedStreamIncludeUsageParsing(t *testing.T) {
+ fromRaw := extractChatCompletionsRequestBody(map[string]any{
+ "stream_options": json.RawMessage(`{"include_usage":"1"}`),
+ })
+ if fromRaw.StreamIncludeUsage == nil || !*fromRaw.StreamIncludeUsage {
+ t.Fatalf("expected stream include_usage to parse true from raw json string flag")
+ }
+
+ fromMapFalse := extractChatCompletionsRequestBody(map[string]any{
+ "stream_options": map[string]any{"include_usage": false},
+ })
+ if fromMapFalse.StreamIncludeUsage == nil {
+ t.Fatalf("expected stream include_usage pointer to be populated for explicit false")
+ }
+ if *fromMapFalse.StreamIncludeUsage {
+ t.Fatalf("expected stream include_usage=false from typed stream_options map")
+ }
+}
+
+func TestRequestedIdentifiersFromTypedRespectHeaders(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ req.Header.Set("X-Conversation-ID", "header-conv")
+ req.Header.Set("X-Thread-ID", "header-thread")
+ req.Header.Set("X-Account-Email", "header@example.com")
+
+ if got := requestedConversationIDFromTyped(req, "body-conv", "body-conv2", map[string]any{"conversation_id": "meta-conv"}); got != "header-conv" {
+ t.Fatalf("conversation id should prefer header, got %q", got)
+ }
+ if got := requestedThreadIDFromTyped(req, "body-thread", "body-thread2", "body-thread3", map[string]any{"thread_id": "meta-thread"}); got != "header-thread" {
+ t.Fatalf("thread id should prefer header, got %q", got)
+ }
+ if got := requestedAccountEmailFromTyped(req, "body@example.com", "body2@example.com", map[string]any{"account_email": "meta@example.com"}); got != "header@example.com" {
+ t.Fatalf("account email should prefer header, got %q", got)
+ }
+}
+
+func TestRequestedIdentifiersFromTypedFallbackToMetadata(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ metadata := json.RawMessage(`{"conversation_id":"meta-conv","thread_id":"meta-thread","account_email":"meta@example.com"}`)
+
+ if got := requestedConversationIDFromTyped(req, "", "", metadata); got != "meta-conv" {
+ t.Fatalf("conversation id should fallback to metadata, got %q", got)
+ }
+ if got := requestedThreadIDFromTyped(req, "", "", "", metadata); got != "meta-thread" {
+ t.Fatalf("thread id should fallback to metadata, got %q", got)
+ }
+ if got := requestedAccountEmailFromTyped(req, "", "", metadata); got != "meta@example.com" {
+ t.Fatalf("account email should fallback to metadata, got %q", got)
+ }
+}
+
+func TestResolveContinuationConversationWithExplicitUsesTypedThreadID(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ seeded := seedCompletedConversation(t, app, "conv-typed-explicit", "Seed question", "Seed answer", "thread-explicit")
+
+ segments := []conversationPromptSegment{
+ {Role: "user", Text: "follow up"},
+ }
+
+ target, ok := app.resolveContinuationConversationWithExplicit("", "", segments, "", "thread-explicit")
+ if !ok {
+ t.Fatalf("expected explicit typed thread id to resolve continuation target")
+ }
+ if strings.TrimSpace(target.Conversation.ID) != seeded.ID {
+ t.Fatalf("unexpected resolved conversation id: got %q want %q", target.Conversation.ID, seeded.ID)
+ }
+ if strings.TrimSpace(target.Conversation.ThreadID) != "thread-explicit" {
+ t.Fatalf("unexpected resolved thread id: got %q", target.Conversation.ThreadID)
+ }
+}
+
+func TestTypedEnvelopeExtractionFallsBackToLegacyWhenTypedFieldsMissing(t *testing.T) {
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = ""
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+ app := &App{State: state}
+
+ var captured PromptRunRequest
+ app.runPromptOverride = func(_ *http.Request, request PromptRunRequest) (InferenceResult, error) {
+ captured = request
+ return InferenceResult{
+ Text: "typed fallback ok",
+ ThreadID: "thread-typed-fallback",
+ MessageID: "msg-typed-fallback",
+ TraceID: "trace-typed-fallback",
+ AccountEmail: "header@example.com",
+ }, nil
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", mustJSONBody(t, map[string]any{
+ "model": "gpt-5.4",
+ "messages": []map[string]any{
+ {"role": "user", "content": "hello"},
+ },
+ }))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ req.Header.Set("X-Account-Email", "header@example.com")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d body=%s", rec.Code, rec.Body.String())
+ }
+ if captured.PinnedAccountEmail != "header@example.com" {
+ t.Fatalf("expected pinned account from header fallback path, got %q", captured.PinnedAccountEmail)
+ }
+ if captured.PublicModel != "gpt-5.4" {
+ t.Fatalf("expected resolved model from legacy payload path, got %q", captured.PublicModel)
+ }
+}
+
+func sqliteWriterFallbackValue(reason string) int64 {
+ if strings.TrimSpace(reason) == "" {
+ return 0
+ }
+ value := sqliteWriterFallbackTotalMetric.Get(reason)
+ if value == nil {
+ return 0
+ }
+ counter, ok := value.(*expvar.Int)
+ if !ok || counter == nil {
+ return 0
+ }
+ return counter.Value()
+}
+
+func boolPtr(value bool) *bool {
+ return &value
+}
+
+func TestSaveResponseWithAccountPersistsViaAsyncSQLiteWriter(t *testing.T) {
+ tempDir := t.TempDir()
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = filepath.Join(tempDir, "responses.sqlite")
+ cfg.Storage.PersistConversations = true
+ cfg.Storage.PersistResponses = boolPtr(true)
+ cfg.Responses.StoreTTLSeconds = 3600
+
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+
+ responseID := "resp_async_test_1"
+ payload := map[string]any{
+ "id": responseID,
+ "object": "response",
+ "output": []any{
+ map[string]any{
+ "type": "message",
+ "content": []any{
+ map[string]any{
+ "type": "output_text",
+ "text": "hello from async sqlite writer",
+ },
+ },
+ },
+ },
+ }
+ state.saveResponseWithAccount(responseID, payload, "conv-async", "thread-async", "async@example.com")
+
+ deadline := time.Now().Add(3 * time.Second)
+ for {
+ record, ok := state.getStoredResponse(responseID)
+ if ok && strings.TrimSpace(record.ThreadID) == "thread-async" {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("response not visible in in-memory store before deadline")
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+
+ readStore, err := openSQLiteStore(cfg)
+ if err != nil {
+ t.Fatalf("openSQLiteStore(read) failed: %v", err)
+ }
+ defer func() {
+ _ = readStore.Close()
+ }()
+
+ waitUntil := time.Now().Add(3 * time.Second)
+ for {
+ rows, queryErr := readStore.db.Query(`SELECT payload_json, conversation_id, thread_id, account_email FROM responses WHERE response_id = ?`, responseID)
+ if queryErr != nil {
+ t.Fatalf("query persisted response failed: %v", queryErr)
+ }
+ found := false
+ var rawPayload string
+ var conversationID string
+ var threadID string
+ var accountEmail string
+ for rows.Next() {
+ found = true
+ if scanErr := rows.Scan(&rawPayload, &conversationID, &threadID, &accountEmail); scanErr != nil {
+ _ = rows.Close()
+ t.Fatalf("scan persisted response failed: %v", scanErr)
+ }
+ }
+ _ = rows.Close()
+ if found {
+ if strings.TrimSpace(conversationID) != "conv-async" {
+ t.Fatalf("conversation_id mismatch: got %q want %q", conversationID, "conv-async")
+ }
+ if strings.TrimSpace(threadID) != "thread-async" {
+ t.Fatalf("thread_id mismatch: got %q want %q", threadID, "thread-async")
+ }
+ if strings.TrimSpace(accountEmail) != "async@example.com" {
+ t.Fatalf("account_email mismatch: got %q want %q", accountEmail, "async@example.com")
+ }
+ if !strings.Contains(rawPayload, "hello from async sqlite writer") {
+ t.Fatalf("unexpected payload_json: %s", rawPayload)
+ }
+ break
+ }
+ if time.Now().After(waitUntil) {
+ t.Fatalf("persisted response not found before deadline")
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+}
+
+func TestSQLiteWriterCloseFlushesQueuedResponseWrites(t *testing.T) {
+ tempDir := t.TempDir()
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = filepath.Join(tempDir, "close-flush.sqlite")
+ cfg.Storage.PersistConversations = true
+ cfg.Storage.PersistResponses = boolPtr(true)
+ cfg.Responses.StoreTTLSeconds = 3600
+
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+
+ total := 12
+ for i := 0; i < total; i++ {
+ responseID := "resp_flush_" + strconv.Itoa(i)
+ state.saveResponseWithAccount(responseID, map[string]any{
+ "id": responseID,
+ "object": "response",
+ "idx": i,
+ }, "conv-flush", "thread-flush", "flush@example.com")
+ }
+
+ if err := state.Close(); err != nil {
+ t.Fatalf("state.Close failed: %v", err)
+ }
+
+ readStore, err := openSQLiteStore(cfg)
+ if err != nil {
+ t.Fatalf("openSQLiteStore(read) failed: %v", err)
+ }
+ defer func() {
+ _ = readStore.Close()
+ }()
+
+ row := readStore.db.QueryRow(`SELECT COUNT(1) FROM responses WHERE conversation_id = ? AND thread_id = ?`, "conv-flush", "thread-flush")
+ var persisted int
+ if scanErr := row.Scan(&persisted); scanErr != nil {
+ t.Fatalf("scan persisted count failed: %v", scanErr)
+ }
+ if persisted != total {
+ t.Fatalf("persisted response count mismatch after close flush: got %d want %d", persisted, total)
+ }
+}
+
+func TestSQLiteWriterFallbackMetricRemainsStableUnderNormalLoad(t *testing.T) {
+ tempDir := t.TempDir()
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = filepath.Join(tempDir, "fallback-metric.sqlite")
+ cfg.Storage.PersistConversations = true
+ cfg.Storage.PersistResponses = boolPtr(true)
+ cfg.Responses.StoreTTLSeconds = 3600
+
+ beforeChannelFull := sqliteWriterFallbackValue("channel_full")
+ beforeUnavailable := sqliteWriterFallbackValue("writer_unavailable")
+
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+
+ for i := 0; i < 8; i++ {
+ responseID := "resp_metric_" + strconv.Itoa(i)
+ state.saveResponseWithAccount(responseID, map[string]any{
+ "id": responseID,
+ "object": "response",
+ "idx": i,
+ }, "conv-metric", "thread-metric", "metric@example.com")
+ }
+
+ time.Sleep(250 * time.Millisecond)
+
+ afterChannelFull := sqliteWriterFallbackValue("channel_full")
+ afterUnavailable := sqliteWriterFallbackValue("writer_unavailable")
+ if afterChannelFull != beforeChannelFull {
+ t.Fatalf("expected no channel_full fallback in normal load; before=%d after=%d", beforeChannelFull, afterChannelFull)
+ }
+ if afterUnavailable != beforeUnavailable {
+ t.Fatalf("expected no writer_unavailable fallback in normal load; before=%d after=%d", beforeUnavailable, afterUnavailable)
+ }
+}
+
func TestHandleChatCompletionsFreshThreadContinuesExplicitConversationIDWithLatestUserOnly(t *testing.T) {
app := newFreshThreadTestApp(t)
@@ -369,6 +1621,53 @@ func TestServerStateSaveAndApplyRejectsEmptyAPIKey(t *testing.T) {
}
}
+func TestServerStateSaveAndApplyInvalidatesDispatchProbeCacheOnActiveAccountChange(t *testing.T) {
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = ""
+ cfg.Accounts = []NotionAccount{
+ {
+ Email: "alice@example.com",
+ ProbeJSON: "probe_files/notion_accounts/alice/probe.json",
+ UserID: "alice-user",
+ SpaceID: "alice-space",
+ ClientVersion: "v1",
+ },
+ {
+ Email: "bob@example.com",
+ ProbeJSON: "probe_files/notion_accounts/bob/probe.json",
+ UserID: "bob-user",
+ SpaceID: "bob-space",
+ ClientVersion: "v1",
+ },
+ }
+ cfg.ActiveAccount = "alice@example.com"
+
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+ if state.DispatchProbeCache == nil {
+ t.Fatalf("expected dispatch probe cache to be initialized")
+ }
+ state.DispatchProbeCache.markSuccess("alice@example.com", time.Now())
+ if state.DispatchProbeCache.shouldProbe("alice@example.com", 45*time.Second, time.Now()) {
+ t.Fatalf("expected warm cache entry before active-account change")
+ }
+
+ next := state.Config
+ next.ActiveAccount = "bob@example.com"
+ if err := state.SaveAndApply(next); err != nil {
+ t.Fatalf("SaveAndApply failed: %v", err)
+ }
+ if !state.DispatchProbeCache.shouldProbe("alice@example.com", 45*time.Second, time.Now()) {
+ t.Fatalf("expected cache invalidation after active-account switch")
+ }
+}
+
func TestHandleChatCompletionsStreamWritesErrorAfterHeadersSent(t *testing.T) {
app := newFreshThreadTestApp(t)
app.runPromptStreamSinkOverride = func(_ *http.Request, _ PromptRunRequest, sink InferenceStreamSink) (InferenceResult, error) {
@@ -401,6 +1700,47 @@ func TestHandleChatCompletionsStreamWritesErrorAfterHeadersSent(t *testing.T) {
}
}
+func TestHandleChatCompletionsStreamIncludeUsageFromTypedMessages(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ app.runPromptStreamSinkOverride = func(_ *http.Request, _ PromptRunRequest, sink InferenceStreamSink) (InferenceResult, error) {
+ if sink.Text != nil {
+ if err := sink.Text("hello "); err != nil {
+ t.Fatalf("stream text write failed: %v", err)
+ }
+ if err := sink.Text("world"); err != nil {
+ t.Fatalf("stream text write failed: %v", err)
+ }
+ }
+ return InferenceResult{
+ Text: "hello world",
+ Prompt: "hello world",
+ ThreadID: "thread-stream-usage",
+ MessageID: "msg-stream-usage",
+ }, nil
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", mustJSONBody(t, map[string]any{
+ "model": "gpt-5.4",
+ "stream": true,
+ "stream_options": map[string]any{"include_usage": true},
+ "messages": []map[string]any{
+ {"role": "user", "content": "hello"},
+ },
+ }))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ body := rec.Body.String()
+ if !strings.Contains(body, "\"usage\"") {
+ t.Fatalf("expected stream output to include usage chunk, got body=%s", body)
+ }
+ if !strings.Contains(body, "data: [DONE]") {
+ t.Fatalf("expected stream done marker, got body=%s", body)
+ }
+}
+
func TestNormalizeConfigDefaultsAccountMaxConcurrencyToOne(t *testing.T) {
cfg := normalizeConfig(AppConfig{
APIKey: "test-api-key",
@@ -487,3 +1827,562 @@ func TestRunPromptWithAccountPoolReturnsCapacityErrorWhenAllSlotsOccupied(t *tes
t.Fatalf("expected wrapped sentinel error, got %v", runErr)
}
}
+
+func TestRefreshSessionInvalidatesDispatchProbeCacheOnSuccess(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ APIKey: "test-api-key",
+ Accounts: []NotionAccount{
+ {
+ Email: "alice@example.com",
+ ProbeJSON: "/tmp/alice/probe.json",
+ StorageStatePath: "/tmp/alice/storage_state.json",
+ PendingStatePath: "/tmp/alice/pending_login.json",
+ UserID: "alice-user",
+ SpaceID: "alice-space",
+ UserName: "alice",
+ SpaceName: "alice-space-name",
+ ClientVersion: "v1",
+ Status: "ready",
+ },
+ },
+ ActiveAccount: "alice@example.com",
+ SessionRefresh: SessionRefreshConfig{
+ Enabled: true,
+ RetryOnAuthError: true,
+ AutoSwitch: true,
+ },
+ })
+ state := &ServerState{
+ Config: cfg,
+ Session: SessionInfo{UserID: "alice-user", SpaceID: "alice-space"},
+ DispatchProbeCache: newProbeCache(),
+ ResponseStore: newResponseStore(45 * time.Second),
+ Conversations: newConversationStore(),
+ AdminTokens: map[string]time.Time{},
+ AdminLoginAttempts: map[string]AdminLoginAttempt{},
+ }
+ slot := &accountSlot{}
+ slot.max.Store(1)
+ slot.inflight.Store(0)
+ slotMap := map[string]*accountSlot{
+ "alice@example.com": slot,
+ }
+ state.slots.Store(&slotMap)
+ syncDispatchSlotInflightFromSlots(slotMap)
+ state.DispatchProbeCache.markSuccess("alice@example.com", time.Now())
+
+ originalTryRefresh := testHookTryRefreshAccount
+ originalSaveAndApply := testHookSaveAndApply
+ defer func() {
+ testHookTryRefreshAccount = originalTryRefresh
+ testHookSaveAndApply = originalSaveAndApply
+ }()
+
+ testHookTryRefreshAccount = func(ctx context.Context, cfg AppConfig, account NotionAccount) (AppConfig, error) {
+ account.Status = "ready"
+ account.LastError = ""
+ account.LastRefreshAt = time.Now().Format(time.RFC3339)
+ cfg.UpsertAccount(account)
+ return cfg, nil
+ }
+ testHookSaveAndApply = func(s *ServerState, cfg AppConfig) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.Config = cfg
+ s.updateSnapshotBundleLocked()
+ return nil
+ }
+
+ if err := state.RefreshSession(context.Background(), "test_refresh_success"); err != nil {
+ t.Fatalf("refresh session failed: %v", err)
+ }
+ if !state.DispatchProbeCache.shouldProbe("alice@example.com", 45*time.Second, time.Now()) {
+ t.Fatalf("expected probe cache to be invalidated after refresh success")
+ }
+}
+
+func newSQLiteStoreTestConfig(path string) AppConfig {
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = path
+ return cfg
+}
+
+func TestOpenSQLiteStoreConfiguresReadWriteAndReadOnlyPools(t *testing.T) {
+ cfg := newSQLiteStoreTestConfig(filepath.Join(t.TempDir(), "notion2api.sqlite"))
+ store, err := openSQLiteStore(cfg)
+ if err != nil {
+ t.Fatalf("openSQLiteStore failed: %v", err)
+ }
+ defer func() {
+ _ = store.Close()
+ }()
+ if store.db == nil {
+ t.Fatalf("expected writable sqlite connection")
+ }
+ if store.roDB == nil {
+ t.Fatalf("expected read-only sqlite connection")
+ }
+ if got := store.db.Stats().MaxOpenConnections; got != 1 {
+ t.Fatalf("unexpected write db max open conns: got %d want 1", got)
+ }
+ wantReadConns := maxInt(2, runtime.NumCPU())
+ if got := store.roDB.Stats().MaxOpenConnections; got != wantReadConns {
+ t.Fatalf("unexpected read db max open conns: got %d want %d", got, wantReadConns)
+ }
+}
+
+func TestSQLiteStoreInitAppliesExtendedPragmas(t *testing.T) {
+ cfg := newSQLiteStoreTestConfig(filepath.Join(t.TempDir(), "notion2api.sqlite"))
+ store, err := openSQLiteStore(cfg)
+ if err != nil {
+ t.Fatalf("openSQLiteStore failed: %v", err)
+ }
+ defer func() {
+ _ = store.Close()
+ }()
+
+ var mmapSize int64
+ if err := store.db.QueryRow("PRAGMA mmap_size;").Scan(&mmapSize); err != nil {
+ t.Fatalf("query mmap_size failed: %v", err)
+ }
+ if mmapSize != 268435456 {
+ t.Fatalf("unexpected mmap_size: got %d want %d", mmapSize, int64(268435456))
+ }
+
+ var cacheSize int64
+ if err := store.db.QueryRow("PRAGMA cache_size;").Scan(&cacheSize); err != nil {
+ t.Fatalf("query cache_size failed: %v", err)
+ }
+ if cacheSize != -65536 {
+ t.Fatalf("unexpected cache_size: got %d want %d", cacheSize, int64(-65536))
+ }
+
+ var tempStore int64
+ if err := store.db.QueryRow("PRAGMA temp_store;").Scan(&tempStore); err != nil {
+ t.Fatalf("query temp_store failed: %v", err)
+ }
+ if tempStore != 2 {
+ t.Fatalf("unexpected temp_store: got %d want 2(memory)", tempStore)
+ }
+
+ var autoCheckpoint int64
+ if err := store.db.QueryRow("PRAGMA wal_autocheckpoint;").Scan(&autoCheckpoint); err != nil {
+ t.Fatalf("query wal_autocheckpoint failed: %v", err)
+ }
+ if autoCheckpoint != 1000 {
+ t.Fatalf("unexpected wal_autocheckpoint: got %d want 1000", autoCheckpoint)
+ }
+}
+
+func TestSQLiteStoreReadOnlyConnectionRejectsWrites(t *testing.T) {
+ cfg := newSQLiteStoreTestConfig(filepath.Join(t.TempDir(), "notion2api.sqlite"))
+ store, err := openSQLiteStore(cfg)
+ if err != nil {
+ t.Fatalf("openSQLiteStore failed: %v", err)
+ }
+ defer func() {
+ _ = store.Close()
+ }()
+ _, err = store.roDB.Exec("CREATE TABLE read_only_write_should_fail(id INTEGER)")
+ if err == nil {
+ t.Fatalf("expected write on read-only connection to fail")
+ }
+ if !strings.Contains(strings.ToLower(err.Error()), "readonly") {
+ t.Fatalf("expected readonly error, got: %v", err)
+ }
+}
+
+func TestSQLiteStoreLoadAccountsUsesReadOnlyConnection(t *testing.T) {
+ cfg := newSQLiteStoreTestConfig(filepath.Join(t.TempDir(), "notion2api.sqlite"))
+ store, err := openSQLiteStore(cfg)
+ if err != nil {
+ t.Fatalf("openSQLiteStore failed: %v", err)
+ }
+ defer func() {
+ _ = store.Close()
+ }()
+
+ saveCfg := normalizeConfig(AppConfig{
+ APIKey: "test-api-key",
+ Storage: StorageConfig{SQLitePath: cfg.Storage.SQLitePath},
+ LoginHelper: LoginHelperConfig{SessionsDir: "probe_files/notion_accounts"},
+ Accounts: []NotionAccount{{Email: "alice@example.com"}},
+ ActiveAccount: "alice@example.com",
+ })
+ if err := store.SaveAccounts(saveCfg); err != nil {
+ t.Fatalf("SaveAccounts failed: %v", err)
+ }
+
+ if err := store.db.Close(); err != nil {
+ t.Fatalf("close write db failed: %v", err)
+ }
+ store.db = nil
+ accounts, activeAccount, ok, err := store.LoadAccounts()
+ if err != nil {
+ t.Fatalf("LoadAccounts failed: %v", err)
+ }
+ if !ok {
+ t.Fatalf("expected persisted accounts to be available")
+ }
+ if len(accounts) != 1 {
+ t.Fatalf("unexpected account count: got %d want 1", len(accounts))
+ }
+ if getAccountEmailKey(accounts[0]) != "alice@example.com" {
+ t.Fatalf("unexpected loaded account email: %q", accounts[0].Email)
+ }
+ if canonicalEmailKey(activeAccount) != "alice@example.com" {
+ t.Fatalf("unexpected active account: %q", activeAccount)
+ }
+}
+
+func TestServeHTTPOptionsReturnsCORSNoContent(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ req := httptest.NewRequest(http.MethodOptions, "/v1/models", nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("unexpected options status: got %d want %d", rec.Code, http.StatusNoContent)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Origin"); got != corsAllowOrigin {
+ t.Fatalf("unexpected Access-Control-Allow-Origin: got %q want %q", got, corsAllowOrigin)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Headers"); got != corsAllowHeaders {
+ t.Fatalf("unexpected Access-Control-Allow-Headers: got %q want %q", got, corsAllowHeaders)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Methods"); got != corsAllowMethods {
+ t.Fatalf("unexpected Access-Control-Allow-Methods: got %q want %q", got, corsAllowMethods)
+ }
+}
+
+func TestServeIndexIncludesCORSHeaders(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d want %d", rec.Code, http.StatusOK)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Origin"); got != corsAllowOrigin {
+ t.Fatalf("unexpected Access-Control-Allow-Origin: got %q want %q", got, corsAllowOrigin)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Headers"); got != corsAllowHeaders {
+ t.Fatalf("unexpected Access-Control-Allow-Headers: got %q want %q", got, corsAllowHeaders)
+ }
+ if got := rec.Header().Get("Access-Control-Allow-Methods"); got != corsAllowMethods {
+ t.Fatalf("unexpected Access-Control-Allow-Methods: got %q want %q", got, corsAllowMethods)
+ }
+}
+
+func TestNormalizeConfigSetsMaxRequestBodyBytesDefault(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{})
+ if got := cfg.Limits.MaxRequestBodyBytes; got != 4*1024*1024 {
+ t.Fatalf("unexpected max request body bytes default: got %d want %d", got, int64(4*1024*1024))
+ }
+}
+
+func TestNormalizeConfigClampsNonPositiveMaxRequestBodyBytes(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{Limits: LimitsConfig{MaxRequestBodyBytes: -1}})
+ if got := cfg.Limits.MaxRequestBodyBytes; got != 4*1024*1024 {
+ t.Fatalf("unexpected max request body bytes clamp: got %d want %d", got, int64(4*1024*1024))
+ }
+}
+
+func TestHandleChatCompletionsRejectsTooLargeBody(t *testing.T) {
+ cfg := defaultConfig()
+ cfg.APIKey = "test-api-key"
+ cfg.Storage.SQLitePath = ""
+ cfg.Limits.MaxRequestBodyBytes = 128
+ state, err := newServerState(cfg)
+ if err != nil {
+ t.Fatalf("newServerState failed: %v", err)
+ }
+ defer func() {
+ _ = state.Close()
+ }()
+ app := &App{State: state}
+
+ oversizeText := strings.Repeat("x", 512)
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", mustJSONBody(t, map[string]any{
+ "model": "gpt-5.4",
+ "messages": []map[string]any{
+ {"role": "user", "content": oversizeText},
+ },
+ }))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("unexpected status: got %d want %d body=%s", rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, `"code":"request_too_large"`) {
+ t.Fatalf("expected request_too_large code, got %s", body)
+ }
+ if !strings.Contains(body, `"type":"invalid_request_error"`) {
+ t.Fatalf("expected invalid_request_error type, got %s", body)
+ }
+}
+
+func TestDecodeBodyRawWithLimitRejectsTrailingContent(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"a":1} {"b":2}`))
+ raw, err := decodeBodyRawWithLimit(nil, req, 0)
+ if err == nil {
+ t.Fatalf("expected trailing content error, got raw=%q", string(raw))
+ }
+ if !strings.Contains(err.Error(), "invalid json") {
+ t.Fatalf("expected invalid json error, got %v", err)
+ }
+}
+
+func TestDecodeBodyRawWithLimitNormalizesWhitespace(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(" \n\t {\"a\":1}\n\t "))
+ raw, err := decodeBodyRawWithLimit(nil, req, 0)
+ if err != nil {
+ t.Fatalf("decodeBodyRawWithLimit failed: %v", err)
+ }
+ if got := strings.TrimSpace(string(raw)); got != "{\"a\":1}" {
+ t.Fatalf("unexpected normalized raw body: got %q", got)
+ }
+}
+
+func TestDecodeBodyRawWithLimitTreatsEmptyBodyAsEmptyObject(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(" \n\t "))
+ raw, err := decodeBodyRawWithLimit(nil, req, 0)
+ if err != nil {
+ t.Fatalf("decodeBodyRawWithLimit failed: %v", err)
+ }
+ if string(raw) != "{}" {
+ t.Fatalf("expected empty object for empty body, got %q", string(raw))
+ }
+}
+
+func TestDecodeChatCompletionsRequestBodyFromRawFallsBackToMapOnTypedDecodeMismatch(t *testing.T) {
+ raw := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"group_names":[1]}`)
+ typed, payload, err := decodeChatCompletionsRequestBodyFromRaw(raw)
+ if err != nil {
+ t.Fatalf("decodeChatCompletionsRequestBodyFromRaw failed: %v", err)
+ }
+ if payload == nil {
+ t.Fatalf("expected payload fallback map to be populated")
+ }
+ messages := sliceValue(typed.Messages)
+ if len(messages) != 1 {
+ t.Fatalf("expected typed messages recovered via map fallback, got len=%d", len(messages))
+ }
+ msg := mapValue(messages[0])
+ if strings.TrimSpace(stringValue(msg["content"])) != "hello" {
+ t.Fatalf("expected fallback-typed message content 'hello', got %#v", msg["content"])
+ }
+}
+
+func TestDecodeChatCompletionsRequestBodyFromRawParsesStreamIncludeUsageWithoutMapFallback(t *testing.T) {
+ raw := []byte(`{"model":"gpt-5.4","stream_options":{"include_usage":"1"},"messages":[{"role":"user","content":"hello"}]}`)
+ typed, payload, err := decodeChatCompletionsRequestBodyFromRaw(raw)
+ if err != nil {
+ t.Fatalf("decodeChatCompletionsRequestBodyFromRaw failed: %v", err)
+ }
+ if payload != nil {
+ t.Fatalf("expected typed decode path without map fallback")
+ }
+ if typed.StreamIncludeUsage == nil || !*typed.StreamIncludeUsage {
+ t.Fatalf("expected stream include_usage=true from typed decode path")
+ }
+}
+
+func TestHandleChatCompletionsSillyTavernFallbackOnContinuePrefillKey(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ captured := PromptRunRequest{}
+ app.runPromptOverride = func(_ *http.Request, request PromptRunRequest) (InferenceResult, error) {
+ captured = request
+ return InferenceResult{
+ Text: "ok",
+ ThreadID: "thread-st-fallback",
+ AccountEmail: "seed@example.com",
+ }, nil
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
+ "model":"gpt-5.4",
+ "messages":[{"role":"user","content":"Hello there"}],
+ "continue_prefill":"...",
+ "group_names":[1]
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("unexpected status: got %d body=%s", rec.Code, rec.Body.String())
+ }
+ if captured.ClientProfile != sillyTavernClientProfile {
+ t.Fatalf("expected sillytavern client profile, got %q", captured.ClientProfile)
+ }
+ if strings.TrimSpace(captured.Prompt) == "" {
+ t.Fatalf("expected non-empty prompt for sillytavern fallback")
+ }
+}
+
+func TestDecodeResponsesRequestBodyFromRawFallsBackToMapOnTypedDecodeMismatch(t *testing.T) {
+ raw := []byte(`{"model":"gpt-5.4","input":"hello","attachments":[{"type":"file","file_url":"https://example.com/f.txt"}],"conversation_id":1}`)
+ typed, payload, err := decodeResponsesRequestBodyFromRaw(raw)
+ if err != nil {
+ t.Fatalf("decodeResponsesRequestBodyFromRaw failed: %v", err)
+ }
+ if payload == nil {
+ t.Fatalf("expected payload fallback map to be populated")
+ }
+ if strings.TrimSpace(typed.Model) != "gpt-5.4" {
+ t.Fatalf("unexpected model after fallback: %q", typed.Model)
+ }
+ if strings.TrimSpace(flattenContent(typed.Input)) != "hello" {
+ t.Fatalf("expected fallback-typed input 'hello', got %#v", typed.Input)
+ }
+ atts := sliceValue(typed.Attachments)
+ if len(atts) != 1 {
+ t.Fatalf("expected one attachment after fallback, got %d", len(atts))
+ }
+}
+
+func TestHandleResponsesTypedFirstDecodeFallbackOnConversationIDTypeMismatch(t *testing.T) {
+ app := newFreshThreadTestApp(t)
+ seeded := seedCompletedConversation(t, app, "conv-responses-fallback", "Please remember this.", "Remembered.", "thread-old-responses-fallback")
+
+ var captured PromptRunRequest
+ app.runPromptOverride = func(_ *http.Request, request PromptRunRequest) (InferenceResult, error) {
+ captured = request
+ return InferenceResult{
+ Text: "Summary ready.",
+ ThreadID: "thread-new-responses-fallback",
+ MessageID: "msg-new-responses-fallback",
+ TraceID: "trace-new-responses-fallback",
+ AccountEmail: "seed@example.com",
+ }, nil
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
+ "model":"gpt-5.4",
+ "input":"Summarize that.",
+ "attachments":[{"type":"file","file_url":"https://example.com/f.txt"}],
+ "conversation_id":1
+ }`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer test-api-key")
+ req.Header.Set("X-Conversation-ID", seeded.ID)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status mismatch: got %d body=%s", rec.Code, rec.Body.String())
+ }
+ if got := rec.Header().Get("X-Conversation-ID"); got != seeded.ID {
+ t.Fatalf("conversation header mismatch: got %q want %q", got, seeded.ID)
+ }
+ if !captured.ForceLocalConversationContinue {
+ t.Fatalf("expected ForceLocalConversationContinue to be enabled")
+ }
+ assertPromptContains(t, captured.Prompt,
+ "Continue the conversation using the transcript below.",
+ "[user]\nPlease remember this.",
+ "[assistant]\nRemembered.",
+ "[user]\nSummarize that.",
+ )
+ assertConversationContinued(t, app, seeded.ID, "thread-new-responses-fallback", "Summary ready.")
+}
+
+func TestCollectProbeModelPathsIncludesActiveAndAccountProbeJSON(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ ProbeJSON: " probe_files/notion_accounts/active/probe.json ",
+ Accounts: []NotionAccount{
+ {ProbeJSON: "probe_files/notion_accounts/alpha/probe.json"},
+ {ProbeJSON: "probe_files/notion_accounts/alpha/probe.json"},
+ {ProbeJSON: " probe_files/notion_accounts/beta/probe.json "},
+ },
+ })
+ paths := collectProbeModelPaths(cfg)
+ for i := range paths {
+ paths[i] = strings.ReplaceAll(paths[i], "\\", "/")
+ }
+ sort.Strings(paths)
+ expected := []string{
+ "probe_files/notion_accounts/active/probe.json",
+ "probe_files/notion_accounts/alpha/probe.json",
+ "probe_files/notion_accounts/beta/probe.json",
+ }
+ if len(paths) != len(expected) {
+ t.Fatalf("unexpected path count: got %d want %d (%v)", len(paths), len(expected), paths)
+ }
+ for i := range expected {
+ if paths[i] != expected[i] {
+ t.Fatalf("unexpected path[%d]: got %q want %q", i, paths[i], expected[i])
+ }
+ }
+}
+
+func TestBuildModelRegistryLoadsProbeModelsFromActiveAndAccountPaths(t *testing.T) {
+ dir := t.TempDir()
+ activeProbe := filepath.Join(dir, "active-probe.json")
+ accountProbe := filepath.Join(dir, "account-probe.json")
+ activeBlob := `{"models":[{"model":"active-model-raw","modelMessage":"Active Model","modelFamily":"openai","displayGroup":"fast","isDisabled":false,"markdownChat":{"beta":false},"workflow":{"finalModelName":"active-notion-model","beta":false},"customAgent":{"finalModelName":"","beta":false}}]}`
+ accountBlob := `{"models":[{"model":"account-model-raw","modelMessage":"Account Model","modelFamily":"anthropic","displayGroup":"intelligent","isDisabled":false,"markdownChat":{"beta":false},"workflow":{"finalModelName":"account-notion-model","beta":false},"customAgent":{"finalModelName":"","beta":false}}]}`
+ writeProbeFile := func(path string, blob string) {
+ payload := map[string]any{
+ "email": "tester@example.com",
+ "userId": "user-id",
+ "spaceId": "space-id",
+ "clientVersion": "v1",
+ "embeddedModels": blob,
+ }
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ t.Fatalf("marshal probe payload failed: %v", err)
+ }
+ if err := os.WriteFile(path, encoded, 0o600); err != nil {
+ t.Fatalf("write probe payload failed: %v", err)
+ }
+ }
+ writeProbeFile(activeProbe, activeBlob)
+ writeProbeFile(accountProbe, accountBlob)
+
+ cfg := normalizeConfig(AppConfig{
+ ProbeJSON: activeProbe,
+ Accounts: []NotionAccount{
+ {Email: "alpha@example.com", ProbeJSON: accountProbe},
+ },
+ })
+ registry := buildModelRegistry(cfg)
+ if _, err := registry.Resolve("active-model", ""); err != nil {
+ t.Fatalf("expected active probe model to be loaded, got err=%v", err)
+ }
+ if _, err := registry.Resolve("account-model", ""); err != nil {
+ t.Fatalf("expected account probe model to be loaded, got err=%v", err)
+ }
+}
+
+func TestDeleteAccountUsesCanonicalKeyComparison(t *testing.T) {
+ cfg := normalizeConfig(AppConfig{
+ ActiveAccount: " Alice@Example.com ",
+ ProbeJSON: "probe_files/notion_accounts/alice/probe.json",
+ Accounts: []NotionAccount{
+ {Email: "alice@example.com"},
+ },
+ })
+ ok := cfg.DeleteAccount("ALICE@example.com")
+ if !ok {
+ t.Fatalf("expected delete to succeed")
+ }
+ if len(cfg.Accounts) != 0 {
+ t.Fatalf("expected accounts to be empty after delete, got %d", len(cfg.Accounts))
+ }
+ if cfg.ActiveAccount != "" {
+ t.Fatalf("expected active account to be cleared, got %q", cfg.ActiveAccount)
+ }
+ if cfg.ProbeJSON != "" {
+ t.Fatalf("expected probe json to be cleared, got %q", cfg.ProbeJSON)
+ }
+}
diff --git a/internal/app/metrics.go b/internal/app/metrics.go
new file mode 100644
index 0000000..e776879
--- /dev/null
+++ b/internal/app/metrics.go
@@ -0,0 +1,435 @@
+package app
+
+import (
+ "expvar"
+ "fmt"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+type histogramSeries struct {
+ count uint64
+ sum float64
+ buckets []uint64
+}
+
+func newHistogramSeries(bucketCount int) *histogramSeries {
+ if bucketCount < 0 {
+ bucketCount = 0
+ }
+ return &histogramSeries{
+ buckets: make([]uint64, bucketCount),
+ }
+}
+
+func (s *histogramSeries) observe(seconds float64, bounds []float64) {
+ if s == nil {
+ return
+ }
+ if seconds < 0 {
+ seconds = 0
+ }
+ s.count++
+ s.sum += seconds
+ for idx, bound := range bounds {
+ if seconds <= bound {
+ s.buckets[idx]++
+ }
+ }
+}
+
+type requestDurationKey struct {
+ Path string
+ Method string
+ Status string
+}
+
+type sqliteDurationKey struct {
+ Op string
+}
+
+var requestDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
+var transportCallDurationBuckets = []float64{0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5}
+var sqliteOpDurationBuckets = []float64{0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1}
+
+var (
+ requestDurationMu sync.Mutex
+ requestDurationSeries = map[requestDurationKey]*histogramSeries{}
+
+ dispatchInflightMu sync.Mutex
+ dispatchInflight = map[string]int64{}
+
+ transportCallMu sync.Mutex
+ transportCallSeries = newHistogramSeries(len(transportCallDurationBuckets))
+
+ browserSpawnMu sync.Mutex
+ browserSpawnTotal uint64
+
+ browserPoolWorkerMu sync.Mutex
+ browserPoolWorkerTotal uint64
+
+ sqliteDurationMu sync.Mutex
+ sqliteDurationSeries = map[sqliteDurationKey]*histogramSeries{}
+)
+
+func resetMetricsForTest() {
+ requestDurationMu.Lock()
+ requestDurationSeries = map[requestDurationKey]*histogramSeries{}
+ requestDurationMu.Unlock()
+
+ dispatchInflightMu.Lock()
+ dispatchInflight = map[string]int64{}
+ dispatchInflightMu.Unlock()
+
+ transportCallMu.Lock()
+ transportCallSeries = newHistogramSeries(len(transportCallDurationBuckets))
+ transportCallMu.Unlock()
+
+ browserSpawnMu.Lock()
+ browserSpawnTotal = 0
+ browserSpawnMu.Unlock()
+
+ browserPoolWorkerMu.Lock()
+ browserPoolWorkerTotal = 0
+ browserPoolWorkerMu.Unlock()
+
+ sqliteDurationMu.Lock()
+ sqliteDurationSeries = map[sqliteDurationKey]*histogramSeries{}
+ sqliteDurationMu.Unlock()
+}
+
+func observeRequestDuration(path string, method string, status int, elapsed time.Duration) {
+ seconds := elapsed.Seconds()
+ if seconds < 0 {
+ seconds = 0
+ }
+ key := requestDurationKey{
+ Path: normalizeMetricsPathLabel(path),
+ Method: strings.ToUpper(strings.TrimSpace(method)),
+ Status: strconv.Itoa(status),
+ }
+ if key.Method == "" {
+ key.Method = "UNKNOWN"
+ }
+ requestDurationMu.Lock()
+ series := requestDurationSeries[key]
+ if series == nil {
+ series = newHistogramSeries(len(requestDurationBuckets))
+ requestDurationSeries[key] = series
+ }
+ series.observe(seconds, requestDurationBuckets)
+ requestDurationMu.Unlock()
+}
+
+func setDispatchSlotInflight(email string, inflight int) {
+ key := canonicalEmailKey(email)
+ if key == "" {
+ return
+ }
+ if inflight < 0 {
+ inflight = 0
+ }
+ dispatchInflightMu.Lock()
+ dispatchInflight[key] = int64(inflight)
+ dispatchInflightMu.Unlock()
+}
+
+func syncDispatchSlotInflightFromSlots(next map[string]*accountSlot) {
+ dispatchInflightMu.Lock()
+ defer dispatchInflightMu.Unlock()
+ for key := range dispatchInflight {
+ if _, ok := next[key]; !ok {
+ delete(dispatchInflight, key)
+ }
+ }
+ for key, slot := range next {
+ if slot == nil {
+ continue
+ }
+ inflight := slot.inflight.Load()
+ if inflight < 0 {
+ inflight = 0
+ }
+ dispatchInflight[key] = int64(inflight)
+ }
+}
+
+func observeTransportCallDuration(elapsed time.Duration) {
+ seconds := elapsed.Seconds()
+ if seconds < 0 {
+ seconds = 0
+ }
+ transportCallMu.Lock()
+ transportCallSeries.observe(seconds, transportCallDurationBuckets)
+ transportCallMu.Unlock()
+}
+
+func addBrowserHelperSpawn() {
+ browserSpawnMu.Lock()
+ browserSpawnTotal++
+ browserSpawnMu.Unlock()
+}
+
+func addBrowserHelperPoolWorkerSpawn() {
+ browserPoolWorkerMu.Lock()
+ browserPoolWorkerTotal++
+ browserPoolWorkerMu.Unlock()
+}
+
+func observeSQLiteOpDuration(op string, elapsed time.Duration) {
+ op = strings.TrimSpace(strings.ToLower(op))
+ if op == "" {
+ op = "unknown"
+ }
+ seconds := elapsed.Seconds()
+ if seconds < 0 {
+ seconds = 0
+ }
+ key := sqliteDurationKey{Op: op}
+ sqliteDurationMu.Lock()
+ series := sqliteDurationSeries[key]
+ if series == nil {
+ series = newHistogramSeries(len(sqliteOpDurationBuckets))
+ sqliteDurationSeries[key] = series
+ }
+ series.observe(seconds, sqliteOpDurationBuckets)
+ sqliteDurationMu.Unlock()
+}
+
+func normalizeMetricsPathLabel(path string) string {
+ clean := strings.TrimSpace(path)
+ if clean == "" {
+ return "unknown"
+ }
+ switch {
+ case clean == "/":
+ return "/"
+ case clean == "/healthz":
+ return "/healthz"
+ case clean == "/metrics":
+ return "/metrics"
+ case clean == "/debug/vars":
+ return "/debug/vars"
+ case strings.HasPrefix(clean, "/v1/models/"):
+ return "/v1/models/:id"
+ case clean == "/v1/models":
+ return "/v1/models"
+ case strings.HasPrefix(clean, "/v1/responses/"):
+ return "/v1/responses/:id"
+ case clean == "/v1/responses":
+ return "/v1/responses"
+ case clean == "/v1/chat/completions":
+ return "/v1/chat/completions"
+ case clean == "/v1/st/chat/completions":
+ return "/v1/st/chat/completions"
+ case strings.HasPrefix(clean, "/admin/accounts/"):
+ return "/admin/accounts/:id"
+ case strings.HasPrefix(clean, "/admin/conversations/"):
+ return "/admin/conversations/:id"
+ case strings.HasPrefix(clean, "/admin"):
+ return "/admin/*"
+ }
+ if strings.Count(clean, "/") >= 2 {
+ parts := strings.Split(clean, "/")
+ if len(parts) >= 3 {
+ return "/" + parts[1] + "/" + parts[2] + "/*"
+ }
+ }
+ return clean
+}
+
+func writePrometheusMetrics(w http.ResponseWriter) {
+ if w == nil {
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+
+ _, _ = fmt.Fprintln(w, "# HELP notion2api_request_duration_seconds HTTP request duration seconds by path/method/status.")
+ _, _ = fmt.Fprintln(w, "# TYPE notion2api_request_duration_seconds histogram")
+ writeRequestDurationHistogram(w)
+
+ _, _ = fmt.Fprintln(w, "# HELP notion2api_dispatch_slot_inflight Current in-flight dispatch slots per account email.")
+ _, _ = fmt.Fprintln(w, "# TYPE notion2api_dispatch_slot_inflight gauge")
+ writeDispatchInflightGauge(w)
+
+ _, _ = fmt.Fprintln(w, "# HELP notion2api_transport_call_duration_seconds Duration of transport helper calls in seconds.")
+ _, _ = fmt.Fprintln(w, "# TYPE notion2api_transport_call_duration_seconds histogram")
+ writeTransportCallHistogram(w)
+
+ _, _ = fmt.Fprintln(w, "# HELP notion2api_browser_helper_spawn_total Total spawned browser helper subprocesses.")
+ _, _ = fmt.Fprintln(w, "# TYPE notion2api_browser_helper_spawn_total counter")
+ writeBrowserHelperSpawnCounter(w)
+
+ _, _ = fmt.Fprintln(w, "# HELP notion2api_browser_helper_pool_worker_spawn_total Total spawned persistent browser helper pool workers.")
+ _, _ = fmt.Fprintln(w, "# TYPE notion2api_browser_helper_pool_worker_spawn_total counter")
+ writeBrowserHelperPoolWorkerSpawnCounter(w)
+
+ _, _ = fmt.Fprintln(w, "# HELP notion2api_sqlite_op_duration_seconds SQLite operation durations in seconds by operation.")
+ _, _ = fmt.Fprintln(w, "# TYPE notion2api_sqlite_op_duration_seconds histogram")
+ writeSQLiteDurationHistogram(w)
+
+ _, _ = fmt.Fprintln(w, "# HELP notion2api_response_store_prune_total Total number of pruned in-memory response entries by reason.")
+ _, _ = fmt.Fprintln(w, "# TYPE notion2api_response_store_prune_total counter")
+ writeResponseStorePruneCounter(w)
+}
+
+func writeRequestDurationHistogram(w http.ResponseWriter) {
+ requestDurationMu.Lock()
+ seriesMap := make(map[requestDurationKey]*histogramSeries, len(requestDurationSeries))
+ keys := make([]requestDurationKey, 0, len(requestDurationSeries))
+ for key, series := range requestDurationSeries {
+ copySeries := *series
+ copySeries.buckets = append([]uint64(nil), series.buckets...)
+ seriesMap[key] = ©Series
+ keys = append(keys, key)
+ }
+ requestDurationMu.Unlock()
+
+ sort.Slice(keys, func(i, j int) bool {
+ if keys[i].Path != keys[j].Path {
+ return keys[i].Path < keys[j].Path
+ }
+ if keys[i].Method != keys[j].Method {
+ return keys[i].Method < keys[j].Method
+ }
+ return keys[i].Status < keys[j].Status
+ })
+
+ for _, key := range keys {
+ series := seriesMap[key]
+ if series == nil {
+ continue
+ }
+ labelPrefix := fmt.Sprintf("path=\"%s\",method=\"%s\",status=\"%s\"",
+ escapePrometheusLabelValue(key.Path),
+ escapePrometheusLabelValue(key.Method),
+ escapePrometheusLabelValue(key.Status),
+ )
+ writeHistogramSeries(w, "notion2api_request_duration_seconds", labelPrefix, requestDurationBuckets, series)
+ }
+}
+
+func writeDispatchInflightGauge(w http.ResponseWriter) {
+ dispatchInflightMu.Lock()
+ type pair struct {
+ email string
+ value int64
+ }
+ items := make([]pair, 0, len(dispatchInflight))
+ for email, value := range dispatchInflight {
+ items = append(items, pair{email: email, value: value})
+ }
+ dispatchInflightMu.Unlock()
+
+ sort.Slice(items, func(i, j int) bool { return items[i].email < items[j].email })
+ for _, item := range items {
+ _, _ = fmt.Fprintf(w, "notion2api_dispatch_slot_inflight{email=\"%s\"} %d\n",
+ escapePrometheusLabelValue(item.email), item.value)
+ }
+}
+
+func writeTransportCallHistogram(w http.ResponseWriter) {
+ transportCallMu.Lock()
+ series := *transportCallSeries
+ series.buckets = append([]uint64(nil), transportCallSeries.buckets...)
+ transportCallMu.Unlock()
+ writeHistogramSeries(w, "notion2api_transport_call_duration_seconds", "", transportCallDurationBuckets, &series)
+}
+
+func writeBrowserHelperSpawnCounter(w http.ResponseWriter) {
+ browserSpawnMu.Lock()
+ total := browserSpawnTotal
+ browserSpawnMu.Unlock()
+ _, _ = fmt.Fprintf(w, "notion2api_browser_helper_spawn_total %d\n", total)
+}
+
+func writeBrowserHelperPoolWorkerSpawnCounter(w http.ResponseWriter) {
+ browserPoolWorkerMu.Lock()
+ total := browserPoolWorkerTotal
+ browserPoolWorkerMu.Unlock()
+ _, _ = fmt.Fprintf(w, "notion2api_browser_helper_pool_worker_spawn_total %d\n", total)
+}
+
+func writeSQLiteDurationHistogram(w http.ResponseWriter) {
+ sqliteDurationMu.Lock()
+ seriesMap := make(map[sqliteDurationKey]*histogramSeries, len(sqliteDurationSeries))
+ keys := make([]sqliteDurationKey, 0, len(sqliteDurationSeries))
+ for key, series := range sqliteDurationSeries {
+ copySeries := *series
+ copySeries.buckets = append([]uint64(nil), series.buckets...)
+ seriesMap[key] = ©Series
+ keys = append(keys, key)
+ }
+ sqliteDurationMu.Unlock()
+
+ sort.Slice(keys, func(i, j int) bool { return keys[i].Op < keys[j].Op })
+ for _, key := range keys {
+ series := seriesMap[key]
+ if series == nil {
+ continue
+ }
+ labelPrefix := fmt.Sprintf("op=\"%s\"", escapePrometheusLabelValue(key.Op))
+ writeHistogramSeries(w, "notion2api_sqlite_op_duration_seconds", labelPrefix, sqliteOpDurationBuckets, series)
+ }
+}
+
+func writeResponseStorePruneCounter(w http.ResponseWriter) {
+ if w == nil {
+ return
+ }
+ entryVar := responseStorePruneTotalMetric.Get("expired_entries")
+ if entryVar == nil {
+ _, _ = fmt.Fprintln(w, "notion2api_response_store_prune_total{reason=\"expired_entries\"} 0")
+ return
+ }
+ entryValue, ok := entryVar.(*expvar.Int)
+ if !ok || entryValue == nil {
+ _, _ = fmt.Fprintln(w, "notion2api_response_store_prune_total{reason=\"expired_entries\"} 0")
+ return
+ }
+ _, _ = fmt.Fprintf(w, "notion2api_response_store_prune_total{reason=\"expired_entries\"} %d\n", entryValue.Value())
+}
+
+func writeHistogramSeries(w http.ResponseWriter, metricName string, baseLabels string, bounds []float64, series *histogramSeries) {
+ if w == nil || series == nil {
+ return
+ }
+ for idx, bound := range bounds {
+ le := strconv.FormatFloat(bound, 'g', -1, 64)
+ labels := withExtraLabel(baseLabels, "le", le)
+ _, _ = fmt.Fprintf(w, "%s_bucket{%s} %d\n", metricName, labels, series.buckets[idx])
+ }
+ infLabels := withExtraLabel(baseLabels, "le", "+Inf")
+ _, _ = fmt.Fprintf(w, "%s_bucket{%s} %d\n", metricName, infLabels, series.count)
+ if baseLabels == "" {
+ _, _ = fmt.Fprintf(w, "%s_sum %s\n", metricName, formatFloat(series.sum))
+ _, _ = fmt.Fprintf(w, "%s_count %d\n", metricName, series.count)
+ return
+ }
+ _, _ = fmt.Fprintf(w, "%s_sum{%s} %s\n", metricName, baseLabels, formatFloat(series.sum))
+ _, _ = fmt.Fprintf(w, "%s_count{%s} %d\n", metricName, baseLabels, series.count)
+}
+
+func withExtraLabel(base string, name string, value string) string {
+ extra := fmt.Sprintf("%s=\"%s\"", name, escapePrometheusLabelValue(value))
+ if strings.TrimSpace(base) == "" {
+ return extra
+ }
+ return base + "," + extra
+}
+
+func escapePrometheusLabelValue(value string) string {
+ value = strings.ReplaceAll(value, `\`, `\\`)
+ value = strings.ReplaceAll(value, "\n", `\n`)
+ value = strings.ReplaceAll(value, `"`, `\"`)
+ return value
+}
+
+func formatFloat(value float64) string {
+ return strconv.FormatFloat(value, 'g', -1, 64)
+}
diff --git a/internal/app/models.go b/internal/app/models.go
index 3ad7ae0..537290c 100644
--- a/internal/app/models.go
+++ b/internal/app/models.go
@@ -6,6 +6,7 @@ import (
"os"
"sort"
"strings"
+ "sync"
"unicode"
)
@@ -56,7 +57,7 @@ func builtinModelDefinitions() []ModelDefinition {
func buildModelRegistry(cfg AppConfig) ModelRegistry {
entries := builtinModelDefinitions()
- if probeEntries := extractProbeModelDefinitions(cfg.ProbeJSON); len(probeEntries) > 0 {
+ if probeEntries := extractProbeModelDefinitions(collectProbeModelPaths(cfg)); len(probeEntries) > 0 {
entries = mergeModelDefinitions(entries, probeEntries)
}
if len(cfg.Models) > 0 {
@@ -98,6 +99,27 @@ func buildModelRegistry(cfg AppConfig) ModelRegistry {
return ModelRegistry{Entries: entries, ByID: byID, AliasToID: aliasToID}
}
+func collectProbeModelPaths(cfg AppConfig) []string {
+ paths := make([]string, 0, len(cfg.Accounts)+1)
+ seen := map[string]struct{}{}
+ appendPath := func(path string) {
+ clean := strings.TrimSpace(path)
+ if clean == "" {
+ return
+ }
+ if _, exists := seen[clean]; exists {
+ return
+ }
+ seen[clean] = struct{}{}
+ paths = append(paths, clean)
+ }
+ appendPath(cfg.ProbeJSON)
+ for _, account := range cfg.Accounts {
+ appendPath(account.ProbeJSON)
+ }
+ return paths
+}
+
func (r ModelRegistry) Resolve(value string, fallback string) (ModelDefinition, error) {
candidate := strings.TrimSpace(value)
if candidate == "" {
@@ -118,7 +140,54 @@ func (r ModelRegistry) Resolve(value string, fallback string) (ModelDefinition,
return ModelDefinition{}, fmt.Errorf("unknown model: %s", candidate)
}
-func extractProbeModelDefinitions(path string) []ModelDefinition {
+func extractProbeModelDefinitions(paths []string) []ModelDefinition {
+ if len(paths) == 0 {
+ return nil
+ }
+ parseConcurrency := len(paths)
+ if parseConcurrency > 4 {
+ parseConcurrency = 4
+ }
+ if parseConcurrency < 1 {
+ parseConcurrency = 1
+ }
+ type indexedResult struct {
+ index int
+ items []ModelDefinition
+ }
+ results := make([]indexedResult, len(paths))
+ sem := make(chan struct{}, parseConcurrency)
+ var wg sync.WaitGroup
+ for i, path := range paths {
+ wg.Add(1)
+ go func(index int, probePath string) {
+ defer wg.Done()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+ results[index] = indexedResult{
+ index: index,
+ items: extractProbeModelDefinitionsFromPath(probePath),
+ }
+ }(i, path)
+ }
+ wg.Wait()
+
+ seen := map[string]struct{}{}
+ out := make([]ModelDefinition, 0)
+ for _, result := range results {
+ for _, item := range result.items {
+ key := item.ID + "|" + item.NotionModel
+ if _, exists := seen[key]; exists {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, item)
+ }
+ }
+ return out
+}
+
+func extractProbeModelDefinitionsFromPath(path string) []ModelDefinition {
if strings.TrimSpace(path) == "" {
return nil
}
diff --git a/internal/app/notion_client.go b/internal/app/notion_client.go
index 38f6087..b71a48c 100644
--- a/internal/app/notion_client.go
+++ b/internal/app/notion_client.go
@@ -8,6 +8,7 @@ import (
"crypto/tls"
"encoding/json"
"errors"
+ "expvar"
"fmt"
"io"
"log"
@@ -19,6 +20,7 @@ import (
"regexp"
"strconv"
"strings"
+ "sync"
"time"
)
@@ -36,6 +38,31 @@ const (
var leadingLangTagPattern = regexp.MustCompile(`(?is)^\s*(?: