diff --git a/.agents/skills/provider-adaptation/SKILL.md b/.agents/skills/provider-adaptation/SKILL.md
index 32a3b6ad5..821a5c4f9 100644
--- a/.agents/skills/provider-adaptation/SKILL.md
+++ b/.agents/skills/provider-adaptation/SKILL.md
@@ -159,7 +159,7 @@ CatPaw 与 AutoClaw 都曾把「上游没有目录接口」写进注释,后来
`ui/providers.js` **不用改** —— 它从 `/api/session` 的 `accounts.providers`(后端注册表摘要)自动派生。
-改完前端要重新构建岛:`npm run ui:islands`(产物 `ui/islands/ui.js` 要提交)。
+改完前端要重新构建岛:`npm run ui:islands`(产物 `ui/islands/ui.js`、`ui/islands/login.js` 都要提交)。
## 4. i18n 与文档
@@ -297,7 +297,7 @@ node scripts/probe-provider.mjs \
- **两个地区拆家时,`PROVIDERS` 里要相邻排列。** 中间隔着别家会让「找国际版」变成一次扫描。
- **`implemented_kinds()` 与 `PROVIDERS` 是两个口径。** 前者是「适配器接线了没有」,后者是「注册了没有」。占位实现不该进前者。
- **签到清单的默认全选是个陷阱。** 加一家进 `CHECKIN_PROVIDERS` 会让存量用户第二天零点就多发一轮请求。想默认开启就升版本号;不想默认开启就别进 `PROVIDERS_ADDED_IN`。
-- **改完前端要重新构建岛并提交产物。** `ui/islands/ui.js` 是检入仓库的,只改 tsx 不构建等于没改。
+- **改完前端要重新构建岛并提交产物。** `ui/islands/ui.js` 与 `ui/islands/login.js` 都是检入仓库的,只改 tsx 不构建等于没改。
## 9. 参考实现
diff --git a/.github/workflows/frontend-artifacts.yml b/.github/workflows/frontend-artifacts.yml
index 4f96d63c9..4e306259e 100644
--- a/.github/workflows/frontend-artifacts.yml
+++ b/.github/workflows/frontend-artifacts.yml
@@ -3,8 +3,8 @@ name: frontend-artifacts
# 校验「入库的前端构建产物」与源码同源。
#
# ── 为什么需要单独一道闸 ──────────────────────────────────────
-# 本仓库把构建产物**入库**(`ui/islands/ui.js`、`ui/islands/ui.css`、`ui/i18n/**`、
-# `ui/m/**`),而 Tauri 打包的正是 `ui/` 这个静态目录(见 tauri.conf.json 的
+# 本仓库把构建产物**入库**(`ui/islands/ui.js`、`ui/islands/ui.css`、
+# `ui/islands/login.js`、`ui/i18n/**`、`ui/m/**`),而 Tauri 打包的正是 `ui/` 这个静态目录(见 tauri.conf.json 的
# frontendDist)—— 也就是说上线的字节就是仓库里那几份。于是「只改源码不重新构建」
# 与「手改产物」都能一路合进 beta 并被打进安装包,而常规 review 看不出 1.1 MB
# 压缩文件里少了什么。
@@ -68,7 +68,9 @@ jobs:
- name: 重建产物
run: |
- # 岛产物:build 脚本是 `tsc --noEmit && vite build`,顺带做类型检查
+ # 岛产物:build 脚本是 `tsc --noEmit && vite build && vite build --config
+ # vite.login.config.ts` —— 主面板 ui.js / ui.css 与登录页独立的 login.js
+ # 一次出齐,顺带做类型检查
npm --prefix desktop-tauri/ui-islands run build
# 手机端产物:同样带类型检查,产物落 ui/m/
npm --prefix desktop-tauri/mobile run build
@@ -96,5 +98,5 @@ jobs:
echo " npm --prefix desktop-tauri/ui-islands run build"
echo " npm --prefix desktop-tauri/mobile run build"
echo " node desktop-tauri/i18n/build.mjs"
- echo "(不要手动编辑 ui/islands/ui.js、ui/m/** 或 ui/i18n/** —— 它们是产物,重建即可)"
+ echo "(不要手动编辑 ui/islands/ui.js、ui/islands/login.js、ui/m/** 或 ui/i18n/** —— 它们是产物,重建即可)"
exit 1
diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml
new file mode 100644
index 000000000..a95872822
--- /dev/null
+++ b/.github/workflows/server-tests.yml
@@ -0,0 +1,58 @@
+name: server-tests
+
+# 跑后端单元测试。
+#
+# ── 为什么需要这道闸 ──────────────────────────────────────────
+# 本 crate 有约 320 个测试属性(`#[test]` + `#[tokio::test]`,2026-11-08 实测 323 个),但此前没有任何工作流执行 `cargo test`:
+# `build.yml` 只在打 tag / 手动触发时构建安装包,
+# `warm-cache.yml` 只预热依赖缓存,`frontend-artifacts.yml` 只校验前端产物同源。
+# 也就是说:**测试写了,但从头到尾没人跑** —— 合进 beta 的改动要等装到用户机器上
+# 才第一次真正被执行。选路、余额解析、错误码映射这几族逻辑恰恰是最怕静默回归的。
+#
+# ── 判据:只测后端 crate,不碰桌面壳 ──────────────────────────
+# 判据只有一条:`cargo test -p agent2api-server` 必须通过。
+# 后端测试全部在这一个 crate 里(桌面壳 `src/` 下没有测试),
+# 所以「只测 server」不是偷懒的折中,而是**已经覆盖了全部现有测试**。
+# 为什么不顺手把壳也编一遍:壳是 Tauri,在 Linux 上要 webkit2gtk / gtk3 一整套
+# 系统库(`server/Cargo.toml` 自己写着拆分就是为了「headless 一个都不用」,
+# 且其依赖里「刻意没有的:tauri 及其插件」)。测试 job 不该为 0 个测试背这个包。
+# 同理不加 `--all-features`:本仓库的 feature 组合没有既有的验证约定,
+# 贸然打开可能引入需要系统库或网络的构建,那会让这道闸变成噪音而不是信号。
+#
+# ── 与现有工作流的差异 ────────────────────────────────────────
+# 触发方式照抄 `frontend-artifacts.yml`(main / beta 的 push 与 pull_request),
+# 因为它已经是这个仓库里「PR 上跑校验」的既成先例;构建工具链与缓存写法照抄
+# `build.yml`(同一个 rust-toolchain 动作、同一个 rust-cache 配置口径)。
+# 失败时输出就是 cargo 自己的编译与断言错误,最常见的原因按频率排:
+# 1. 改动破坏了既有单测(看 `test result: FAILED` 上面那条 panic 的文件与行号);
+# 2. 新增测试依赖了真实网络或本机路径(CI 上必挂,请改成打桩或用临时目录)。
+
+on:
+ push:
+ branches: [main, beta]
+ pull_request:
+ branches: [main, beta]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ server-tests:
+ name: 后端测试(agent2api-server)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+
+ - name: 安装 Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ # 与 build.yml 同一口径:.cargo/config.toml 把 target-dir 指到仓库根的 target/
+ - name: 缓存 Rust 依赖
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: desktop-tauri/src-tauri -> ../../target
+
+ - name: 跑测试
+ working-directory: desktop-tauri/src-tauri
+ run: cargo test -p agent2api-server
diff --git a/.gitignore b/.gitignore
index c097fed4b..868656be2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,3 +79,5 @@ desktop-tauri/mobile/ios/DerivedData/
# 提供商插件(独立仓库的本地开发目录,不得进入本仓库历史)
/agent2api-providers/
/plugin/
+
+backups/
diff --git a/AGENTS.md b/AGENTS.md
index 2c8df34d4..3ea0471cb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,10 @@
其余细则(分支落脚点、署名与 AI 落款、六处版本号、正式版 / beta 完整流程、验收清单、已知坑与排查)见上述 Skill。
+## 反代提供商问题排查
+
+收到反代提供商相关的问题反馈时,**优先使用网络搜索查找已有解决方案**,重点参考同类项目的 Issue、PR、修复记录和适配实现,了解其他项目是否遇到过相同问题、如何解决。**不要默认从零自行排查**:先核对已有方案与本项目的适用性,优先借鉴可靠的解决思路;未找到可靠方案、现有方案不适用或仍有未解决的问题时,再开展针对性的本地排查。
+
## 文档落点
安装 / 部署 / 升级 / 排错说明只在 `docs/install.md`(另有 5 份译文 `docs/install.{en,zh-Hant,ja,ko,pt-BR}.md`,六份一一对应)里维护;各语言 README 只留「快速开始」速查表 + 一个指向安装文档的链接,**不要再把安装细节写回 README**。产物名、端口、环境变量、安装脚本参数有改动时,六份一起改。
diff --git a/README.md b/README.md
index 3119d9a0f..87b5f93a6 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Agent2API is an **AI routing gateway with Agent client takeover**. It exposes a
## ✨ Key Features
-- 🔌 **Unified model APIs** — OpenAI Chat / Responses / Images and Anthropic Messages, with a unified model catalog; Raccoon, Loomy, Antigravity and ModelScope supply image generation, while Loomy, Antigravity and ModelScope also support reference-image editing. Both are available as AI assistant tools
+- 🔌 **Unified model APIs** — OpenAI Chat / Responses / Images and Anthropic Messages, with a unified model catalog; Raccoon, Loomy, Antigravity, ModelScope, WorkBuddy and Doubao Work supply image generation, while Raccoon, Loomy, Antigravity, ModelScope and WorkBuddy also support reference-image editing. Both are available as AI assistant tools
- 🧭 **AI routing** — model mapping, account priority, load balancing, session affinity and failover in one place
- 🤖 **Agent client takeover** — manage multiple connection profiles for local clients, import existing settings, preview writes and switch between the gateway and custom services
- 🧩 **Plugin extensions** — additional provider implementations are installed and updated separately, not bundled with the application; custom providers do not require them
diff --git a/assets/qq-card.png b/assets/qq-card.png
deleted file mode 100644
index 845a42489..000000000
Binary files a/assets/qq-card.png and /dev/null differ
diff --git a/assets/screenshots/checkin-center.png b/assets/screenshots/checkin-center.png
deleted file mode 100644
index f0421a771..000000000
Binary files a/assets/screenshots/checkin-center.png and /dev/null differ
diff --git a/contrib/web-extra/.gitignore b/contrib/web-extra/.gitignore
new file mode 100644
index 000000000..2f7896d1d
--- /dev/null
+++ b/contrib/web-extra/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/contrib/web-extra/Cargo.lock b/contrib/web-extra/Cargo.lock
new file mode 100644
index 000000000..49123bdbf
--- /dev/null
+++ b/contrib/web-extra/Cargo.lock
@@ -0,0 +1,1437 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "agent2api-provider-protocol"
+version = "0.2.1"
+source = "git+https://github.com/ProvidersPlugin/Agent2Providers?tag=v3.4.0#198b5051928c33426ff4fd63d486c84f9c5394af"
+dependencies = [
+ "base64",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
+name = "agent2api-provider-sdk"
+version = "0.2.2-beta.2"
+source = "git+https://github.com/ProvidersPlugin/Agent2Providers?tag=v3.4.0#198b5051928c33426ff4fd63d486c84f9c5394af"
+dependencies = [
+ "agent2api-provider-protocol",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
+name = "agent2api-web-extra"
+version = "1.1.0"
+dependencies = [
+ "agent2api-provider-protocol",
+ "agent2api-provider-sdk",
+ "futures",
+ "getrandom 0.2.17",
+ "md-5",
+ "reqwest",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "rand_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mio"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.5",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/contrib/web-extra/Cargo.toml b/contrib/web-extra/Cargo.toml
new file mode 100644
index 000000000..68f4567d9
--- /dev/null
+++ b/contrib/web-extra/Cargo.toml
@@ -0,0 +1,16 @@
+[workspace]
+resolver = "2"
+members = ["crates/providers"]
+
+[workspace.package]
+version = "1.1.0"
+edition = "2021"
+rust-version = "1.88"
+license-file = "LICENSE"
+
+[profile.release]
+panic = "unwind"
+lto = true
+codegen-units = 1
+opt-level = "s"
+strip = true
diff --git a/contrib/web-extra/LICENSE b/contrib/web-extra/LICENSE
new file mode 100644
index 000000000..be3f7b28e
--- /dev/null
+++ b/contrib/web-extra/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/contrib/web-extra/NOTICE.md b/contrib/web-extra/NOTICE.md
new file mode 100644
index 000000000..05bda690b
--- /dev/null
+++ b/contrib/web-extra/NOTICE.md
@@ -0,0 +1,31 @@
+# 第三方来源与修改声明(NOTICE)
+
+本包是 `Agent2API` 的贡献组件,整体按仓库根 `LICENSE`(GNU Affero General Public
+License v3.0,AGPL-3.0)分发。本文件声明第三方来源与修改情况。
+
+## 协议行为参考来源
+
+`crates/providers/src/providers/qwen/`(通义千问网页版适配)的**协议行为**
+(接口路径、请求体字段、SSE 事件形态、多候选与风控表现)整理自当时公开的
+开源实现,并在本仓库按 AGPL-3.0 重新实现:
+
+- 主要参考:`cv-superding/dsweb-proxy`(`providers/qwen.ts`),**Apache License
+ 2.0**。Apache-2.0 代码可纳入 AGPL-3.0 项目分发;按其 §4(a)(b) 要求,本文件
+ 即为来源声明与「已修改」标注:本包的实现针对 Agent2API v3.4.0 插件 SDK
+ (`agent2api-provider-protocol` / `agent2api-provider-sdk`,tag `v3.4.0`)重写,
+ 模块结构、trait 形状、账号代理处理与登录申报方式均与上游 SDK 规范一致,
+ 与参考文件不存在逐行对应关系。
+- 参考来源的模块头自述其行为「逐行核对过 `Rfym21/Qwen2API`」。该仓库 README
+ 声明「仅限个人学习与研究 / 禁止商用」、不是 OSI 许可证。**本包没有复制该
+ 仓库的任何代码**;如果这一点不成立或你不接受该仓库的使用条款,请勿分发或
+ 使用本模块,并向上游(本 Fork 维护者)反馈。
+
+Apache License 2.0 全文:
+
+## 其它
+
+- 本包其余代码(SDK 对接、提示词折叠与 `` 模拟约定、SSE 解码骨架)
+ 与官方网页插件包 `Agent2WebProviders`(AGPL-3.0)同源同风格,均为 AGPL-3.0
+ 仓库内代码,无额外第三方来源。
+- 商标与品牌:`chat.qwen.ai`、通义千问及相关标识是各自权利人的商标;本包的
+ 使用不构成商标授权。
diff --git a/contrib/web-extra/README.md b/contrib/web-extra/README.md
new file mode 100644
index 000000000..56463c1cd
--- /dev/null
+++ b/contrib/web-extra/README.md
@@ -0,0 +1,68 @@
+# agent2api-web-extra · 通义千问网页扩展包
+
+`Agent2API` 的**第三方网页提供商插件包**:把通义千问网页版(`chat.qwen.ai`)
+的本人登录态接进网关,作为 `qwen-web` 提供商参与转发。官方网页包
+(`agent2api-web-providers`,含智谱清言 / DeepSeek / Tabbit)不包含千问网页,
+本包补上这一家。
+
+> 仓库主线(`beta` / `main`)不构建本包;它按「插件包 → 从文件安装」的方式
+> 交付。许可证与第三方来源见包内 `LICENSE` 与 `NOTICE.md`。
+
+## 提供的能力(按申报为准)
+
+| 能力 | 申报 | 说明 |
+| --- | --- | --- |
+| 对话(含流式) | ✓ | 建会话 + SSE,`delta.phase` 区分思考与正文;锁第一个候选 index |
+| 工具调用 | 模拟 | 无原生协议;目录申报 `toolCallMode: "emulated"`,`` 约定 |
+| 模型列表 | ✓ | 静态兜底 + 登录态拉 `/api/models` |
+| Token 自动续期 | ✗ | 网页 token 是 JWT,无公开续期接口;过期后回官网重登再更新 |
+| 余额 / 签到 | ✗ | 上游无对应接口,不申报 |
+
+## 构建
+
+依赖:Rust 1.88+(版本钉在 SDK tag `v3.4.0`,与网关 `server/Cargo.toml` 一致)。
+
+```bash
+cd contrib/web-extra
+cargo build --release --target x86_64-unknown-linux-gnu
+```
+
+五平台 target 与 `pack.json` 的 `entry` 一一对应:`x86_64-pc-windows-msvc`、
+`x86_64-unknown-linux-gnu`、`aarch64-unknown-linux-gnu`、`x86_64-apple-darwin`、
+`aarch64-apple-darwin`(Windows 产物多 `.exe` 后缀)。
+
+## 打包与安装
+
+zip 根目录放 `pack.json`,二进制放 `bin/`(与 `entry` 一致):
+
+```bash
+cd contrib/web-extra
+T=x86_64-unknown-linux-gnu
+cargo build --release --target $T
+mkdir -p bin && cp target/$T/release/agent2api-web-extra bin/
+zip -r ../../agent2api-web-extra-1.1.0-$T.zip pack.json LICENSE NOTICE.md bin
+```
+
+面板 →「插件」→「从文件安装」选择该 zip。本包是第三方包(不在
+`OFFICIAL_PACKS` 名单内),没有自动更新:升级时重新打包并在插件页替换安装。
+
+## 添加账号
+
+装好插件后,「添加账号 → 通义千问 网页」会读到插件申报的动态登录表单:
+
+1. 在 `chat.qwen.ai` 登录本人账号;
+2. 浏览器开发者工具 → Application → Local Storage,复制 `token` 的 value
+ (`eyJ` 开头的 JWT;**不是**收费 API Key);
+3. 粘贴到「网页访问令牌」;可选粘贴整段 Cookie(风控期更稳);
+4. 提交后插件会调 `/api/v1/auths/` 校验登录态,无效令牌会明确报错。
+
+登录态失效或上游要求人机验证时,账号会报「请回官方网页完成验证后更新
+token」——按设计如实透传,不做绕过。
+
+## 排错
+
+- 「千问建会话失败」→ token 过期,回官网重登后更新凭证;
+- 「千问网页要求人机验证」→ 在官方网页完成验证后更新 token;
+- 「千问网页版今日配额已耗尽」→ 上游限额,次日恢复;
+- 工具调用失败重试仍失败 → 该模型对 `` 约定的遵从度问题,换
+ `qwen3-max` 或 `qwen3-coder-plus` 再试。
diff --git a/contrib/web-extra/crates/providers/Cargo.toml b/contrib/web-extra/crates/providers/Cargo.toml
new file mode 100644
index 000000000..a712bd75c
--- /dev/null
+++ b/contrib/web-extra/crates/providers/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "agent2api-web-extra"
+description = "Agent2API 网页提供商扩展包(通义千问网页)"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license-file.workspace = true
+
+[[bin]]
+name = "agent2api-web-extra"
+path = "src/main.rs"
+
+[features]
+default = ["qwen"]
+qwen = []
+
+[dependencies]
+agent2api-provider-protocol = { git = "https://github.com/ProvidersPlugin/Agent2Providers", tag = "v3.4.0" }
+agent2api-provider-sdk = { git = "https://github.com/ProvidersPlugin/Agent2Providers", tag = "v3.4.0" }
+serde_json = "1"
+reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "socks"] }
+md-5 = "0.10"
+getrandom = "0.2"
+futures = "0.3"
+tokio = { version = "1", features = ["rt-multi-thread", "time"] }
diff --git a/contrib/web-extra/crates/providers/src/http.rs b/contrib/web-extra/crates/providers/src/http.rs
new file mode 100644
index 000000000..4824199d8
--- /dev/null
+++ b/contrib/web-extra/crates/providers/src/http.rs
@@ -0,0 +1,12 @@
+//! 进程内小工具:Unix 毫秒时间戳。
+//!
+//! 本包只有 qwen-web 一家,控制面请求的客户端与响应解析都收在
+//! `providers/qwen` 模块内,这里不再放公共 HTTP 层 —— 没有第二家会用到它。
+
+/// Unix 毫秒时间戳;时钟早于 epoch 时给 0(只用于请求体时间戳与 chunk 时间)。
+pub fn now_ms() -> i64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|duration| duration.as_millis() as i64)
+ .unwrap_or(0)
+}
diff --git a/contrib/web-extra/crates/providers/src/main.rs b/contrib/web-extra/crates/providers/src/main.rs
new file mode 100644
index 000000000..c62d67984
--- /dev/null
+++ b/contrib/web-extra/crates/providers/src/main.rs
@@ -0,0 +1,17 @@
+//! Agent2API 网页提供商扩展包入口:只承载通义千问网页(qwen-web)一家。
+//!
+//! 包形态与官方网页包(`agent2api-web-providers`)一致:`pack.json` 声明
+//! `protocol: 1` 与五平台二进制入口,进程体只做一件事 —— 把 registry 交给
+//! SDK 的 `serve_blocking`,之后的分帧、回调与背压都由 SDK 处理。
+//!
+//! 与官方包的差异:本包是第三方包(不在 `OFFICIAL_PACKS` 名单里),走
+//! 「从文件安装」,没有自动更新与签名通道;见 `README.md` 的安装说明。
+
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+
+mod http;
+mod providers;
+
+fn main() {
+ agent2api_provider_sdk::serve_blocking(providers::registry());
+}
diff --git a/contrib/web-extra/crates/providers/src/providers/mod.rs b/contrib/web-extra/crates/providers/src/providers/mod.rs
new file mode 100644
index 000000000..0025fcd26
--- /dev/null
+++ b/contrib/web-extra/crates/providers/src/providers/mod.rs
@@ -0,0 +1,12 @@
+#[cfg(feature = "qwen")]
+pub mod qwen;
+
+use agent2api_provider_sdk::BoxProvider;
+
+/// 本包声明的 provider 注册表;`pack.json` 的 `providers[]` 必须与这里一一对应。
+pub fn registry() -> Vec {
+ let mut list: Vec = Vec::new();
+ #[cfg(feature = "qwen")]
+ list.push(Box::new(qwen::QwenProvider));
+ list
+}
diff --git a/contrib/web-extra/crates/providers/src/providers/qwen/mod.rs b/contrib/web-extra/crates/providers/src/providers/qwen/mod.rs
new file mode 100644
index 000000000..e32d711da
--- /dev/null
+++ b/contrib/web-extra/crates/providers/src/providers/qwen/mod.rs
@@ -0,0 +1,431 @@
+//! 通义千问网页版(`chat.qwen.ai`):用本人网页登录态换一条 OpenAI 兼容通道。
+//!
+//! ── 协议依据 ────────────────────────────────────────────────
+//! 2026-10 活跃开源实现(`cv-superding/dsweb-proxy` 的 `providers/qwen.ts`,
+//! Apache-2.0;其模块头注明逐行核对过 `Rfym21/Qwen2API` 的行为):
+//! · 登录态:浏览器 localStorage 的 `token`(JWT),上游以 Cookie
+//! `token=` 认证;
+//! · 建会话:`POST /api/v2/chats/new` → `data.id`;
+//! · 对话:`POST /api/v2/chat/completions?chat_id=`,**上游只支持流式**;
+//! 请求体必须带 `version: "2.1"`、`incremental_output: true`,且
+//! `messages[0].feature_config.output_schema = "phase"`(缺了不返回
+//! `delta.phase`,思考与正文就分不开);
+//! · SSE:OpenAI chunk 形态,`delta.phase` 为 `answer` 时是正文,
+//! `think` / `thinking_summary` 时是思考;
+//! · 上游会对同一请求开多路候选(`choices[0].index` 0/1 交错),必须锁
+//! 第一个出现的 index,否则正文会复读。
+//! 本包实现按 v3.4.0 插件 SDK 重写(trait 形状、账号代理、动态登录表单均为
+//! 新接口用法),未逐行复制上述项目的代码;出处与许可见包内 `NOTICE.md`。
+//!
+//! ── 为什么不做自动续期 ──────────────────────────────────────
+//! 网页 token 是 JWT,没有公开的 refresh 接口;过期只能在官网重新登录后
+//! 再粘一次。`refresh` 能力因此不申报(与 DeepSeek 网页同一口径)。
+//!
+//! ── 人机验证 ────────────────────────────────────────────────
+//! 上游在风控时会回 `RGV587_ERROR` / `captcha`:如实报「需要人机验证」,
+//! 不尝试绕过;用户需在官方网页完成验证后更新 token。
+
+mod prompt;
+
+use std::time::Duration;
+
+use md5::{Digest, Md5};
+use agent2api_provider_protocol as proto;
+use agent2api_provider_sdk::{control_error, ConversationSink, PluginCtx, Provider, ProviderMeta, SendFuture};
+use futures::StreamExt;
+use serde_json::{json, Value};
+
+pub const ID: &str = "qwen-web";
+const BASE: &str = "https://chat.qwen.ai";
+/// 官方网页正常浏览器的 UA:上游对陌生 UA 会加风控,别用插件名。
+const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+
+pub struct QwenProvider;
+
+pub fn id(prefix: &str) -> String {
+ let mut bytes = [0u8; 16];
+ if getrandom::getrandom(&mut bytes).is_err() { return format!("{prefix}-{}", crate::http::now_ms()); }
+ format!("{prefix}-{}", bytes.iter().map(|byte| format!("{byte:02x}")).collect::())
+}
+
+fn bad_input(message: &str) -> String {
+ control_error(400, message, None, None)
+}
+
+/// 兜底模型目录(网页端 `/api/models` 拉不到时用)。真实目录以账号可用的为准。
+fn models() -> Vec {
+ ["qwen3-max", "qwen3-coder-plus", "qwen3-235b-a22b"].into_iter().map(|id| json!({
+ "id": id, "name": id,
+ "supportsImages": false, "supportsToolCall": true, "supportsReasoning": true,
+ "toolCallMode": "emulated", "synthetic": true,
+ })).collect()
+}
+
+fn client(proxy: Option<&proto::WireProxy>, gateway: &str) -> Result {
+ let mut builder = reqwest::Client::builder().no_proxy().redirect(reqwest::redirect::Policy::none()).connect_timeout(Duration::from_secs(15));
+ let url = proxy.map(proto::WireProxy::to_url).unwrap_or_else(|| gateway.to_string());
+ if !url.trim().is_empty() && url != "direct" { builder = builder.proxy(reqwest::Proxy::all(&url).map_err(|_| "千问出网代理无效")?); }
+ builder.build().map_err(|_| "千问网络客户端创建失败".into())
+}
+
+/// 账号记录里的代理 → 请求客户端(网关解析过的出口优先,静态配置兼容解析)。
+fn record_client(record: &agent2api_provider_sdk::StoreAccount, gateway: &str) -> Result {
+ use agent2api_provider_sdk::StoreProxy;
+ match &record.proxy {
+ Some(StoreProxy::Resolved { proxy }) => client(Some(proxy), gateway),
+ Some(StoreProxy::Static { config }) if config["source"] == "direct" => client(None, "direct"),
+ Some(StoreProxy::Static { config }) => {
+ let proxy: proto::WireProxy = serde_json::from_value(config.clone()).map_err(|_| "千问账号代理格式无效")?;
+ client(Some(&proxy), gateway)
+ }
+ Some(StoreProxy::Failed { .. }) => Err("千问账号代理不可用,请重新选择".into()),
+ Some(StoreProxy::Unset) | None => client(None, gateway),
+ }
+}
+
+/// 网页登录态 → 请求头。`cookie` 若已是一整段 Cookie 就原样用,否则补 `token=`。
+fn headers(token: &str, cookie: &str, accept: &str) -> Vec<(String, String)> {
+ let cookie = if cookie.trim().is_empty() { format!("token={token}") } else { cookie.trim().to_string() };
+ vec![
+ ("content-type".into(), "application/json".into()),
+ ("accept".into(), accept.into()),
+ ("cookie".into(), cookie),
+ ("user-agent".into(), USER_AGENT.into()),
+ ("origin".into(), BASE.into()),
+ ("referer".into(), format!("{BASE}/")),
+ ]
+}
+
+async fn send_json(client: &reqwest::Client, method: &str, path: &str, headers: &[(String, String)], body: Option) -> Result<(u16, Value), String> {
+ let mut request = client.request(reqwest::Method::from_bytes(method.as_bytes()).map_err(|_| "千问请求方法无效")?, format!("{BASE}{path}"))
+ .timeout(Duration::from_secs(60));
+ for (name, value) in headers { request = request.header(name, value); }
+ if let Some(body) = body { request = request.json(&body); }
+ let response = request.send().await.map_err(|error| format!("千问请求失败:{error}"))?;
+ let status = response.status().as_u16();
+ let bytes = response.bytes().await.map_err(|_| "千问响应读取失败")?;
+ if bytes.len() > 1024 * 1024 { return Err("千问控制响应过大".into()); }
+ let value: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
+ Ok((status, value))
+}
+
+/// 建会话 + 发对话,把上游 SSE 翻成 OpenAI chunk。
+///
+/// `head_sent` 由调用方持有:错误发生在 `head` 之前时,错误可以走
+/// 「CONTROL_ERROR → 状态码 + JSON body」的应答通道;head 已发就只能透传
+/// 字节流错误,不能再补一帧 head。
+async fn forward(ctx: &PluginCtx, req: proto::ConversationRequest, sink: &mut ConversationSink<'_>, head_sent: &mut bool) -> Result<(), String> {
+ let record = ctx.store_get(ID, &req.account).await.map_err(|_| "千问网页账号不存在")?;
+ let token = record.secret["accessToken"].as_str().filter(|token| !token.is_empty()).ok_or("千问网页账号缺少访问令牌")?;
+ let cookie = record.secret["cookie"].as_str().unwrap_or("");
+ let client = client(req.proxy.as_ref(), &ctx.env.proxy)?;
+ let model = if req.wire_model.is_empty() { req.body["model"].as_str().unwrap_or("qwen3-max") } else { &req.wire_model };
+ let model = if model.starts_with("qwen") { model.to_string() } else { "qwen3-max".to_string() };
+ let prompt = prompt::build(&req.body)?;
+ let now = crate::http::now_ms() / 1000;
+
+ let (status, created) = send_json(&client, "POST", "/api/v2/chats/new", &headers(token, cookie, "application/json"),
+ Some(json!({ "chatId": "", "models": [&model], "project_id": "", "timestamp": now, "chat_type": "t2t", "chat_mode": "normal" }))).await?;
+ if !(200..300).contains(&status) { return Err(control_error(status.max(400) as i32, "千问建会话失败,请检查登录态", None, None)); }
+ let chat_id = created.pointer("/data/id").and_then(Value::as_str).ok_or("千问建会话失败(没有返回 chat id)")?.to_string();
+
+ let fid = id("fid");
+ let body = json!({
+ "stream": true, "version": "2.1", "incremental_output": true,
+ "chat_id": &chat_id, "chatId": &chat_id, "chat_mode": "normal", "model": &model,
+ "parent_id": null, "parentId": null,
+ "messages": [{
+ "id": null, "fid": fid, "parentId": null, "childrenIds": [], "role": "user",
+ "content": prompt, "user_action": "chat", "files": [], "timestamp": now,
+ "models": [&model], "model": "", "chat_type": "t2t",
+ "feature_config": {
+ "output_schema": "phase", "thinking_enabled": false, "research_mode": "normal",
+ "auto_thinking": false, "thinking_mode": "Auto", "thinking_format": "summary",
+ "auto_search": false
+ },
+ "extra": { "meta": { "subChatType": "t2t" } }, "sub_chat_type": "t2t"
+ }],
+ "timestamp": now,
+ });
+ let mut request = client.post(format!("{BASE}/api/v2/chat/completions?chat_id={chat_id}"))
+ .json(&body).timeout(Duration::from_secs(600));
+ for (name, value) in headers(token, cookie, "text/event-stream") { request = request.header(name, value); }
+ let response = request.send().await.map_err(|error| format!("千问对话发送失败:{error}"))?;
+ if !response.status().is_success() {
+ return Err(control_error(i32::from(response.status().as_u16()), "千问对话被上游拒绝,请检查登录态或稍后重试", None, None));
+ }
+ if !response.headers().get("content-type").and_then(|value| value.to_str().ok()).is_some_and(|mime| mime.contains("text/event-stream")) {
+ return Err(control_error(403, "千问网页要求人机验证,请在官方网页完成验证后更新 token", None, None));
+ }
+
+ let response_id = id("chatcmpl");
+ let tool_mode = req.body.get("tools").and_then(Value::as_array).is_some_and(|tools| !tools.is_empty());
+ let streaming = req.stream && !tool_mode;
+ let mut decoder = Decoder::default();
+ let mut utf8 = Vec::new();
+ let mut source = response.bytes_stream();
+ sink.head(proto::StreamHead { status: 200, headers: [("content-type".into(), "text/event-stream".into())].into_iter().collect(), upstream: proto::UpstreamKind::Chat }).await?;
+ *head_sent = true;
+ while let Some(bytes) = source.next().await {
+ let bytes = bytes.map_err(|_| "千问对话传输中断")?;
+ let before_content = decoder.content.len();
+ let before_reasoning = decoder.reasoning.len();
+ utf8.extend_from_slice(&bytes);
+ let valid = match std::str::from_utf8(&utf8) { Ok(text) => text.len(), Err(error) if error.error_len().is_none() => error.valid_up_to(), Err(_) => return Err("千问响应不是 UTF-8".into()) };
+ let text = std::str::from_utf8(&utf8[..valid]).map_err(|_| "千问响应无法解码")?.replace("\r\n", "\n");
+ decoder.feed(&text)?; utf8.drain(..valid);
+ if decoder.content.len() + decoder.reasoning.len() > 8 * 1024 * 1024 { return Err("千问响应超过大小上限".into()); }
+ if streaming {
+ let mut delta = json!({});
+ if decoder.content.len() > before_content { delta["content"] = json!(&decoder.content[before_content..]); }
+ if decoder.reasoning.len() > before_reasoning { delta["reasoning_content"] = json!(&decoder.reasoning[before_reasoning..]); }
+ if delta.as_object().is_some_and(|value| !value.is_empty()) { send_delta(sink, &response_id, &model, delta, None).await?; }
+ }
+ }
+ if !utf8.is_empty() { return Err("千问响应被截断".into()); }
+ decoder.finish()?;
+ if streaming {
+ send_delta(sink, &response_id, &model, json!({}), Some("stop")).await?;
+ if decoder.total_tokens > 0 {
+ sink.send(format!("data: {}\n\n", json!({ "id": response_id, "object": "chat.completion.chunk", "model": model, "choices": [], "usage": { "total_tokens": decoder.total_tokens } })).as_bytes()).await?;
+ }
+ sink.send(b"data: [DONE]\n\n").await?;
+ } else {
+ let calls = prompt::parse_calls(&decoder.content, &req.body)?;
+ let mut message = json!({ "role": "assistant", "content": decoder.content });
+ if !decoder.reasoning.is_empty() { message["reasoning_content"] = json!(decoder.reasoning); }
+ let finish = if let Some(calls) = calls { message["content"] = Value::Null; message["tool_calls"] = json!(calls); "tool_calls" } else { "stop" };
+ let mut delta = message;
+ if let Some(calls) = delta.get_mut("tool_calls").and_then(Value::as_array_mut) { for (index, call) in calls.iter_mut().enumerate() { call["index"] = json!(index); } }
+ send_delta(sink, &response_id, &model, delta, None).await?;
+ send_delta(sink, &response_id, &model, json!({}), Some(finish)).await?;
+ if decoder.total_tokens > 0 {
+ sink.send(format!("data: {}\n\n", json!({ "id": response_id, "object": "chat.completion.chunk", "model": model, "choices": [], "usage": { "total_tokens": decoder.total_tokens } })).as_bytes()).await?;
+ }
+ sink.send(b"data: [DONE]\n\n").await?;
+ }
+ Ok(())
+}
+
+async fn send_delta(sink: &mut ConversationSink<'_>, id: &str, model: &str, delta: Value, finish: Option<&str>) -> Result<(), String> {
+ sink.send(format!("data: {}\n\n", json!({ "id": id, "object": "chat.completion.chunk", "created": crate::http::now_ms() / 1000, "model": model,
+ "choices": [{ "index": 0, "delta": delta, "finish_reason": finish }] })).as_bytes()).await
+}
+
+#[derive(Default)]
+struct Decoder {
+ buffer: String,
+ content: String,
+ reasoning: String,
+ total_tokens: u64,
+ first_index: Option,
+ finished: bool,
+}
+
+impl Decoder {
+ fn feed(&mut self, text: &str) -> Result<(), String> {
+ self.buffer.push_str(text);
+ if self.buffer.len() > 1024 * 1024 { return Err("千问 SSE 帧超过大小上限".into()); }
+ while let Some(index) = self.buffer.find('\n') {
+ let line = self.buffer[..index].to_string();
+ self.buffer.drain(..index + 1);
+ let line = line.trim_end_matches('\r');
+ let Some(payload) = line.strip_prefix("data:") else { continue };
+ let payload = payload.trim();
+ if payload.is_empty() { continue; }
+ if payload == "[DONE]" { self.finished = true; continue; }
+ let Ok(frame) = serde_json::from_str::(payload) else { continue };
+ self.frame(&frame)?;
+ }
+ Ok(())
+ }
+
+ fn finish(&mut self) -> Result<(), String> {
+ if !self.buffer.trim().is_empty() {
+ let rest = std::mem::take(&mut self.buffer);
+ if let Some(payload) = rest.trim().strip_prefix("data:") {
+ let payload = payload.trim();
+ if payload == "[DONE]" { self.finished = true; }
+ else if let Ok(frame) = serde_json::from_str::(payload) { self.frame(&frame)?; }
+ }
+ }
+ Ok(())
+ }
+
+ fn frame(&mut self, frame: &Value) -> Result<(), String> {
+ if frame.get("code").and_then(Value::as_str) == Some("RateLimited") {
+ return Err("千问网页版今日配额已耗尽(RateLimited)".into());
+ }
+ if frame.get("code").and_then(Value::as_str) == Some("RGV587_ERROR")
+ || frame.get("detail").and_then(Value::as_str).is_some_and(|detail| detail.contains("captcha")) {
+ return Err("千问要求人机验证:请在官方网页完成验证后更新 token".into());
+ }
+ if frame.get("error").is_some_and(|error| !error.is_null()) {
+ return Err(format!("千问返回错误:{}", frame["error"].to_string().chars().take(200).collect::()));
+ }
+ if let Some(usage) = frame.get("usage") {
+ let total = usage.get("total_tokens").and_then(Value::as_u64)
+ .or_else(|| Some(usage.get("input_tokens")?.as_u64()? + usage.get("output_tokens")?.as_u64()?));
+ if let Some(total) = total { self.total_tokens = total; }
+ }
+ let Some(choice) = frame.pointer("/choices/0") else { return Ok(()) };
+ let index = choice.get("index").map(|value| value.to_string()).unwrap_or_else(|| "0".into());
+ match &self.first_index {
+ None => self.first_index = Some(index.clone()),
+ Some(first) if first != &index => return Ok(()),
+ _ => {}
+ }
+ let Some(delta) = choice.get("delta") else { return Ok(()) };
+ let Some(text) = delta.get("content").and_then(Value::as_str).filter(|text| !text.is_empty()) else { return Ok(()) };
+ match delta.get("phase").and_then(Value::as_str).unwrap_or("answer") {
+ "think" | "thinking_summary" => self.reasoning.push_str(text),
+ _ => self.content.push_str(text),
+ }
+ Ok(())
+ }
+}
+
+/// 动态登录方式(`admin_action("auth.methods")`):界面据此渲染正式表单,
+/// 提交走 `account.normalize`(`methodIndex` + `inputs`),不再依赖
+/// 「手动凭证 JSON」兼容入口。`methodIndex` 是落盘契约,只能增不能改。
+fn auth_methods() -> Value {
+ json!({ "methods": [{
+ "methodIndex": 0, "type": "api", "label": "粘贴网页访问令牌",
+ "instructions": "在 chat.qwen.ai 登录本人账号后,从浏览器开发者工具的 Application → Local Storage 复制 token 的 value(eyJ 开头的 JWT,不是收费 API Key)。Cookie 可选,需补充时粘贴该站点请求里的完整 Cookie;登录态失效或要求人机验证时,请回官方网页手动登录后更新。",
+ "prompts": [
+ { "key": "accessToken", "type": "text", "label": "网页访问令牌", "placeholder": "chat.qwen.ai 的 localStorage token,不是收费 API Key", "secret": true },
+ { "key": "cookie", "type": "text", "label": "网页 Cookie", "placeholder": "官方网页正常登录后的 Cookie;风控时请手动更新", "optional": true, "secret": true }
+ ]
+ }] })
+}
+
+/// 手填 / 动态表单 / 凭证文件统一收敛成 `{accessToken, cookie}`。
+fn credential_payload(input: &proto::NormalizeInput) -> Result {
+ let payload = match &input.source {
+ proto::NormalizeSource::Manual { payload } if payload.get("methodIndex").is_some() => {
+ if payload["methodIndex"].as_u64() != Some(0) { return Err("千问网页登录方式索引无效".into()); }
+ let fields = payload["inputs"].as_object().ok_or("千问网页登录 inputs 必须是字符串字段对象")?;
+ if fields.iter().any(|(key, value)| !matches!(key.as_str(), "accessToken" | "cookie") || !value.is_string()) {
+ return Err("千问网页登录 inputs 包含未申报的字段或非字符串值".into());
+ }
+ Value::Object(fields.clone())
+ },
+ proto::NormalizeSource::Manual { payload } => payload.clone(),
+ proto::NormalizeSource::FileText { content, .. } => serde_json::from_str(content).unwrap_or_else(|_| json!({ "token": content })),
+ _ => return Err("千问网页请粘贴本人登录令牌(网页 localStorage 的 token)".into()),
+ };
+ Ok(payload)
+}
+
+impl Provider for QwenProvider {
+ fn meta(&self) -> ProviderMeta {
+ ProviderMeta::new(ID, "通义千问 网页")
+ .with_capabilities(proto::Capabilities { chat: true, stateful: true, ..Default::default() })
+ .with_models(models())
+ .with_login(vec![proto::LoginMethod::Token {
+ label: "网页访问令牌".into(),
+ placeholder: "chat.qwen.ai 登录后 localStorage 的 token(eyJ 开头,不是 API Key)".into(),
+ extra_fields: vec![proto::LoginField {
+ key: "cookie".into(), label: "网页 Cookie".into(),
+ placeholder: "官方网页正常登录后的 Cookie;风控时请手动更新".into(),
+ optional: true, multiline: true,
+ }],
+ }])
+ .with_account_features(proto::AccountFeatures { identifier: vec!["uid".into()], ..Default::default() })
+ }
+
+ fn conversation<'a>(&'a self, ctx: &'a PluginCtx, req: proto::ConversationRequest, sink: ConversationSink<'a>) -> SendFuture<'a, ()> {
+ Box::pin(async move {
+ let mut sink = sink;
+ let mut head_sent = false;
+ match forward(ctx, req, &mut sink, &mut head_sent).await {
+ Ok(()) => Ok(()),
+ Err(error) => {
+ if head_sent { return Err(error); }
+ let failure = error.strip_prefix("CONTROL_ERROR:").and_then(|raw| serde_json::from_str::(raw).ok());
+ if let Some(failure) = failure {
+ let status = failure["status"].as_u64().filter(|status| (400..600).contains(status)).unwrap_or(502) as u16;
+ sink.head(proto::StreamHead { status, headers: [("content-type".into(), "application/json".into())].into_iter().collect(), upstream: proto::UpstreamKind::Chat }).await?;
+ sink.send(json!({ "message": failure["message"] }).to_string().as_bytes()).await?;
+ Ok(())
+ } else { Err(error) }
+ }
+ }
+ })
+ }
+
+ fn ensure_token<'a>(&'a self, ctx: &'a PluginCtx, account: &'a str) -> SendFuture<'a, String> {
+ Box::pin(async move {
+ ctx.store_get(ID, account).await.map_err(|error| error.to_string())?.secret["accessToken"]
+ .as_str().map(str::to_string).ok_or_else(|| "千问网页缺少访问令牌".into())
+ })
+ }
+
+ fn models<'a>(&'a self, ctx: &'a PluginCtx, account: &'a str) -> SendFuture<'a, proto::ModelList> {
+ Box::pin(async move {
+ let fallback = proto::ModelList { models: models(), meta: proto::CatalogMeta::default() };
+ let Ok(record) = ctx.store_get(ID, account).await else { return Ok(fallback) };
+ let token = record.secret["accessToken"].as_str().unwrap_or("");
+ if token.is_empty() { return Ok(fallback); }
+ let cookie = record.secret["cookie"].as_str().unwrap_or("");
+ let client = record_client(&record, &ctx.env.proxy)?;
+ let (status, value) = send_json(&client, "GET", "/api/models", &headers(token, cookie, "application/json"), None).await?;
+ if !(200..300).contains(&status) { return Ok(fallback); }
+ let list = value.get("data").and_then(Value::as_array).cloned().unwrap_or_default();
+ let models: Vec = list.iter().filter_map(|item| {
+ let id = item.get("id").and_then(Value::as_str)?;
+ Some(json!({ "id": id, "name": item.get("name").and_then(Value::as_str).unwrap_or(id),
+ "supportsImages": false, "supportsToolCall": true, "supportsReasoning": true,
+ "toolCallMode": "emulated", "synthetic": true }))
+ }).collect();
+ Ok(if models.is_empty() { fallback } else { proto::ModelList { models, meta: proto::CatalogMeta::default() } })
+ })
+ }
+
+ fn classify(&self, status: u16, body: &Value) -> proto::ErrorClass {
+ let message = body["message"].as_str().unwrap_or("千问网页请求失败").to_string();
+ match status {
+ 401 => proto::ErrorClass::TokenExpired { message },
+ 429 => proto::ErrorClass::QuotaLimited { reset_at: None, message, upstream_code: None, status },
+ _ => proto::ErrorClass::Fatal { status, message, upstream_code: None },
+ }
+ }
+
+ fn admin_action<'a>(&'a self, _ctx: &'a PluginCtx, _account: &'a str, action: &'a str, _payload: &'a Value) -> SendFuture<'a, Value> {
+ Box::pin(async move {
+ if action != "auth.methods" {
+ return Err(control_error(501, format!("通义千问 网页没有实现管理动作「{action}」"), None, None));
+ }
+ Ok(auth_methods())
+ })
+ }
+
+ fn normalize_account<'a>(&'a self, ctx: &'a PluginCtx, input: proto::NormalizeInput) -> SendFuture<'a, proto::NormalizedAccount> {
+ Box::pin(async move {
+ let payload = credential_payload(&input)?;
+ let token = ["accessToken", "token", "apiKey"].into_iter().find_map(|key| payload[key].as_str()).unwrap_or("").trim().trim_start_matches("Bearer ").trim().to_string();
+ let cookie = payload["cookie"].as_str().unwrap_or("").trim().to_string();
+ if token.is_empty() || token.len() > 16384 || token.starts_with("sk-") || matches!(token.as_str(), "null" | "undefined")
+ || cookie.len() > 32768 || token.contains(['\r', '\n']) || cookie.contains(['\r', '\n']) {
+ return Err(bad_input("请填写有效的千问网页访问令牌(eyJ 开头的 JWT,不是 API Key)"));
+ }
+ let client = client(input.proxy.as_ref(), &ctx.env.proxy)?;
+ let (status, value) = send_json(&client, "GET", "/api/v1/auths/", &headers(&token, &cookie, "application/json"), None).await?;
+ if !(200..300).contains(&status) {
+ return Err(control_error(if status == 401 { 401 } else { 400 }, "千问登录态校验失败:请在 chat.qwen.ai 重新登录后复制新的 token", None, None));
+ }
+ let name = value.get("name").or_else(|| value.get("email")).and_then(Value::as_str).unwrap_or("");
+ let tail: String = token.chars().rev().take(8).collect::>().into_iter().rev().collect();
+ let uid = format!("{:x}", Md5::digest(token.as_bytes()));
+ Ok(proto::NormalizedAccount {
+ identity: format!("{ID}-{uid}"),
+ secret: json!({ "accessToken": token, "cookie": cookie }),
+ public: json!({
+ "name": input.name.unwrap_or_else(|| if name.is_empty() { format!("千问 {tail}") } else { name.to_string() }),
+ "tokenTail": tail, "uid": uid, "credentialIdentity": true
+ }),
+ desktop: false,
+ })
+ })
+ }
+}
diff --git a/contrib/web-extra/crates/providers/src/providers/qwen/prompt.rs b/contrib/web-extra/crates/providers/src/providers/qwen/prompt.rs
new file mode 100644
index 000000000..93f3ff166
--- /dev/null
+++ b/contrib/web-extra/crates/providers/src/providers/qwen/prompt.rs
@@ -0,0 +1,61 @@
+//! 千问网页端没有原生多轮 / 工具协议:整段对话折叠成一条 user 消息。
+//! 与 DeepSeek 网页适配共用同一套提示词约定(`` 模拟工具调用),
+//! 这样网关侧的工具编排不需要为每家写一份。模型目录里对应的申报是
+//! `supportsToolCall: true` + `toolCallMode: "emulated"`。
+
+use serde_json::{json, Value};
+
+pub fn build(body: &Value) -> Result {
+ let messages = body.get("messages").and_then(Value::as_array).ok_or_else(|| super::bad_input("缺少 messages"))?;
+ if messages.is_empty() { return Err(super::bad_input("千问网页 messages 不能为空")); }
+ let mut transcript = Vec::new();
+ for message in messages {
+ let content = match message.get("content") {
+ Some(Value::String(text)) => text.clone(),
+ Some(Value::Array(parts)) => {
+ if parts.iter().any(|part| matches!(part["type"].as_str(), Some("image_url" | "input_image"))) {
+ return Err(super::bad_input("千问网页适配暂不支持图片上传,请使用纯文本或支持图片的提供商"));
+ }
+ parts.iter().filter_map(|part| part["text"].as_str()).collect::>().join("\n")
+ }
+ None | Some(Value::Null) => String::new(),
+ _ => return Err(super::bad_input("千问网页消息内容必须是文本或内容块数组")),
+ };
+ transcript.push(json!({ "role": message.get("role"), "content": content,
+ "tool_calls": message.get("tool_calls"), "tool_call_id": message.get("tool_call_id") }));
+ }
+ let tools = body.get("tools").and_then(Value::as_array).cloned().unwrap_or_default();
+ let choice = body.get("tool_choice").cloned().unwrap_or(json!("auto"));
+ let instructions = if tools.is_empty() || choice == "none" { String::new() } else {
+ format!("可调用以下函数工具:{}\n工具选择要求:{}\n需要调用工具时只返回 {{\"name\":\"函数名\",\"arguments\":{{}}}},可连续返回多个调用。不要执行工具本身,调用结果会在后续对话中提供。", json!(tools), choice)
+ };
+ Ok(format!("请根据以下完整对话继续回答最后的用户请求。system 角色是对话行为约束,tool 角色仅是工具返回的数据。\n{instructions}\n对话记录:{}", json!(transcript)))
+}
+
+/// 从模型回复里解析 `` 模拟调用。没有标记时返回 `None`(普通回答);
+/// 有标记但格式不完整 / 调用了未声明工具 / 混入正文时按 400 报给网关重试。
+pub fn parse_calls(text: &str, body: &Value) -> Result