diff --git a/.downstream/native-domains.md b/.downstream/native-domains.md
index fbf113db7503..d1f9262bbc81 100644
--- a/.downstream/native-domains.md
+++ b/.downstream/native-domains.md
@@ -139,7 +139,9 @@ handshake. Account mode edits invalidate only that account's subsequent connecti
## Independent Pelican tests
-`/admin/pelican-tests` is a task/result workbench. The new-task composer loads
+`/admin/pelican-tests` opens a paginated gallery of all observations from the last
+24 hours, with task/account/model/effort/scene/execution/verdict filters. Wide
+screens show four cards, medium screens two, and phones one. The new-task composer loads
accounts in pages of 50, retains selection across pages, and offers explicit
current-page/all-matching scopes. Models use locally saved mappings and catalogs;
searchable dropdowns accept catalog choices only. Backend defaults prefer
@@ -171,10 +173,21 @@ and failed-item retests directly create a new task with original model/effort
parameters and budget. Cross-task selected retries create one task per source,
preserving each source budget. New submissions retain UUID replay protection;
history, browsing, reconnecting and comparison are read-only.
-The fixed Pelican prompt is preserved. The actual sending path, credentials,
+New manual tasks select Pelican and/or candy (the UI defaults to both). Each
+result saves its chosen scene and a fresh visible verification code. The fixed
+legacy prompt remains exclusive to the old `/tests` contract. The actual sending path, credentials,
identity, model mapping, proxy, TLS and protocol remain owned by each account. No API Key, group policy, model score or fallback account
is manufactured for a test.
+Candy (90 seconds), SVG generation (the saved generation budget), rendering
+(30 seconds after renderer admission), and same-model visual review (180 seconds)
+run as distinct stages. Candy failure does not prevent generation. Review uses
+four 960x640 frames at 0/0.23/0.51/0.79 of the sampled animation cycle, checks
+pelican/bicycle/riding/motion, and keeps true/false/null judgments. Unsupported
+image input is unreviewed. Only all selected checks passing gives a passed verdict;
+execution status is independent. Stage evidence retains raw bytes as Base64 in
+detail JSON. A single on-demand Chromium process renders network-blocked SVGs.
+
All batches share ten model execution slots, with one request per account.
Ordinary selected-account sends participate in normal business
account capacity. Generation starts after preparation and admission; its default
@@ -184,7 +197,7 @@ renew every 30 seconds; lost capacity cancels the operation. Existing credential
refresh remains available, while account health, quota, scheduling, affinity,
usage and billing writes are suppressed, including background callbacks.
-Task IDs are timestamped UUIDv7 values. Expired/future IDs are rejected and replay
+Submission IDs are timestamped UUIDv7 values. Expired/future IDs are rejected and replay
cannot dispatch again. Migration 275 owns the task/result tables; migration 276
adds budgets, execution phases and actual invocation metadata in place, preserving
old records and raw BYTEA output. Existing records retain their 90-second budget.
@@ -201,7 +214,18 @@ idempotent. Output/errors of retained results preserve original bytes;
binary upstream envelopes use lossless Base64. Truncated or incomplete output is
recorded without another generation or automatic repair.
-Result previews use short random capability URLs under
+`GET /admin/pelican-tests/results` reads only gallery summaries.
+`POST /admin/pelican-tests/tasks/:id/retest` accepts a new client UUID, optional
+result IDs or failed-only selection, preserves source configuration/scenes and
+budget, and creates fresh verification codes. Replays compare the retest source
+and selection before consulting source records. Migration 280 adds task config,
+small evaluation metadata and byte-backed evidence/SVG/review image in place.
+Old records remain unscored and are never automatically re-executed.
+
+New SVG previews use `scene.svg` image capabilities and a no-script CSP.
+Gallery and preview SQL do not read original answers, upstream envelopes or review
+images. An already generated SVG remains viewable after candy/review failure.
+Legacy HTML result previews use short random capability URLs under
`/pelican-tests/preview/` only,
expire within 30 minutes and the task's remaining lifetime, and use an opaque
script-enabled sandbox and their own
diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml
index f24b68241a32..e497eec04839 100644
--- a/.github/workflows/downstream-verify.yml
+++ b/.github/workflows/downstream-verify.yml
@@ -214,7 +214,10 @@ jobs:
file: Dockerfile
platforms: linux/amd64
push: false
- outputs: type=oci,dest=/tmp/sub2api-candidate.tar
+ tags: sub2api-pelican-candidate:check
+ outputs: |
+ type=oci,dest=/tmp/sub2api-candidate.tar
+ type=docker,name=sub2api-pelican-candidate:check
build-args: |
VERSION=0.0.0-candidate
COMMIT=${{ github.sha }}
@@ -222,6 +225,20 @@ jobs:
GOSUMDB=sum.golang.org
cache-from: type=gha,scope=sub2api-candidate-amd64
cache-to: type=gha,mode=max,scope=sub2api-candidate-amd64
+ - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
+ with:
+ go-version-file: backend/go.mod
+ check-latest: false
+ cache: true
+ cache-dependency-path: backend/go.sum
+ - name: Verify four-frame rendering inside the candidate runtime
+ working-directory: backend
+ run: |
+ CGO_ENABLED=0 go test -c -tags unit -o "$RUNNER_TEMP/pelican-render.test" ./internal/service
+ docker run --rm --init --network none --memory 768m --user 1000:1000 \
+ --mount "type=bind,src=$RUNNER_TEMP/pelican-render.test,dst=/tmp/pelican-render.test,readonly" \
+ -e PELICAN_RENDER_TEST=1 --entrypoint /tmp/pelican-render.test \
+ sub2api-pelican-candidate:check -test.run '^TestPelicanChromiumRenderer$' -test.v
- name: Upload candidate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
diff --git a/Dockerfile b/Dockerfile
index 0a1138fe99a7..0d7d813cc0a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -122,6 +122,7 @@ LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api"
RUN apk add --no-cache \
ca-certificates \
tzdata \
+ chromium \
su-exec \
libpq \
zstd-libs \
@@ -148,6 +149,7 @@ WORKDIR /app
COPY --from=backend-builder --chown=sub2api:sub2api /app/sub2api /app/sub2api
COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/resources
COPY --chown=sub2api:sub2api THIRD_PARTY_NOTICES.md /app/THIRD_PARTY_NOTICES.md
+COPY --chown=sub2api:sub2api third_party/manxue-ai.LICENSE /app/third_party/manxue-ai.LICENSE
# Create the writable paired registry directory used by the runtime snapshot.
RUN mkdir -p /app/data && \
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 64613953ff0c..deb5d4af9aee 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -82,3 +82,13 @@ SOFTWARE.
under the MIT license; its original license is retained in `LICENSE.txt`.
Local changes add opt-in client compression-window negotiation using the already
locked klauspost/compress dependency. Other service paths retain the original module.
+
+## Pelican scenes, candy test and visual review
+
+The SVG scene prompt, eight scene names, candy problem, visual-review criteria
+and animation frame sampling are adapted from w1196396546/manxue-ai at
+commit 5da79674417dd0748682f09b7b7467efd1968ea8 (Apache License 2.0).
+Source: https://github.com/w1196396546/manxue-ai
+The retained Apache license is in third_party/manxue-ai.LICENSE.
+The Go implementation and Vue gallery integrate these ideas with Sub2API's
+existing account senders, background observations and isolated previews.
diff --git a/backend/go.mod b/backend/go.mod
index 3e660d61df34..aa028c1bee7f 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -16,6 +16,8 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/cespare/xxhash/v2 v2.3.0
+ github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
+ github.com/chromedp/chromedp v0.15.1
github.com/coder/websocket v1.8.14
github.com/dgraph-io/ristretto v0.2.0
github.com/gin-gonic/gin v1.9.1
@@ -97,6 +99,7 @@ require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
+ github.com/chromedp/sysutil v1.1.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
@@ -117,6 +120,7 @@ require (
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
@@ -125,6 +129,9 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 97b747c1945f..b2bb780d6695 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -133,6 +133,12 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
+github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
+github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
+github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
+github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -193,6 +199,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
+github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
+github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -218,6 +226,12 @@ github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHl
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -253,8 +267,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
-github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
-github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
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/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -310,6 +322,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -369,6 +383,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@@ -564,8 +580,6 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
-golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
-golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -582,8 +596,6 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
-golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -607,8 +619,6 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
-golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
-golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@@ -622,8 +632,6 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
-golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -657,16 +665,9 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
-golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959 h1:RJhm5l6Fo4rmEIcndxDllNhhf/fAx8qIm4t6A7vpm2A=
-golang.org/x/telemetry v0.0.0-20260708182218-49f421fb7959/go.mod h1:LV7u5Oco+Z/g6XI7PqN+EUUUGGkEcmB1uj2ceI0fOVg=
-golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q=
-golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4=
-golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -678,8 +679,6 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
-golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
-golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -692,8 +691,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
-golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
-golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
@@ -710,10 +707,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
-golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
-golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
-golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/backend/internal/handler/admin/pelican_gallery_handler.go b/backend/internal/handler/admin/pelican_gallery_handler.go
new file mode 100644
index 000000000000..8baac47be96b
--- /dev/null
+++ b/backend/internal/handler/admin/pelican_gallery_handler.go
@@ -0,0 +1,67 @@
+package admin
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func (h *PelicanTestHandler) Gallery(c *gin.Context) {
+ pelicanPrivateHeaders(c)
+ if id := c.Query("task_id"); id != "" {
+ if _, err := uuid.Parse(id); err != nil {
+ response.BadRequest(c, "task_id must be a UUID")
+ return
+ }
+ }
+ page, size := response.ParsePagination(c)
+ account, _ := strconv.ParseInt(c.Query("account_id"), 10, 64)
+ rows, err := h.runner.Gallery(c.Request.Context(), service.PelicanResultFilter{Page: page, Size: size, TaskID: c.Query("task_id"), AccountID: account, Model: c.Query("model"), Status: c.Query("status"), Effort: c.Query("effort"), Scene: c.Query("scene"), Verdict: c.Query("verdict")})
+ if err != nil {
+ pelicanTestError(c, err)
+ return
+ }
+ for _, row := range rows.Items {
+ h.preview.DecorateSummary(row)
+ }
+ response.Success(c, rows)
+}
+
+func (h *PelicanTestHandler) Retest(c *gin.Context) {
+ id, ok := pelicanTaskID(c)
+ if !ok {
+ return
+ }
+ var request service.PelicanRetestRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ var createdBy int64
+ if subject, found := middleware.GetAuthSubjectFromContext(c); found {
+ createdBy = subject.UserID
+ }
+ task, err := h.runner.Retest(c.Request.Context(), createdBy, id, request, h.catalog)
+ if err != nil {
+ pelicanTestError(c, err)
+ return
+ }
+ middleware.SetAuditExtra(c, map[string]any{"result": "pelican_retest", "task_id": task.ID, "source_task_id": id, "replayed": task.Replayed})
+ response.Accepted(c, task)
+}
+
+func (h *PelicanTestHandler) ServeSVG(c *gin.Context) {
+ pelicanPrivateHeaders(c)
+ c.Header("Content-Security-Policy", "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; sandbox")
+ svg, err := h.preview.ResolveSVG(c.Request.Context(), c.Param("cap"))
+ if err != nil {
+ c.Status(http.StatusGone)
+ return
+ }
+ c.Data(http.StatusOK, "image/svg+xml; charset=utf-8", []byte(svg))
+}
diff --git a/backend/internal/handler/admin/pelican_gallery_handler_test.go b/backend/internal/handler/admin/pelican_gallery_handler_test.go
new file mode 100644
index 000000000000..2a516b96236a
--- /dev/null
+++ b/backend/internal/handler/admin/pelican_gallery_handler_test.go
@@ -0,0 +1,43 @@
+//go:build unit
+
+package admin
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPelicanGalleryReadAndSVGFailurePreview(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ legacy, base := newPelicanHandlerFixture(t)
+ repo := &pelicanSummaryHandlerRepo{base}
+ runner := service.NewPelicanTestRunner(repo, nil, nil)
+ t.Cleanup(runner.Stop)
+ h := NewPelicanTestHandler(nil, runner, legacy.preview)
+ router := gin.New()
+ router.GET("/results", h.Gallery)
+ router.GET("/api/v1/pelican-tests/preview/:cap/scene.svg", h.ServeSVG)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, httptest.NewRequest("GET", "/results?page=1&page_size=24", nil))
+ require.Equal(t, http.StatusOK, response.Code)
+ require.NotContains(t, response.Body.String(), "raw_answer")
+ require.Zero(t, base.creates)
+ require.Zero(t, base.reads)
+ require.Zero(t, base.writes)
+ base.result.Status = "failed"
+ base.result.SVG = ``
+ _, base.result.HTML = service.ExtractPelicanHTML(base.result.SVG)
+ preview := h.preview.DecorateResult(base.result)
+ require.Contains(t, preview.PreviewURL, "scene.svg")
+ response = httptest.NewRecorder()
+ router.ServeHTTP(response, httptest.NewRequest("GET", preview.PreviewURL, nil))
+ require.Equal(t, http.StatusOK, response.Code)
+ require.Equal(t, base.result.SVG, response.Body.String())
+ require.Contains(t, response.Header().Get("Content-Type"), "image/svg+xml")
+ require.Contains(t, response.Header().Get("Content-Security-Policy"), "script-src 'none'")
+}
diff --git a/backend/internal/repository/pelican_background_repo.go b/backend/internal/repository/pelican_background_repo.go
index 9b563ce84a0e..a0b733d8ee1a 100644
--- a/backend/internal/repository/pelican_background_repo.go
+++ b/backend/internal/repository/pelican_background_repo.go
@@ -3,6 +3,7 @@ package repository
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
"fmt"
"strings"
@@ -25,7 +26,8 @@ func (r *pelicanTestRepository) GetTaskSnapshot(ctx context.Context, id string)
return nil, service.ErrPelicanTestDeleted
}
snapshot := &service.PelicanTaskSnapshot{PelicanTestTask: task, Counts: make(map[string]int)}
- rows, err := r.db.QueryContext(ctx, `SELECT status, COUNT(*), COUNT(*) FILTER (WHERE status='complete' AND octet_length(html)=0)
+ rows, err := r.db.QueryContext(ctx, `SELECT status, COUNT(*), COUNT(*) FILTER (WHERE status='complete' AND octet_length(html)=0),
+ COUNT(*) FILTER (WHERE status IN ('failed','incomplete','skipped') OR evaluation->>'verdict' IN ('invalid','error'))
FROM codex_gateway_borrow_test_results WHERE task_id=$1 GROUP BY status`, task.ID)
if err != nil {
return nil, err
@@ -33,12 +35,13 @@ func (r *pelicanTestRepository) GetTaskSnapshot(ctx context.Context, id string)
defer func() { _ = rows.Close() }()
for rows.Next() {
var status string
- var count, noPreview int
- if err := rows.Scan(&status, &count, &noPreview); err != nil {
+ var count, noPreview, failed int
+ if err := rows.Scan(&status, &count, &noPreview, &failed); err != nil {
return nil, err
}
snapshot.Counts[status] = count
snapshot.Counts["no_preview"] += noPreview
+ snapshot.FailedCount += failed
}
snapshot.Completed = 0
for status, count := range snapshot.Counts {
@@ -52,9 +55,9 @@ func (r *pelicanTestRepository) GetTaskSnapshot(ctx context.Context, id string)
// Keep this projection independent from pelicanTestResultColumns:
// no raw_answer, raw_response, raw_html, html or complete error enters a page.
const pelicanResultSummaryColumns = `r.id,r.task_id,r.ordinal,r.account_id,r.account_name,r.platform,r.model_id,r.effort,r.status,
- (r.status='complete' AND octet_length(r.html)>0),
+ (octet_length(r.raw_svg)>0 OR r.status='complete' AND octet_length(r.html)>0),
(r.status='incomplete' AND t.error=convert_to('process stopped before the test finished; this task will not be replayed','UTF8')),
- r.started_at,r.finished_at,r.generation_started_at,r.duration_ms,r.queue_duration_ms,r.preparation_duration_ms,r.generation_duration_ms,t.expires_at`
+ r.started_at,r.finished_at,r.generation_started_at,r.duration_ms,r.queue_duration_ms,r.preparation_duration_ms,r.generation_duration_ms,t.expires_at,r.evaluation`
func (r *pelicanTestRepository) ListTaskResults(ctx context.Context, id string, filter service.PelicanResultFilter) (*service.PelicanResultPage, error) {
if filter.Page < 1 {
@@ -66,12 +69,24 @@ func (r *pelicanTestRepository) ListTaskResults(ctx context.Context, id string,
if filter.Size > 50 {
filter.Size = 50
}
- args := []any{id}
- where := []string{"t.scope='manual_pelican'", "t.id=$1", "t.deleted_at IS NULL", "t.expires_at>CURRENT_TIMESTAMP"}
+ args := []any{}
+ where := []string{"t.scope='manual_pelican'", "t.deleted_at IS NULL", "t.expires_at>CURRENT_TIMESTAMP"}
add := func(clause string, value any) {
args = append(args, value)
where = append(where, fmt.Sprintf(clause, len(args)))
}
+ if id != "" {
+ add("t.id=$%d", id)
+ }
+ if filter.Effort != "" {
+ add("r.effort=$%d", filter.Effort)
+ }
+ if filter.Scene != "" {
+ add("r.evaluation->>'scene'=$%d", filter.Scene)
+ }
+ if filter.Verdict != "" {
+ add("r.evaluation->>'verdict'=$%d", filter.Verdict)
+ }
if filter.AccountID > 0 {
add("r.account_id=$%d", filter.AccountID)
}
@@ -93,7 +108,11 @@ func (r *pelicanTestRepository) ListTaskResults(ctx context.Context, id string,
return nil, err
}
args = append(args, filter.Size, (filter.Page-1)*filter.Size)
- query := "SELECT " + pelicanResultSummaryColumns + from + fmt.Sprintf(" ORDER BY r.ordinal LIMIT $%d OFFSET $%d", len(args)-1, len(args))
+ order := "r.ordinal"
+ if id == "" {
+ order = "COALESCE(r.started_at,t.created_at) DESC,r.id DESC"
+ }
+ query := "SELECT " + pelicanResultSummaryColumns + from + fmt.Sprintf(" ORDER BY %s LIMIT $%d OFFSET $%d", order, len(args)-1, len(args))
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
@@ -101,10 +120,16 @@ func (r *pelicanTestRepository) ListTaskResults(ctx context.Context, id string,
defer func() { _ = rows.Close() }()
for rows.Next() {
item := &service.PelicanResultSummary{}
+ var evaluation []byte
if err := rows.Scan(&item.ID, &item.TaskID, &item.Ordinal, &item.AccountID, &item.AccountName, &item.Platform, &item.ModelID, &item.Effort, &item.Status,
- &item.HasPreview, &item.Interrupted, &item.StartedAt, &item.FinishedAt, &item.GenerationStartedAt, &item.DurationMS, &item.QueueDurationMS, &item.PreparationDurationMS, &item.GenerationDurationMS, &item.ExpiresAt); err != nil {
+ &item.HasPreview, &item.Interrupted, &item.StartedAt, &item.FinishedAt, &item.GenerationStartedAt, &item.DurationMS, &item.QueueDurationMS, &item.PreparationDurationMS, &item.GenerationDurationMS, &item.ExpiresAt, &evaluation); err != nil {
return nil, err
}
+ if len(evaluation) > 0 {
+ if err = json.Unmarshal(evaluation, &item.Evaluation); err != nil {
+ return nil, err
+ }
+ }
page.Items = append(page.Items, item)
}
return page, rows.Err()
diff --git a/backend/internal/repository/pelican_gallery_repo_integration_test.go b/backend/internal/repository/pelican_gallery_repo_integration_test.go
new file mode 100644
index 000000000000..ecde29ba4b63
--- /dev/null
+++ b/backend/internal/repository/pelican_gallery_repo_integration_test.go
@@ -0,0 +1,70 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPelicanGalleryIntegration(t *testing.T) {
+ ctx := context.Background()
+ db := pelicanPrivateIntegrationDB(t, ctx)
+ repo := &pelicanTestRepository{db: db}
+ first := pelicanIntegrationTask(t, 42)
+ first.TestConfig = service.PelicanTestConfig{TestItems: []string{"pelican", "candy"}}
+ second := pelicanIntegrationTask(t, 43)
+ for _, task := range []*service.PelicanTestTask{first, second} {
+ _, _, err := repo.Create(ctx, task)
+ require.NoError(t, err)
+ result := task.Results[0]
+ result.Status = "failed"
+ now := time.Now().UTC()
+ result.StartedAt = &now
+ result.SVG = ``
+ result.HTML = "saved preview"
+ result.RawResponse = "GENERATION-CANARY"
+ result.Evaluation = &service.PelicanEvaluation{TestItems: []string{"pelican", "candy"}, Scene: "雪山谷地", Nonce: "ABC123", Generation: "passed", Candy: "passed", Review: "error", Verdict: "error"}
+ result.Evidence = map[string]*service.PelicanStageEvidence{"reviewing": {RawResponse: []byte{0xff, 0, 1}, Error: []byte("REVIEW-CANARY")}}
+ result.ReviewImage = []byte("sheet")
+ require.NoError(t, repo.SaveResult(ctx, result))
+ }
+ page, err := repo.ListTaskResults(ctx, "", service.PelicanResultFilter{Page: 1, Size: 1, Scene: "雪山谷地", Verdict: "error"})
+ require.NoError(t, err)
+ require.EqualValues(t, 2, page.Total)
+ require.Len(t, page.Items, 1)
+ require.Equal(t, second.ID, page.Items[0].TaskID)
+ require.True(t, page.Items[0].HasPreview)
+ encoded, err := json.Marshal(page)
+ require.NoError(t, err)
+ require.NotContains(t, string(encoded), "CANARY")
+ require.NotContains(t, string(encoded), "raw_response")
+ require.NotContains(t, string(encoded), "review_image")
+ filtered, err := repo.ListTaskResults(ctx, "", service.PelicanResultFilter{AccountID: 42})
+ require.NoError(t, err)
+ require.EqualValues(t, 1, filtered.Total)
+ full, err := repo.GetResult(ctx, first.Results[0].ID)
+ require.NoError(t, err)
+ require.Equal(t, []byte{0xff, 0, 1}, full.Evidence["reviewing"].RawResponse)
+ preview, err := repo.GetPreviewResult(ctx, full.ID)
+ require.NoError(t, err)
+ require.Equal(t, full.SVG, preview.SVG)
+ require.Empty(t, preview.RawResponse)
+ require.Empty(t, preview.Evidence)
+ snapshot, err := repo.GetTaskSnapshot(ctx, first.ID)
+ require.NoError(t, err)
+ require.Equal(t, 1, snapshot.FailedCount)
+ require.Equal(t, first.TestConfig, snapshot.TestConfig)
+ require.NoError(t, repo.FinishTask(ctx, first.ID, "failed", "", time.Now()))
+ require.NoError(t, repo.DeleteTask(ctx, first.ID))
+ _, err = repo.GetPreviewResult(ctx, full.ID)
+ require.Error(t, err)
+ page, err = repo.ListTaskResults(ctx, "", service.PelicanResultFilter{})
+ require.NoError(t, err)
+ require.EqualValues(t, 1, page.Total)
+}
diff --git a/backend/internal/repository/pelican_test_repo.go b/backend/internal/repository/pelican_test_repo.go
index de0526ee69c4..0cf77f1b52f4 100644
--- a/backend/internal/repository/pelican_test_repo.go
+++ b/backend/internal/repository/pelican_test_repo.go
@@ -3,6 +3,7 @@ package repository
import (
"context"
"database/sql"
+ "encoding/json"
"errors"
"time"
@@ -16,12 +17,13 @@ func NewPelicanTestRepository(db *sql.DB) service.PelicanTestRepository {
}
const pelicanTestTaskColumns = `id, client_task_id, created_by, request_hash, status, prompt,
- created_at, expires_at, started_at, finished_at, total, completed, error, generation_timeout_seconds, execution_mode, deleted_at`
+ created_at, expires_at, started_at, finished_at, total, completed, error, generation_timeout_seconds, execution_mode, deleted_at, test_config`
const pelicanTestResultColumns = `r.id, r.task_id, r.ordinal, r.account_id, r.account_name, r.model_id,
r.upstream_model, r.effort, r.status, r.raw_answer, r.raw_response, r.raw_html, r.html, r.error,
r.started_at, r.finished_at, r.duration_ms, t.expires_at, r.platform, r.actual_endpoint, r.actual_protocol,
- r.actual_transport, r.borrow_applied, r.queue_duration_ms, r.preparation_duration_ms, r.generation_duration_ms, r.generation_started_at`
+ r.actual_transport, r.borrow_applied, r.queue_duration_ms, r.preparation_duration_ms, r.generation_duration_ms, r.generation_started_at,
+ r.evaluation,r.evidence,r.raw_svg,r.review_image`
type pelicanTestScanner interface{ Scan(...any) error }
@@ -29,8 +31,9 @@ func scanPelicanTestTask(row pelicanTestScanner) (*service.PelicanTestTask, erro
task := &service.PelicanTestTask{}
var started, finished, deleted sql.NullTime
var rawError []byte
+ var config []byte
if err := row.Scan(&task.ID, &task.ClientTaskID, &task.CreatedBy, &task.RequestHash, &task.Status, &task.Prompt,
- &task.CreatedAt, &task.ExpiresAt, &started, &finished, &task.Total, &task.Completed, &rawError, &task.GenerationTimeoutSeconds, &task.ExecutionMode, &deleted); err != nil {
+ &task.CreatedAt, &task.ExpiresAt, &started, &finished, &task.Total, &task.Completed, &rawError, &task.GenerationTimeoutSeconds, &task.ExecutionMode, &deleted, &config); err != nil {
return nil, err
}
if started.Valid {
@@ -40,6 +43,9 @@ func scanPelicanTestTask(row pelicanTestScanner) (*service.PelicanTestTask, erro
task.FinishedAt = &finished.Time
}
task.Error = string(rawError)
+ if err := json.Unmarshal(config, &task.TestConfig); err != nil {
+ return nil, err
+ }
if deleted.Valid {
task.DeletedAt = &deleted.Time
}
@@ -50,13 +56,26 @@ func scanPelicanTestResult(row pelicanTestScanner) (*service.PelicanTestResult,
result := &service.PelicanTestResult{}
var answer, rawResponse, rawHTML, html, rawError []byte
var started, finished, generationStarted sql.NullTime
+ var evaluation, evidence, svg []byte
if err := row.Scan(&result.ID, &result.TaskID, &result.Ordinal, &result.AccountID, &result.AccountName, &result.ModelID,
&result.UpstreamModel, &result.Effort, &result.Status, &answer, &rawResponse, &rawHTML, &html, &rawError,
&started, &finished, &result.DurationMS, &result.ExpiresAt, &result.Platform, &result.ActualEndpoint, &result.ActualProtocol,
- &result.ActualTransport, &result.BorrowApplied, &result.QueueDurationMS, &result.PreparationDurationMS, &result.GenerationDurationMS, &generationStarted); err != nil {
+ &result.ActualTransport, &result.BorrowApplied, &result.QueueDurationMS, &result.PreparationDurationMS, &result.GenerationDurationMS, &generationStarted,
+ &evaluation, &evidence, &svg, &result.ReviewImage); err != nil {
return nil, err
}
result.RawAnswer, result.RawResponse, result.RawHTML, result.HTML, result.Error = string(answer), string(rawResponse), string(rawHTML), string(html), string(rawError)
+ result.SVG = string(svg)
+ if len(evaluation) > 0 {
+ if err := json.Unmarshal(evaluation, &result.Evaluation); err != nil {
+ return nil, err
+ }
+ }
+ if len(evidence) > 0 {
+ if err := json.Unmarshal(evidence, &result.Evidence); err != nil {
+ return nil, err
+ }
+ }
if started.Valid {
result.StartedAt = &started.Time
}
@@ -77,16 +96,20 @@ func scanPelicanTestResult(row pelicanTestScanner) (*service.PelicanTestResult,
// The conflict read intentionally includes expired UUIDs. Reposting a UUID can
// never re-run a task while its row exists, even before expired-row cleanup.
func (r *pelicanTestRepository) Create(ctx context.Context, task *service.PelicanTestTask) (*service.PelicanTestTask, bool, error) {
+ config, err := json.Marshal(task.TestConfig)
+ if err != nil {
+ return nil, false, err
+ }
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, false, err
}
defer func() { _ = tx.Rollback() }()
stored, err := scanPelicanTestTask(tx.QueryRowContext(ctx, `INSERT INTO codex_gateway_borrow_test_tasks
- (id,client_task_id,created_by,request_hash,status,prompt,total,created_at,expires_at,generation_timeout_seconds,execution_mode)
- VALUES ($1,$2,$3,$4,'pending',$5,$6,$7,$8,$9,$10)
+ (id,client_task_id,created_by,request_hash,status,prompt,total,created_at,expires_at,generation_timeout_seconds,execution_mode,test_config)
+ VALUES ($1,$2,$3,$4,'pending',$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (client_task_id) DO NOTHING RETURNING `+pelicanTestTaskColumns,
- task.ID, task.ClientTaskID, task.CreatedBy, task.RequestHash, task.Prompt, task.Total, task.CreatedAt, task.ExpiresAt, task.GenerationTimeoutSeconds, task.ExecutionMode))
+ task.ID, task.ClientTaskID, task.CreatedBy, task.RequestHash, task.Prompt, task.Total, task.CreatedAt, task.ExpiresAt, task.GenerationTimeoutSeconds, task.ExecutionMode, config))
if errors.Is(err, sql.ErrNoRows) {
stored, err = scanPelicanTestTask(tx.QueryRowContext(ctx, `SELECT `+pelicanTestTaskColumns+`
FROM codex_gateway_borrow_test_tasks WHERE scope='manual_pelican' AND client_task_id=$1 FOR UPDATE`, task.ClientTaskID))
@@ -112,9 +135,13 @@ func (r *pelicanTestRepository) Create(ctx context.Context, task *service.Pelica
return nil, false, err
}
for _, result := range task.Results {
+ evaluation, marshalErr := json.Marshal(result.Evaluation)
+ if marshalErr != nil {
+ return nil, false, marshalErr
+ }
if _, err = tx.ExecContext(ctx, `INSERT INTO codex_gateway_borrow_test_results
- (id,task_id,ordinal,account_id,account_name,model_id,effort,status,platform)
- VALUES ($1,$2,$3,$4,$5,$6,$7,'pending',$8)`, result.ID, stored.ID, result.Ordinal, result.AccountID, result.AccountName, result.ModelID, result.Effort, result.Platform); err != nil {
+ (id,task_id,ordinal,account_id,account_name,model_id,effort,status,platform,evaluation)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,'pending',$8,$9)`, result.ID, stored.ID, result.Ordinal, result.AccountID, result.AccountName, result.ModelID, result.Effort, result.Platform, evaluation); err != nil {
return nil, false, err
}
}
@@ -228,6 +255,14 @@ func (r *pelicanTestRepository) StartTask(ctx context.Context, id string, starte
}
func (r *pelicanTestRepository) SaveResult(ctx context.Context, result *service.PelicanTestResult) error {
+ evaluation, err := json.Marshal(result.Evaluation)
+ if err != nil {
+ return err
+ }
+ evidence, err := json.Marshal(result.Evidence)
+ if err != nil {
+ return err
+ }
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -237,13 +272,14 @@ func (r *pelicanTestRepository) SaveResult(ctx context.Context, result *service.
SET account_name=$3,upstream_model=$4,effort=$5,status=$6,raw_answer=$7,raw_response=$8,raw_html=$9,html=$10,error=$11,
started_at=$12,finished_at=$13,duration_ms=$14,platform=$15,actual_endpoint=$16,actual_protocol=$17,
actual_transport=$18,borrow_applied=$19,queue_duration_ms=$20,preparation_duration_ms=$21,
- generation_duration_ms=$22,generation_started_at=$23,updated_at=CURRENT_TIMESTAMP
+ generation_duration_ms=$22,generation_started_at=$23,evaluation=$24,evidence=$25,raw_svg=$26,review_image=$27,updated_at=CURRENT_TIMESTAMP
FROM codex_gateway_borrow_test_tasks t
WHERE r.id=$1 AND r.task_id=$2 AND t.id=r.task_id AND t.scope='manual_pelican' AND t.deleted_at IS NULL AND t.expires_at>CURRENT_TIMESTAMP`,
result.ID, result.TaskID, result.AccountName, result.UpstreamModel, result.Effort, result.Status,
[]byte(result.RawAnswer), []byte(result.RawResponse), []byte(result.RawHTML), []byte(result.HTML), []byte(result.Error),
result.StartedAt, result.FinishedAt, result.DurationMS, result.Platform, result.ActualEndpoint, result.ActualProtocol,
- result.ActualTransport, result.BorrowApplied, result.QueueDurationMS, result.PreparationDurationMS, result.GenerationDurationMS, result.GenerationStartedAt)); err != nil {
+ result.ActualTransport, result.BorrowApplied, result.QueueDurationMS, result.PreparationDurationMS, result.GenerationDurationMS, result.GenerationStartedAt,
+ evaluation, evidence, []byte(result.SVG), nonNilPelicanBytes(result.ReviewImage))); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `UPDATE codex_gateway_borrow_test_tasks t
@@ -255,6 +291,32 @@ func (r *pelicanTestRepository) SaveResult(ctx context.Context, result *service.
return tx.Commit()
}
+func nonNilPelicanBytes(value []byte) []byte {
+ if value == nil {
+ return []byte{}
+ }
+ return value
+}
+
+// Preview reads never fetch answers, upstream envelopes, or review evidence.
+func (r *pelicanTestRepository) GetPreviewResult(ctx context.Context, id string) (*service.PelicanTestResult, error) {
+ result := &service.PelicanTestResult{}
+ var html, svg, evaluation []byte
+ err := r.db.QueryRowContext(ctx, `SELECT r.id,r.status,r.html,r.raw_svg,r.evaluation,t.expires_at
+ FROM codex_gateway_borrow_test_results r JOIN codex_gateway_borrow_test_tasks t ON t.id=r.task_id
+ WHERE r.id=$1 AND t.scope='manual_pelican' AND t.deleted_at IS NULL AND t.expires_at>CURRENT_TIMESTAMP`, id).Scan(&result.ID, &result.Status, &html, &svg, &evaluation, &result.ExpiresAt)
+ if err != nil {
+ return nil, err
+ }
+ result.HTML, result.SVG = string(html), string(svg)
+ if len(evaluation) > 0 {
+ if err = json.Unmarshal(evaluation, &result.Evaluation); err != nil {
+ return nil, err
+ }
+ }
+ return result, nil
+}
+
func (r *pelicanTestRepository) FinishTask(ctx context.Context, id, status, errorText string, finished time.Time) error {
return pelicanRequireAffected(r.db.ExecContext(ctx, `UPDATE codex_gateway_borrow_test_tasks
SET status=$2,error=$3,finished_at=$4,updated_at=CURRENT_TIMESTAMP
diff --git a/backend/internal/repository/pelican_test_repo_integration_test.go b/backend/internal/repository/pelican_test_repo_integration_test.go
index a3164bb75faa..047d40925a1e 100644
--- a/backend/internal/repository/pelican_test_repo_integration_test.go
+++ b/backend/internal/repository/pelican_test_repo_integration_test.go
@@ -109,6 +109,10 @@ func pelicanPrivateIntegrationDB(t *testing.T, ctx context.Context) *sql.DB {
_, err = privateDB.ExecContext(ctx, string(deletion))
require.NoError(t, err)
}
+ evaluationDDL, err := migrations.FS.ReadFile("280_pelican_evaluation.sql")
+ require.NoError(t, err)
+ _, err = privateDB.ExecContext(ctx, string(evaluationDDL))
+ require.NoError(t, err)
var executionMode string
var timeout int
var retention int64
diff --git a/backend/internal/repository/pelican_test_repo_test.go b/backend/internal/repository/pelican_test_repo_test.go
index 4acd1e971fb7..43c7311de1bd 100644
--- a/backend/internal/repository/pelican_test_repo_test.go
+++ b/backend/internal/repository/pelican_test_repo_test.go
@@ -24,14 +24,14 @@ func newPelicanRepoTest(t *testing.T) (*pelicanTestRepository, sqlmock.Sqlmock)
}
func pelicanTaskTestRows(task *service.PelicanTestTask) *sqlmock.Rows {
- return sqlmock.NewRows([]string{"id", "client_task_id", "created_by", "request_hash", "status", "prompt", "created_at", "expires_at", "started_at", "finished_at", "total", "completed", "error", "generation_timeout_seconds", "execution_mode", "deleted_at"}).AddRow(
- task.ID, task.ClientTaskID, task.CreatedBy, task.RequestHash, task.Status, task.Prompt, task.CreatedAt, task.ExpiresAt, nil, nil, task.Total, task.Completed, []byte(task.Error), task.GenerationTimeoutSeconds, task.ExecutionMode, task.DeletedAt)
+ return sqlmock.NewRows([]string{"id", "client_task_id", "created_by", "request_hash", "status", "prompt", "created_at", "expires_at", "started_at", "finished_at", "total", "completed", "error", "generation_timeout_seconds", "execution_mode", "deleted_at", "test_config"}).AddRow(
+ task.ID, task.ClientTaskID, task.CreatedBy, task.RequestHash, task.Status, task.Prompt, task.CreatedAt, task.ExpiresAt, nil, nil, task.Total, task.Completed, []byte(task.Error), task.GenerationTimeoutSeconds, task.ExecutionMode, task.DeletedAt, []byte("{}"))
}
func pelicanResultTestRows(result *service.PelicanTestResult) *sqlmock.Rows {
- return sqlmock.NewRows([]string{"id", "task_id", "ordinal", "account_id", "account_name", "model_id", "upstream_model", "effort", "status", "raw_answer", "raw_response", "raw_html", "html", "error", "started_at", "finished_at", "duration_ms", "expires_at", "platform", "actual_endpoint", "actual_protocol", "actual_transport", "borrow_applied", "queue_duration_ms", "preparation_duration_ms", "generation_duration_ms", "generation_started_at"}).AddRow(
+ return sqlmock.NewRows([]string{"id", "task_id", "ordinal", "account_id", "account_name", "model_id", "upstream_model", "effort", "status", "raw_answer", "raw_response", "raw_html", "html", "error", "started_at", "finished_at", "duration_ms", "expires_at", "platform", "actual_endpoint", "actual_protocol", "actual_transport", "borrow_applied", "queue_duration_ms", "preparation_duration_ms", "generation_duration_ms", "generation_started_at", "evaluation", "evidence", "raw_svg", "review_image"}).AddRow(
result.ID, result.TaskID, result.Ordinal, result.AccountID, result.AccountName, result.ModelID, result.UpstreamModel, result.Effort, result.Status, []byte(result.RawAnswer), []byte(result.RawResponse), []byte(result.RawHTML), []byte(result.HTML), []byte(result.Error), result.StartedAt, result.FinishedAt, result.DurationMS, result.ExpiresAt,
- result.Platform, result.ActualEndpoint, result.ActualProtocol, result.ActualTransport, result.BorrowApplied, result.QueueDurationMS, result.PreparationDurationMS, result.GenerationDurationMS, result.GenerationStartedAt)
+ result.Platform, result.ActualEndpoint, result.ActualProtocol, result.ActualTransport, result.BorrowApplied, result.QueueDurationMS, result.PreparationDurationMS, result.GenerationDurationMS, result.GenerationStartedAt, nil, []byte{}, []byte{}, []byte{})
}
func pelicanRepoFixture(t *testing.T) *service.PelicanTestTask {
@@ -41,7 +41,7 @@ func pelicanRepoFixture(t *testing.T) *service.PelicanTestTask {
require.NoError(t, err)
task := &service.PelicanTestTask{ID: uuid.NewString(), ClientTaskID: clientID.String(), CreatedBy: 7, RequestHash: "hash", Status: "pending", Prompt: service.PelicanPrompt, CreatedAt: now, ExpiresAt: now.Add(service.PelicanTestTTL), Total: 1,
GenerationTimeoutSeconds: service.PelicanDefaultGenerationTimeoutSeconds, ExecutionMode: service.PelicanExecutionModeAccount}
- task.Results = []*service.PelicanTestResult{{ID: uuid.NewString(), TaskID: task.ID, Ordinal: 1, AccountID: 42, AccountName: "paused target", Platform: "openai", ModelID: "gpt-6-astra", Effort: "high", Status: "pending", ExpiresAt: task.ExpiresAt}}
+ task.Results = []*service.PelicanTestResult{{ID: uuid.NewString(), TaskID: task.ID, Ordinal: 1, AccountID: 42, AccountName: "paused target", Platform: "openai", ModelID: "gpt-6-astra", Effort: "high", Status: "pending", ExpiresAt: task.ExpiresAt, ReviewImage: []byte{}}}
return task
}
@@ -49,9 +49,9 @@ func TestPelicanTestRepositoryCreateAtomicAndReplayReadsWithoutWrites(t *testing
repo, mock := newPelicanRepoTest(t)
task := pelicanRepoFixture(t)
mock.ExpectBegin()
- mock.ExpectQuery("INSERT INTO codex_gateway_borrow_test_tasks").WithArgs(task.ID, task.ClientTaskID, task.CreatedBy, task.RequestHash, task.Prompt, task.Total, task.CreatedAt, task.ExpiresAt, task.GenerationTimeoutSeconds, task.ExecutionMode).WillReturnRows(pelicanTaskTestRows(task))
+ mock.ExpectQuery("INSERT INTO codex_gateway_borrow_test_tasks").WithArgs(task.ID, task.ClientTaskID, task.CreatedBy, task.RequestHash, task.Prompt, task.Total, task.CreatedAt, task.ExpiresAt, task.GenerationTimeoutSeconds, task.ExecutionMode, []byte("{}")).WillReturnRows(pelicanTaskTestRows(task))
result := task.Results[0]
- mock.ExpectExec("INSERT INTO codex_gateway_borrow_test_results").WithArgs(result.ID, task.ID, 1, result.AccountID, result.AccountName, result.ModelID, result.Effort, result.Platform).WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("INSERT INTO codex_gateway_borrow_test_results").WithArgs(result.ID, task.ID, 1, result.AccountID, result.AccountName, result.ModelID, result.Effort, result.Platform, []byte("null")).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
stored, replayed, err := repo.Create(context.Background(), task)
require.NoError(t, err)
@@ -143,7 +143,7 @@ func TestPelicanTestRepositorySavesFullRawValuesAndMarksRestartIncomplete(t *tes
result.QueueDurationMS, result.PreparationDurationMS, result.GenerationDurationMS, result.GenerationStartedAt = 1200, 2300, 3400, &started
mock.ExpectBegin()
mock.ExpectExec("UPDATE codex_gateway_borrow_test_results r.*t.expires_at>CURRENT_TIMESTAMP").WithArgs(result.ID, result.TaskID, result.AccountName, result.UpstreamModel, result.Effort, result.Status, []byte(result.RawAnswer), []byte(result.RawResponse), []byte(result.RawHTML), []byte(result.HTML), []byte(result.Error), nil, nil, int64(0),
- result.Platform, result.ActualEndpoint, result.ActualProtocol, result.ActualTransport, result.BorrowApplied, result.QueueDurationMS, result.PreparationDurationMS, result.GenerationDurationMS, result.GenerationStartedAt).WillReturnResult(sqlmock.NewResult(0, 1))
+ result.Platform, result.ActualEndpoint, result.ActualProtocol, result.ActualTransport, result.BorrowApplied, result.QueueDurationMS, result.PreparationDurationMS, result.GenerationDurationMS, result.GenerationStartedAt, []byte("null"), []byte("null"), []byte{}, []byte{}).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec("UPDATE codex_gateway_borrow_test_tasks t.*completed=.*status NOT IN").WithArgs(task.ID).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
require.NoError(t, repo.SaveResult(context.Background(), result))
diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go
index 3932b61d7061..81c0c3a22ff8 100644
--- a/backend/internal/server/routes/admin.go
+++ b/backend/internal/server/routes/admin.go
@@ -23,6 +23,7 @@ func RegisterAdminRoutes(
// 插件 UI 使用短时能力 URL,仅提供经过安装校验的静态资源。
v1.GET("/plugin-ui/:token/*path", h.Admin.Plugin.ServeUIAsset)
v1.GET("/pelican-tests/preview/:cap/index.html", h.Admin.PelicanTest.ServePreview)
+ v1.GET("/pelican-tests/preview/:cap/scene.svg", h.Admin.PelicanTest.ServeSVG)
admin := v1.Group("/admin")
admin.Use(gin.HandlerFunc(adminAuth))
diff --git a/backend/internal/server/routes/pelican_tests.go b/backend/internal/server/routes/pelican_tests.go
index f040051b4bdf..3c88be97e54d 100644
--- a/backend/internal/server/routes/pelican_tests.go
+++ b/backend/internal/server/routes/pelican_tests.go
@@ -10,6 +10,8 @@ func registerPelicanTestRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
pelican.GET("/options", h.Admin.PelicanTest.GetOptions)
pelican.POST("/options", h.Admin.PelicanTest.GetAccountOptions)
pelican.POST("/tasks", h.Admin.PelicanTest.StartTask)
+ pelican.GET("/results", h.Admin.PelicanTest.Gallery)
+ pelican.POST("/tasks/:id/retest", h.Admin.PelicanTest.Retest)
pelican.GET("/tasks", h.Admin.PelicanTest.ListTests)
pelican.GET("/tasks/:id", h.Admin.PelicanTest.GetTask)
pelican.GET("/tasks/:id/results", h.Admin.PelicanTest.GetTaskResults)
diff --git a/backend/internal/service/pelican_account_generator.go b/backend/internal/service/pelican_account_generator.go
index 3e7f10dd9383..1e0baf0f496d 100644
--- a/backend/internal/service/pelican_account_generator.go
+++ b/backend/internal/service/pelican_account_generator.go
@@ -371,6 +371,27 @@ func pelicanFinalHTTPRequest(req *http.Request, fallbackModel, fallbackEffort st
// business sender. Only that sender owns credential refresh and compatibility
// retries. An UpstreamFailoverError is recorded here and never enters selection.
func (s *AccountTestService) GeneratePelican(ctx context.Context, accountID int64, model, effort string) (*PelicanResult, error) {
+ return s.GeneratePelicanInput(ctx, accountID, model, effort, PelicanModelInput{Text: PelicanPrompt})
+}
+
+func (s *AccountTestService) PelicanImageCapability(ctx context.Context, accountID int64, model string) (bool, error) {
+ account, err := s.accountRepo.GetByID(ctx, accountID)
+ if err != nil {
+ return false, err
+ }
+ mapped, _ := pelicanTestUpstreamModel(account, model)
+ if metadata, known := pelicanTestLocalMetadata(account, mapped); known && len(metadata.InputModalities) > 0 {
+ return slices.Contains(metadata.InputModalities, "image"), nil
+ }
+ // Only affirmative native/catalog evidence admits an image request.
+ if account.Platform == PlatformAnthropic || account.IsGemini() || account.Platform == PlatformAntigravity && account.Type != AccountTypeUpstream {
+ return true, nil
+ }
+ descriptor := newConfiguredCodexModelDescriptorForAccount(mapped, account)
+ return slices.Contains(descriptor.InputModalities, "image"), nil
+}
+
+func (s *AccountTestService) GeneratePelicanInput(ctx context.Context, accountID int64, model, effort string, input PelicanModelInput) (*PelicanResult, error) {
result := &PelicanResult{ModelID: model, Effort: effort, Status: "failed"}
ctx = WithAccountObservation(ctx)
if pelicanExecutionFromContext(ctx) == nil {
@@ -414,7 +435,7 @@ func (s *AccountTestService) GeneratePelican(ctx context.Context, accountID int6
c.Request = httptest.NewRequest(http.MethodPost, "/internal/admin/pelican-tests", nil).WithContext(ctx)
// This represents the server's internal generation operation. No client
// UA, protocol handshake, API-key subject or group policy is synthesized.
- sendErr := s.sendPelicanWithAccount(ctx, c, account, model, option.UpstreamModel, effort)
+ sendErr := s.sendPelicanInputWithAccount(ctx, c, account, model, option.UpstreamModel, effort, input)
last := snapshotPelicanAttempt(capture)
if last == nil {
@@ -493,9 +514,16 @@ func pelicanOutputBudget(account *Account, upstreamModel string) int64 {
return 64000
}
-func pelicanMessagesBody(model, upstreamModel, effort string, budget int64) []byte {
+func pelicanMessagesInputBody(model, upstreamModel, effort string, budget int64, input PelicanModelInput) []byte {
+ content := []map[string]any{{"type": "text", "text": input.Text}}
+ for _, image := range input.Images {
+ content = append(content, map[string]any{"type": "text", "text": fmt.Sprintf("待审核画面,时间 %.3f 秒", image.Time)}, map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": "image/png", "data": base64.StdEncoding.EncodeToString(image.PNG)}})
+ }
payload := map[string]any{"model": model, "stream": true, "max_tokens": budget,
- "messages": []map[string]any{{"role": "user", "content": PelicanPrompt}}}
+ "messages": []map[string]any{{"role": "user", "content": content}}}
+ if len(input.Images) == 0 {
+ payload["messages"] = []map[string]any{{"role": "user", "content": input.Text}}
+ }
if effort != "" {
payload["output_config"] = map[string]any{"effort": effort}
if claude.EffortUsesAdaptiveThinking(upstreamModel) {
@@ -506,11 +534,18 @@ func pelicanMessagesBody(model, upstreamModel, effort string, budget int64) []by
return body
}
-func (s *AccountTestService) sendPelicanWithAccount(ctx context.Context, c *gin.Context, account *Account, model, upstreamModel, effort string) error {
+func (s *AccountTestService) sendPelicanInputWithAccount(ctx context.Context, c *gin.Context, account *Account, model, upstreamModel, effort string, input PelicanModelInput) error {
budget := pelicanOutputBudget(account, upstreamModel)
+ if input.MaxOutputTokens > 0 && input.MaxOutputTokens < budget {
+ budget = input.MaxOutputTokens
+ }
+ parts := []map[string]any{{"text": input.Text}}
+ for _, image := range input.Images {
+ parts = append(parts, map[string]any{"text": fmt.Sprintf("待审核画面,时间 %.3f 秒", image.Time)}, map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": base64.StdEncoding.EncodeToString(image.PNG)}})
+ }
switch {
case account.IsGemini(), account.Platform == PlatformAntigravity && account.Type != AccountTypeUpstream && isAntigravityGeminiModel(upstreamModel):
- payload := map[string]any{"contents": []map[string]any{{"role": "user", "parts": []map[string]any{{"text": PelicanPrompt}}}},
+ payload := map[string]any{"contents": []map[string]any{{"role": "user", "parts": parts}},
"generationConfig": map[string]any{"maxOutputTokens": budget}}
body, _ := json.Marshal(payload)
if account.IsGemini() {
@@ -529,7 +564,7 @@ func (s *AccountTestService) sendPelicanWithAccount(ctx context.Context, c *gin.
if s.pelicanGatewayService == nil {
return errors.New("anthropic account sender is unavailable")
}
- body := pelicanMessagesBody(model, upstreamModel, effort, budget)
+ body := pelicanMessagesInputBody(model, upstreamModel, effort, budget, input)
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
if err != nil {
return err
@@ -540,14 +575,18 @@ func (s *AccountTestService) sendPelicanWithAccount(ctx context.Context, c *gin.
if s.antigravityGatewayService == nil {
return errors.New("antigravity account sender is unavailable")
}
- _, err := s.antigravityGatewayService.Forward(ctx, c, account, pelicanMessagesBody(model, upstreamModel, effort, budget), false)
+ _, err := s.antigravityGatewayService.Forward(ctx, c, account, pelicanMessagesInputBody(model, upstreamModel, effort, budget, input), false)
return err
case account.IsOpenAICompatible():
if s.openaiGatewayService == nil {
return errors.New("OpenAI-compatible account sender is unavailable")
}
+ content := []map[string]any{{"type": "input_text", "text": input.Text}}
+ for _, image := range input.Images {
+ content = append(content, map[string]any{"type": "input_text", "text": fmt.Sprintf("待审核画面,时间 %.3f 秒", image.Time)}, map[string]any{"type": "input_image", "image_url": "data:image/png;base64," + base64.StdEncoding.EncodeToString(image.PNG), "detail": "high"})
+ }
payload := map[string]any{"model": model, "stream": true, "max_output_tokens": budget,
- "input": []map[string]any{{"role": "user", "content": []map[string]any{{"type": "input_text", "text": PelicanPrompt}}}}}
+ "input": []map[string]any{{"role": "user", "content": content}}}
if effort != "" {
payload["reasoning"] = map[string]any{"effort": effort}
}
diff --git a/backend/internal/service/pelican_account_generator_test.go b/backend/internal/service/pelican_account_generator_test.go
index 752f3cb02a30..2b9da01ba1c7 100644
--- a/backend/internal/service/pelican_account_generator_test.go
+++ b/backend/internal/service/pelican_account_generator_test.go
@@ -259,6 +259,34 @@ func TestPelicanAccountGeneratorNativeTextDispatch(t *testing.T) {
}
}
+func TestPelicanAccountGeneratorNativeImageDispatch(t *testing.T) {
+ claudeSSE := "data: {\"type\":\"content_block_delta\",\"delta\":{\"text\":\"review\"}}\n\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\ndata: {\"type\":\"message_stop\"}\n\n"
+ chatSSE := "data: {\"model\":\"text-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"review\"},\"finish_reason\":\"stop\"}]}\n\n"
+ geminiSSE := "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"review\"}]},\"finishReason\":\"STOP\"}]}\n\n"
+ for _, tc := range []struct{ name, platform, model, protocol, response, imageMarker string }{
+ {"responses", PlatformOpenAI, "gpt-6.1-sol", "", pelicanResponsesSSE("gpt-6.1-sol", "completed", "