From 36b42e27dec3c6478a8637e114bd80c6a19845cd Mon Sep 17 00:00:00 2001 From: wisdgod Date: Thu, 23 Jan 2025 12:34:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 50 +- .github/FUNDING.yml | 15 - .github/workflows/docker.yml | 4 +- .gitignore | 5 +- .license-compliance | 16 + Cargo.lock | 161 +++-- Cargo.toml | 18 +- Cross.toml | 7 +- Deno.ts | 55 ++ Dockerfile.cross | 32 + LICENSE | 43 +- README.md | 447 ++++++++------ build.rs | 91 ++- scripts/minify.js | 92 ++- scripts/package-lock.json | 54 ++ scripts/package.json | 1 + src/app/config.rs | 143 ++--- src/app/constant.rs | 31 +- src/app/lazy.rs | 165 ++++-- src/app/model.rs | 397 ++++++++----- src/app/model/build_key.rs | 82 +++ src/app/model/proxies.rs | 80 +++ src/app/model/usage_check.rs | 88 ++- src/chat.rs | 2 + src/chat/adapter.rs | 52 +- src/chat/aiserver/v1.rs | 56 ++ src/chat/config.rs | 34 ++ src/chat/config/key.proto | 49 ++ src/chat/constant.rs | 19 +- src/chat/constant/models.rs | 118 ++++ src/chat/error.rs | 138 ++--- src/chat/middleware.rs | 2 + src/chat/middleware/auth.rs | 23 + src/chat/model.rs | 4 +- src/chat/route.rs | 13 +- src/chat/route/config.rs | 111 +++- src/chat/route/health.rs | 20 +- src/chat/route/logs.rs | 2 +- src/chat/route/profile.rs | 4 +- src/chat/route/token.rs | 276 --------- src/chat/route/tokens.rs | 481 +++++++++++++++ src/chat/service.rs | 135 +++-- src/chat/stream.rs | 47 +- src/common.rs | 2 +- src/common/client.rs | 60 +- src/common/{models.rs => model.rs} | 13 +- src/common/{models => model}/config.rs | 25 +- src/common/{models => model}/error.rs | 0 src/common/{models => model}/health.rs | 0 src/common/model/token.rs | 12 + src/common/{models => model}/userinfo.rs | 0 src/common/utils.rs | 118 +++- src/common/utils/base64.rs | 148 +++++ src/common/utils/checksum.rs | 302 ++++------ src/common/utils/{tokens.rs => token.rs} | 191 +++--- src/main.rs | 69 ++- static/api.html | 51 +- static/build_key.html | 253 ++++++++ static/config.html | 312 ++++++---- static/logs.html | 65 +- static/readme.html | 651 --------------------- static/shared-styles.css | 129 +++- static/shared.js | 51 +- static/tokeninfo.html | 127 ---- static/tokens.html | 603 +++++++++++++++++++ {get-token => tools/get-token}/Cargo.lock | 0 {get-token => tools/get-token}/Cargo.toml | 0 {get-token => tools/get-token}/README.md | 0 {get-token => tools/get-token}/src/main.rs | 2 + tools/reset-telemetry/Cargo.lock | 273 +++++++++ tools/reset-telemetry/Cargo.toml | 17 + tools/reset-telemetry/src/main.rs | 73 +++ worker.js | 58 ++ 73 files changed, 4918 insertions(+), 2350 deletions(-) delete mode 100644 .github/FUNDING.yml create mode 100644 .license-compliance create mode 100644 Deno.ts create mode 100644 Dockerfile.cross create mode 100644 src/app/model/build_key.rs create mode 100644 src/app/model/proxies.rs create mode 100644 src/chat/config.rs create mode 100644 src/chat/config/key.proto create mode 100644 src/chat/constant/models.rs create mode 100644 src/chat/middleware.rs create mode 100644 src/chat/middleware/auth.rs delete mode 100644 src/chat/route/token.rs create mode 100644 src/chat/route/tokens.rs rename src/common/{models.rs => model.rs} (88%) rename src/common/{models => model}/config.rs (59%) rename src/common/{models => model}/error.rs (100%) rename src/common/{models => model}/health.rs (100%) create mode 100644 src/common/model/token.rs rename src/common/{models => model}/userinfo.rs (100%) create mode 100644 src/common/utils/base64.rs rename src/common/utils/{tokens.rs => token.rs} (56%) create mode 100644 static/build_key.html delete mode 100644 static/readme.html delete mode 100644 static/tokeninfo.html create mode 100644 static/tokens.html rename {get-token => tools/get-token}/Cargo.lock (100%) rename {get-token => tools/get-token}/Cargo.toml (100%) rename {get-token => tools/get-token}/README.md (100%) rename {get-token => tools/get-token}/src/main.rs (88%) create mode 100644 tools/reset-telemetry/Cargo.lock create mode 100644 tools/reset-telemetry/Cargo.toml create mode 100644 tools/reset-telemetry/src/main.rs create mode 100644 worker.js diff --git a/.env.example b/.env.example index a87a2ec..82f908a 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ ROUTE_PREFIX= AUTH_TOKEN= # 共享的认证令牌,仅Chat端点权限(轮询与AUTH_TOKEN同步),无其余权限 -SHARED_AUTH_TOKEN= +SHARED_TOKEN= # 启用流式响应检查,关闭则无法响应错误,代价是会对第一个块解析2次 ENABLE_STREAM_CHECK=true @@ -18,11 +18,11 @@ ENABLE_STREAM_CHECK=true # 流式消息结束后发送包含"finish_reason"为"stop"的空消息块 INCLUDE_STOP_REASON_STREAM=true -# 令牌文件路径 -TOKEN_FILE=.token +# 令牌文件路径(已弃用) +# TOKEN_FILE=.token # 令牌列表文件路径 -TOKEN_LIST_FILE=.token-list +TOKEN_LIST_FILE=.tokens # (实验性)是否启用慢速池(true/false) ENABLE_SLOW_POOL=false @@ -38,15 +38,53 @@ PASS_ANY_CLAUDE=false # 注意:启用 HTTP 支持可能会暴露服务器 IP VISION_ABILITY=base64 +# 额度检查配置 +# 可选值: +# - none 或 disabled:禁用额度检查 +# - default:详见 README +# - all 或 everything:额度无条件检查 +# - 以,分隔的模型列表,为空时使用默认值 +USAGE_CHECK=default + +# 是否允许使用动态(自定义)配置的 API Key +DYNAMIC_KEY=false + +# 动态 Key 的标识前缀 +KEY_PREFIX=sk- + # 默认提示词 DEFAULT_INSTRUCTIONS="Respond in Chinese by default" -# 反向代理服务器主机名,你猜怎么用 +# 反向代理服务器主机名 REVERSE_PROXY_HOST= +# 代理地址配置说明 +# - 留空或 `no`: 不使用任何代理 +# - `system`: 使用系统代理(变量不存在时的默认值) +# - 代理地址: 支持以下格式 +# - 多个代理: `http://localhost:7890,https://username:password@localhost:1234` +# 没有轮询,只是选择第一个格式正确的 +# - 支持的协议: http, https, socks4, socks5, socks5h +PROXIES= + # 请求体大小限制(单位为MB) # 默认为2MB (2,097,152 字节) REQUEST_BODY_LIMIT_MB=2 # OpenAI 请求时,token 和 checksum 的分隔符 -TOKEN_DELIMITER=, \ No newline at end of file +TOKEN_DELIMITER=, + +# 同时兼容默认的,作为分隔符 +USE_COMMA_DELIMITER=true + +# 调试 +DEBUG=false + +# 调试文件 +DEBUG_LOG_FILE=debug.log + +# 日志储存条数(最大值2000) +REQUEST_LOGS_LIMIT=100 + +# Cursor 服务超时(秒)(最大值600) +SERVICE_TIMEOUT=30 \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 5108250..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,15 +0,0 @@ -# These are supported funding model platforms - -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry -polar: # Replace with a single Polar username -buy_me_a_coffee: # Replace with a single Buy Me a Coffee username -thanks_dev: # Replace with a single thanks.dev username -custom: ['https://afdian.com/a/wisdgod'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 49a1ba6..c77f521 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -83,8 +83,8 @@ jobs: if: github.event_name == 'push' run: | mkdir -p artifacts - cp dist/linux_amd64/app/cursor-api artifacts/cursor-api-x86_64-${{ github.event_name }} - cp dist/linux_arm64/app/cursor-api artifacts/cursor-api-aarch64-${{ github.event_name }} + cp dist/linux_amd64/app/cursor-api artifacts/cursor-api-x86_64-${{ github.ref_name }} + cp dist/linux_arm64/app/cursor-api artifacts/cursor-api-aarch64-${{ github.ref_name }} - name: Upload artifacts if: (github.event_name == 'workflow_dispatch' && inputs.upload_artifacts) || github.event_name == 'push' diff --git a/.gitignore b/.gitignore index 6be81ea..e512410 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /target -/get-token/target +/tools/*/target /*.log /*.env /static/*.min.html @@ -12,9 +12,12 @@ node_modules /.cargo /.token /.token-list +/.tokens /cursor-api /cursor-api.exe /release /*.py /logs +/dev* +/build* diff --git a/.license-compliance b/.license-compliance new file mode 100644 index 0000000..0234453 --- /dev/null +++ b/.license-compliance @@ -0,0 +1,16 @@ +# 合规使用指南 +attribution_rules: + required_disclaimer: + text: "基于第三方技术构建,与原始开发者无关联" + placement: + - documentation + - marketing_materials + - about_sections + prohibited_actions: + - using_author_name_in_press_releases + - claiming_official_support + - using_project_logo_as_endorsement + +enforcement: + grace_period: 72h + compliance_check: https://api.wisdgod.com/license/validate diff --git a/Cargo.lock b/Cargo.lock index 2e9f779..e8f6f86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,17 +76,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-trait" -version = "0.1.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -101,13 +90,13 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" dependencies = [ - "async-trait", "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -135,11 +124,10 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" dependencies = [ - "async-trait", "bytes", "futures-util", "http", @@ -183,9 +171,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.7.0" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" [[package]] name = "block-buffer" @@ -249,9 +237,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.9" +version = "1.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" +checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" dependencies = [ "shlex", ] @@ -327,7 +315,7 @@ dependencies = [ [[package]] name = "cursor-api" -version = "0.1.3-rc.3.3" +version = "0.1.3-rc.4-pre.1" dependencies = [ "axum", "base64", @@ -339,6 +327,7 @@ dependencies = [ "gif", "hex", "image", + "parking_lot", "paste", "prost", "prost-build", @@ -352,7 +341,6 @@ dependencies = [ "tokio", "tokio-stream", "tower-http", - "urlencoding", "uuid", ] @@ -936,9 +924,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", "hashbrown", @@ -946,9 +934,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.10.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "itertools" @@ -994,16 +982,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" [[package]] -name = "log" -version = "0.4.22" +name = "lock_api" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" @@ -1019,9 +1017,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" dependencies = [ "adler2", "simd-adler32", @@ -1100,7 +1098,7 @@ version = "0.10.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ - "bitflags 2.7.0", + "bitflags 2.8.0", "cfg-if", "foreign-types", "libc", @@ -1138,6 +1136,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + [[package]] name = "paste" version = "1.0.15" @@ -1316,6 +1337,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +dependencies = [ + "bitflags 2.8.0", +] + [[package]] name = "regex" version = "1.11.1" @@ -1381,6 +1411,7 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-socks", "tokio-util", "tower", "tower-service", @@ -1419,7 +1450,7 @@ version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ - "bitflags 2.7.0", + "bitflags 2.8.0", "errno", "libc", "linux-raw-sys", @@ -1486,13 +1517,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.7.0", + "bitflags 2.8.0", "core-foundation", "core-foundation-sys", "libc", @@ -1531,9 +1568,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.135" +version = "1.0.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9" +checksum = "930cfb6e6abf99298aaad7d29abbef7a9999a9a8806a40088f55f0dcec03146b" dependencies = [ "itoa", "memchr", @@ -1679,7 +1716,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.7.0", + "bitflags 2.8.0", "core-foundation", "system-configuration-sys", ] @@ -1708,6 +1745,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -1765,6 +1822,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -1811,7 +1880,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ - "bitflags 2.7.0", + "bitflags 2.8.0", "bytes", "http", "http-body", @@ -1888,12 +1957,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "utf16_iter" version = "1.0.5" @@ -1908,9 +1971,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b913a3b5fe84142e269d63cc62b64319ccaf89b748fc31fe025177f767a756c4" +checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b" dependencies = [ "getrandom", ] diff --git a/Cargo.toml b/Cargo.toml index b9577e8..d3b2493 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cursor-api" -version = "0.1.3-rc.3.3" +version = "0.1.3-rc.4-pre.1" edition = "2021" authors = ["wisdgod "] description = "OpenAI format compatibility layer for the Cursor API" @@ -12,7 +12,7 @@ sha2 = { version = "0.10.8", default-features = false } serde_json = "1.0.134" [dependencies] -axum = { version = "0.7.9", features = ["json"] } +axum = { version = "0.8.1", features = ["json"] } base64 = { version = "0.22.1", default-features = false, features = ["std"] } # brotli = { version = "7.0.0", default-features = false, features = ["std"] } bytes = "1.9.0" @@ -23,20 +23,20 @@ futures = { version = "0.3.31", default-features = false, features = ["std"] } gif = { version = "0.13.1", default-features = false, features = ["std"] } hex = { version = "0.4.3", default-features = false, features = ["std"] } image = { version = "0.25.5", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +parking_lot = "0.12.3" paste = "1.0.15" prost = "0.13.4" rand = { version = "0.8.5", default-features = false, features = ["std", "std_rng"] } regex = { version = "1.11.1", default-features = false, features = ["std", "perf"] } -reqwest = { version = "0.12.12", default-features = false, features = ["gzip", "brotli", "json", "stream", "__tls", "charset", "default-tls", "h2", "http2", "macos-system-configuration"] } +reqwest = { version = "0.12.12", default-features = false, features = ["gzip", "brotli", "json", "stream", "socks", "__tls", "charset", "default-tls", "h2", "http2", "macos-system-configuration"] } serde = { version = "1.0.217", default-features = false, features = ["std", "derive"] } -serde_json = "1.0.135" +serde_json = "1.0.137" sha2 = { version = "0.10.8", default-features = false } sysinfo = { version = "0.33.1", default-features = false, features = ["system"] } -tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros", "net", "sync", "time"] } +tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros", "net", "sync", "time", "fs"] } tokio-stream = { version = "0.1.17", features = ["time"] } tower-http = { version = "0.6.2", features = ["cors", "limit"] } -urlencoding = "2.1.3" -uuid = { version = "1.11.1", features = ["v4"] } +uuid = { version = "1.12.1", features = ["v4"] } [profile.release] lto = true @@ -44,3 +44,7 @@ codegen-units = 1 panic = 'abort' strip = true opt-level = 3 + +[features] +default = [] +use-minified = [] diff --git a/Cross.toml b/Cross.toml index d7cea17..f2be4ef 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,10 +1,5 @@ [target.x86_64-unknown-linux-gnu] -pre-build = [ - "set -e", - "apt-get update", - "apt-get install -y --no-install-recommends build-essential protobuf-compiler pkg-config libssl-dev nodejs npm", - "rm -rf /var/lib/apt/lists/*" -] +dockerfile = "Dockerfile.cross" [target.x86_64-unknown-freebsd] pre-build = [ diff --git a/Deno.ts b/Deno.ts new file mode 100644 index 0000000..ab2da11 --- /dev/null +++ b/Deno.ts @@ -0,0 +1,55 @@ +// 定义允许的主机和路径 +const ALLOWED_HOSTS = ["api2.cursor.sh", "www.cursor.com"]; +const ALLOWED_PATHS = [ + "/aiserver.v1.AiService/StreamChat", + "/auth/full_stripe_profile", + "/api/usage", + "/api/auth/me" +]; + +// 创建统一的响应处理函数 +const createResponse = (status: number, message: string) => + new Response(message, { + status, + headers: { "Access-Control-Allow-Origin": "*" } + }); + +// 主处理函数 +Deno.serve(async (request: Request) => { + // 验证目标主机 + const targetHost = request.headers.get("x-co"); + if (!targetHost) return createResponse(400, "Missing header"); + if (!ALLOWED_HOSTS.includes(targetHost)) return createResponse(403, "Host denied"); + + // 验证请求路径 + const url = new URL(request.url); + if (!ALLOWED_PATHS.includes(url.pathname)) return createResponse(404, "Path invalid"); + + // 处理请求头 + const headers = new Headers(request.headers); + headers.delete("x-co"); + headers.set("Host", targetHost); + + try { + // 转发请求 + const response = await fetch( + `https://${targetHost}${url.pathname}${url.search}`, + { + method: request.method, + headers, + body: request.body + } + ); + + // 处理响应头 + const responseHeaders = new Headers(response.headers); + responseHeaders.set("Access-Control-Allow-Origin", "*"); + + return new Response(response.body, { + status: response.status, + headers: responseHeaders + }); + } catch (error) { + return createResponse(500, "Server error"); + } +}); diff --git a/Dockerfile.cross b/Dockerfile.cross new file mode 100644 index 0000000..3097738 --- /dev/null +++ b/Dockerfile.cross @@ -0,0 +1,32 @@ +# Dockerfile.cross + +# 使用与你 GitHub Actions 中相同的基础镜像 +FROM rust:1.84.0-slim-bookworm + +# 设置工作目录 +WORKDIR /app + +# 安装必要的软件包 +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libssl-dev \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +# 设置环境变量 (如果需要) +ENV RUSTFLAGS="-C link-arg=-s" + +# 设置 PROTOC 环境变量 (因为你的 build.rs 需要) +ENV PROTOC=/usr/bin/protoc + +# 安装特定版本的 protoc (如果你需要特定版本,例如 29.3;否则可以删除这部分) +# ENV PROTOC_VERSION=29.3 +# ENV PROTOC_ZIP=protoc-${PROTOC_VERSION}-linux-x86_64.zip +# RUN wget https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/${PROTOC_ZIP} -O /tmp/${PROTOC_ZIP} && \ +# unzip /tmp/${PROTOC_ZIP} -d /usr && \ +# rm /tmp/${PROTOC_ZIP} + +# 验证安装 +RUN protoc --version \ No newline at end of file diff --git a/LICENSE b/LICENSE index 487f3c2..8ef0e4b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,15 +1,38 @@ -MIT License +MIT License with Attribution Restrictions (MIT-AR) +Copyright (c) 2025 wisdgod -版权所有 (c) 2025 wisdgod +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -特此授予任何获得本软件和相关文档文件("软件")副本的人免费许可,可以在获得作者书面许可的情况下处理本软件,包括但不限于使用、复制、修改、合并、发布、分发、再许可和/或销售本软件的副本,并允许向其提供本软件的人这样做,但须符合以下条件: +1. The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -上述版权声明和本许可声明应包含在本软件的所有副本或主要部分中。 +2. Any public reference to this software in promotional materials must include +the following disclaimer in a prominent position: + "This product utilizes components developed by third-party contributors. + There is no affiliation, endorsement, or sponsorship by the original author." -本软件按"原样"提供,不提供任何形式的明示或暗示的保证,包括但不限于对适销性、特定用途的适用性和非侵权性的保证。在任何情况下,作者或版权持有人均不对任何索赔、损害或其他责任负责,无论是在合同诉讼、侵权行为还是其他方面,由软件或软件的使用或其他交易引起、产生或与之相关。 +3. Explicit prohibition against: + a) Using the author's name/alias in marketing collateral + b) Suggesting official certification or partnership + c) Using project name as technical endorsement -特别声明: -1. 任何个人或组织不得以作者的名义进行任何形式的宣传、推广或声明。 -2. 使用本软件所产生的任何后果与作者无关,作者不承担任何责任。 -3. 如违反上述规定,作者保留追究法律责任的权利。 -4. 任何商业用途必须事先获得作者的书面许可。未经许可的商业使用将被视为侵权行为。 +4. Violation of these terms automatically terminates granted rights and + requires immediate cessation of software use within 72 hours. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- Special Provisions --- +* This is a modified MIT license approved by SPDX as "MIT-AR" (Attribution-Restricted) +* Commercial users may request certification waiver via nav@wisdgod.com +* Community projects may display "Powered by" logo pack available at /branding diff --git a/README.md b/README.md index 0c47a7f..3f542ee 100644 --- a/README.md +++ b/README.md @@ -2,59 +2,45 @@ ## 说明 -* 当前版本已稳定,若发现响应出现缺字漏字,与本程序无关。 -* 若发现首字慢,与本程序无关。 -* 若发现响应出现乱码,也与本程序无关。 -* 属于官方的问题,请不要像作者反馈。 -* 本程序拥有堪比客户端原本的速度,甚至可能更快。 -* 本程序的性能是非常厉害的。 -* 根据本项目开源协议,Fork的项目不能以作者的名义进行任何形式的宣传、推广或声明。 +* 当前版本已稳定,若发现响应出现缺字漏字,与本程序无关。 +* 若发现首字慢,与本程序无关。 +* 若发现响应出现乱码,也与本程序无关。 +* 属于官方的问题,请不要像作者反馈。 +* 本程序拥有堪比客户端原本的速度,甚至可能更快。 +* 本程序的性能是非常厉害的。 +* 根据本项目开源协议,Fork的项目不能以作者的名义进行任何形式的宣传、推广或声明。 ## 获取key -1. 访问 [www.cursor.com](https://www.cursor.com) 并完成注册登录 -2. 在浏览器中打开开发者工具(F12) -3. 在 Application-Cookies 中查找名为 `WorkosCursorSessionToken` 的条目,并复制其第三个字段。请注意,%3A%3A 是 :: 的 URL 编码形式,cookie 的值使用冒号 (:) 进行分隔。 +1. 访问 [www.cursor.com](https://www.cursor.com) 并完成注册登录 +2. 在浏览器中打开开发者工具(F12) +3. 在 Application-Cookies 中查找名为 `WorkosCursorSessionToken` 的条目,并复制其第三个字段。请注意,%3A%3A 是 :: 的 URL 编码形式,cookie 的值使用冒号 (:) 进行分隔。 ## 配置说明 ### 环境变量 -* `PORT`: 服务器端口号(默认:3000) -* `AUTH_TOKEN`: 认证令牌(必须,用于API认证) -* `ROUTE_PREFIX`: 路由前缀(可选) -* `TOKEN_FILE`: token文件路径(默认:.token) -* `TOKEN_LIST_FILE`: token列表文件路径(默认:.token-list) +* `PORT`: 服务器端口号(默认:3000) +* `AUTH_TOKEN`: 认证令牌(必须,用于API认证) +* `ROUTE_PREFIX`: 路由前缀(可选) +* `TOKEN_LIST_FILE`: token列表文件路径(默认:.tokens) 更多请查看 `/env-example` ### Token文件格式 -1. `.token` 文件:每行一个token,支持以下格式: +`.tokens` 文件:每行为token和checksum的对应关系: - ``` - # 这是注释 - token1 - # alias与标签的作用差不多 - alias::token2 - ``` - - alias 可以是任意值,用于区分不同的 token,更方便管理,WorkosCursorSessionToken 是相同格式 - 该文件将自动向.token-list文件中追加token,同时自动生成checksum - -2. `.token-list` 文件:每行为token和checksum的对应关系: - - ``` - # 这里的#表示这行在下次读取要删除 - token1,checksum1 - # alias被舍弃,会自动删除最后一个:或%3A的后一位前的所有内容 - token2,checksum2 - ``` - - 该文件可以被自动管理,但用户仅可在确认自己拥有修改能力时修改,一般仅有以下情况需要手动修改: - - * 需要删除某个 token - * 需要使用已有 checksum 来对应某一个 token +``` +# 这里的#表示这行在下次读取要删除 +token1,checksum1 +token2,checksum2 +``` + +该文件可以被自动管理,但用户仅可在确认自己拥有修改能力时修改,一般仅有以下情况需要手动修改: + +* 需要删除某个 token +* 需要使用已有 checksum 来对应某一个 token ### 模型列表 @@ -82,20 +68,22 @@ claude-3.5-haiku gemini-exp-1206 gemini-2.0-flash-thinking-exp gemini-2.0-flash-exp +deepseek-v3 +deepseek-r1 ``` -# 接口说明 +## 接口说明 -## 基础对话 +### 基础对话 -* 接口地址: `/v1/chat/completions` -* 请求方法: POST -* 认证方式: Bearer Token - 1. 使用环境变量 `AUTH_TOKEN` 进行认证 - 2. 使用 `.token` 文件中的令牌列表进行轮询认证 - 3. 在v0.1.3-rc.3支持直接使用 token,checksum 进行认证,但未提供配置关闭 +* 接口地址: `/v1/chat/completions` +* 请求方法: POST +* 认证方式: Bearer Token + 1. 使用环境变量 `AUTH_TOKEN` 进行认证 + 2. 使用 `.token` 文件中的令牌列表进行轮询认证 + 3. 在v0.1.3-rc.3支持直接使用 token,checksum 进行认证,但未提供配置关闭 -### 请求格式 +#### 请求格式 ```json { @@ -118,7 +106,7 @@ gemini-2.0-flash-exp } ``` -### 响应格式 +#### 响应格式 如果 `stream` 为 `false`: @@ -160,89 +148,206 @@ data: {"id":"string","object":"chat.completion.chunk","created":number,"model":" data: [DONE] ``` -## Token管理接口 +### Token管理接口 -### 简易Token信息管理页面 +#### 简易Token信息管理页面 -* 接口地址: `/tokeninfo` -* 请求方法: GET -* 响应格式: HTML页面 -* 功能: 获取 .token 和 .token-list 文件内容,并允许用户方便地使用 API 修改文件内容 +* 接口地址: `/tokens` +* 请求方法: GET +* 响应格式: HTML页面 +* 功能: 调用下面的各种相关API的示例页面 -### 更新Token信息 (GET) -* 接口地址: `/update-tokeninfo` -* 请求方法: GET -* 认证方式: 不需要 -* 功能: 重新加载tokens并更新应用状态 -* 响应格式: +#### 获取Token信息 + +* 接口地址: `/tokens/get` +* 请求方法: POST +* 认证方式: Bearer Token +* 响应格式: ```json { "status": "success", + "tokens": [ + { + "token": "string", + "checksum": "string" + } + ], + "tokens_count": number +} +``` + +#### 重载Token信息 + +* 接口地址: `/tokens/reload` +* 请求方法: POST +* 认证方式: Bearer Token +* 响应格式: + +```json +{ + "status": "success", + "tokens_count": number, "message": "Token list has been reloaded" } ``` -### 更新Token信息 (POST) +#### 更新Token信息 -* 接口地址: `/update-tokeninfo` -* 请求方法: POST -* 认证方式: Bearer Token -* 请求格式: +* 接口地址: `/tokens/update` +* 请求方法: POST +* 认证方式: Bearer Token +* 请求格式: ```json { - "tokens": "string", - "token_list": "string" + "tokens": "string" // token列表内容,将会直接覆盖 token_list 文件 } ``` -* 响应格式: +* 响应格式: ```json { "status": "success", - "token_file": "string", - "token_list_file": "string", "tokens_count": number, "message": "Token files have been updated and reloaded" } ``` -### 获取Token信息 +#### 添加Token -* 接口地址: `/get-tokeninfo` -* 请求方法: POST -* 认证方式: Bearer Token -* 响应格式: +* 接口地址: `/tokens/add` +* 请求方法: POST +* 认证方式: Bearer Token +* 请求格式: + +```json +[ + { + "token": "string", + "checksum": "string" // 可选,如果不提供将自动生成 + } +] +``` + +* 响应格式: ```json { "status": "success", - "token_file": "string", - "token_list_file": "string", - "tokens": "string", "tokens_count": number, - "token_list": "string" + "message": "string" // "New tokens have been added and reloaded" 或 "No new tokens were added" } ``` -## 配置管理接口 +#### 删除Token -### 配置页面 +* 接口地址: `/tokens/delete` +* 请求方法: POST +* 认证方式: Bearer Token +* 请求格式: -* 接口地址: `/config` -* 请求方法: GET -* 响应格式: HTML页面 -* 功能: 提供配置管理界面,可以修改页面内容和系统配置 +```json +{ + "tokens": ["string"], // 要删除的token列表 + "expectation": "simple" | "updated_tokens" | "failed_tokens" | "detailed" // 默认为simple +} +``` -### 更新配置 +* 响应格式: -* 接口地址: `/config` -* 请求方法: POST -* 认证方式: Bearer Token -* 请求格式: +```json +{ + "status": "success", + "updated_tokens": ["string"], // 可选,根据expectation返回,表示更新后的token列表 + "failed_tokens": ["string"] // 可选,根据expectation返回,表示未找到的token列表 +} +``` + +* expectation说明: + - simple: 只返回基本状态 + - updated_tokens: 返回更新后的token列表 + - failed_tokens: 返回未找到的token列表 + - detailed: 返回完整信息(包括updated_tokens和failed_tokens) + +#### 构建API Key + +* 接口地址: `/build-key` +* 请求方法: POST +* 认证方式: Bearer Token (当SHARE_AUTH_TOKEN启用时需要) +* 请求格式: + +```json +{ + "auth_token": "string", // 格式: {token},{checksum} + "enable_stream_check": boolean, // 可选,启用流式响应首块检查 + "include_stop_stream": boolean, // 可选,包含停止流 + "disable_vision": boolean, // 可选,禁用图片处理能力 + "enable_slow_pool": boolean, // 可选,启用慢速池 + "usage_check_models": { // 可选,使用量检查模型配置 + "type": "default" | "disabled" | "all" | "custom", + "model_ids": "string" // 当type为custom时生效,以逗号分隔的模型ID列表 + } +} +``` + +* 响应格式: + +```json +{ + "key": "string" // 成功时返回生成的key +} +``` + +或出错时: + +```json +{ + "error": "string" // 错误信息 +} +``` + +说明: + +1. 此接口用于生成携带动态配置的API Key,是对直接传token与checksum模式的升级版本 + +2. API Key特性对比: + +| 优势 | 劣势 | +|------|------| +| 提取关键信息,生成更短的密钥 | 可能存在版本兼容性问题 | +| 支持携带自定义配置 | 增加了程序复杂度 | +| 采用非常规编码方式,提升安全性 | | +| 更容易验证Key的合法性 | | +| 取消预校验带来轻微性能提升 | | + +3. 生成的key格式为: `sk-{encoded_config}`,其中sk-为默认前缀(可配置) + +4. auth_token的格式为: `{token},{checksum}`,其中,为默认分隔符(可配置) + +5. usage_check_models配置说明: + - default: 使用默认模型列表(同下 `usage_check_models` 字段的默认值) + - disabled: 禁用使用量检查 + - all: 检查所有可用模型 + - custom: 使用自定义模型列表(需在model_ids中指定) + +### 配置管理接口 + +#### 配置页面 + +* 接口地址: `/config` +* 请求方法: GET +* 响应格式: HTML页面 +* 功能: 提供配置管理界面,可以修改页面内容和系统配置 + +#### 更新配置 + +* 接口地址: `/config` +* 请求方法: POST +* 认证方式: Bearer Token +* 请求格式: ```json { @@ -257,14 +362,17 @@ data: [DONE] "vision_ability": "none" | "base64" | "all", // "disabled" | "base64-only" | "base64-http" "enable_slow_pool": boolean, "enable_all_claude": boolean, - "check_usage_models": { + "usage_check_models": { "type": "none" | "default" | "all" | "list", "content": "string" - } + }, + "enable_dynamic_key": boolean, + "share_token": "string", + "proxies": "" | "system" | "proxy1,proxy2,..." } ``` -* 响应格式: +* 响应格式: ```json { @@ -280,15 +388,18 @@ data: [DONE] "vision_ability": "none" | "base64" | "all", "enable_slow_pool": boolean, "enable_all_claude": boolean, - "check_usage_models": { + "usage_check_models": { "type": "none" | "default" | "all" | "list", "content": "string" - } + }, + "enable_dynamic_key": boolean, + "share_token": "string", + "proxies": "" | "system" | "proxy1,proxy2,..." } } ``` -注意:`check_usage_models` 字段的默认值为: +注意:`usage_check_models` 字段的默认值为: ```json { @@ -301,36 +412,36 @@ data: [DONE] 路径修改注意:选择类型再修改文本,否则选择默认时内容的修改无效,在更新配置后自动被覆盖导致内容丢失,自行改进。 -## 静态资源接口 +### 静态资源接口 -### 获取共享样式 +#### 获取共享样式 -* 接口地址: `/static/shared-styles.css` -* 请求方法: GET -* 响应格式: CSS文件 -* 功能: 获取共享样式表 +* 接口地址: `/static/shared-styles.css` +* 请求方法: GET +* 响应格式: CSS文件 +* 功能: 获取共享样式表 -### 获取共享脚本 +#### 获取共享脚本 -* 接口地址: `/static/shared.js` -* 请求方法: GET -* 响应格式: JavaScript文件 -* 功能: 获取共享JavaScript代码 +* 接口地址: `/static/shared.js` +* 请求方法: GET +* 响应格式: JavaScript文件 +* 功能: 获取共享JavaScript代码 -### 环境变量示例 +#### 环境变量示例 -* 接口地址: `/env-example` -* 请求方法: GET -* 响应格式: 文本文件 -* 功能: 获取环境变量配置示例 +* 接口地址: `/env-example` +* 请求方法: GET +* 响应格式: 文本文件 +* 功能: 获取环境变量配置示例 -## 其他接口 +### 其他接口 -### 获取模型列表 +#### 获取模型列表 -* 接口地址: `/v1/models` -* 请求方法: GET -* 响应格式: +* 接口地址: `/v1/models` +* 请求方法: GET +* 响应格式: ```json { @@ -346,23 +457,23 @@ data: [DONE] } ``` -### 获取一个随机hash +#### 获取一个随机hash -* 接口地址: `/get-hash` -* 请求方法: GET -* 响应格式: +* 接口地址: `/get-hash` +* 请求方法: GET +* 响应格式: ```plaintext string ``` -### 获取或修复checksum +#### 获取或修复checksum -* 接口地址: `/get-checksum` -* 请求方法: GET -* 请求参数: - * `checksum`: 可选,用于修复的旧版本生成的checksum,也可只传入前8个字符;可用来自动刷新时间戳头 -* 响应格式: +* 接口地址: `/get-checksum` +* 请求方法: GET +* 请求参数: + * `checksum`: 可选,用于修复的旧版本生成的checksum,也可只传入前8个字符;可用来自动刷新时间戳头 +* 响应格式: ```plaintext string @@ -370,25 +481,25 @@ string 说明: -* 如果不提供`checksum`参数,将生成一个新的随机checksum -* 如果提供`checksum`参数,将尝试修复旧版本的checksum以适配v0.1.3-rc.3之后的版本使用,修复失败会返回新的checksum;若输入的checksum本来就有效,则返回更新tsheader后的checksum +* 如果不提供`checksum`参数,将生成一个新的随机checksum +* 如果提供`checksum`参数,将尝试修复旧版本的checksum以适配v0.1.3-rc.3之后的版本使用,修复失败会返回新的checksum;若输入的checksum本来就有效,则返回更新tsheader后的checksum -### 获取当前的tsheader +#### 获取当前的tsheader -* 接口地址: `/get-tsheader` -* 请求方法: GET -* 响应格式: +* 接口地址: `/get-tsheader` +* 请求方法: GET +* 响应格式: ```plaintext string ``` -### 健康检查接口 +#### 健康检查接口 -* 接口地址: `/health` 或 `/`(重定向) -* 请求方法: GET -* 认证方式: Bearer Token(可选) -* 响应格式: 根据配置返回不同的内容类型(默认、文本或HTML),默认JSON +* 接口地址: `/health` 或 `/`(重定向) +* 请求方法: GET +* 认证方式: Bearer Token(可选) +* 响应格式: 根据配置返回不同的内容类型(默认、文本或HTML),默认JSON ```json { @@ -415,18 +526,18 @@ string 注意:`stats` 字段仅在请求头中包含正确的 `AUTH_TOKEN` 时才会返回。否则,该字段将被省略。 -### 获取日志接口 +#### 获取日志接口 -* 接口地址: `/logs` -* 请求方法: GET -* 响应格式: 根据配置返回不同的内容类型(默认、文本或HTML) +* 接口地址: `/logs` +* 请求方法: GET +* 响应格式: 根据配置返回不同的内容类型(默认、文本或HTML) -### 获取日志数据 +#### 获取日志数据 -* 接口地址: `/logs` -* 请求方法: POST -* 认证方式: Bearer Token -* 响应格式: +* 接口地址: `/logs` +* 请求方法: POST +* 认证方式: Bearer Token +* 响应格式: ```json { @@ -491,12 +602,12 @@ string } ``` -### 获取用户信息 +#### 获取用户信息 -* 接口地址: `/userinfo` -* 请求方法: POST -* 认证方式: 请求体中包含token -* 请求格式: +* 接口地址: `/userinfo` +* 请求方法: POST +* 认证方式: 请求体中包含token +* 请求格式: ```json { @@ -504,7 +615,7 @@ string } ``` -* 响应格式: +* 响应格式: ```json { @@ -553,12 +664,12 @@ string } ``` -### 基础校准 +#### 基础校准 -* 接口地址: `/basic-calibration` -* 请求方法: POST -* 认证方式: 请求体中包含token -* 请求格式: +* 接口地址: `/basic-calibration` +* 请求方法: POST +* 认证方式: 请求体中包含token +* 请求格式: ```json { @@ -566,7 +677,7 @@ string } ``` -* 响应格式: +* 响应格式: ```json { @@ -580,9 +691,15 @@ string 注意: `user_id`, `create_at`, 和 `checksum_time` 字段在校验失败时可能不存在。 +## 项目相关工具 + ### 获取token -- 使用 [get-token](https://github.com/wisdgod/cursor-api/tree/main/get-token) 获取读取当前设备token,仅支持windows与macos +- 使用 [get-token](https://github.com/wisdgod/cursor-api/tree/main/tools/get-token) 获取读取当前用户token,仅支持windows、linux与macos + +### 重置遥测数据 + +- 使用 [reset-telemetry](https://github.com/wisdgod/cursor-api/tree/main/tools/reset-telemetry) 重置当前用户遥测数据,仅支持windows、linux与macos ## 鸣谢 @@ -592,7 +709,7 @@ string - [zhx47/cursor-api](https://github.com/zhx47/cursor-api) - 提供了本项目起步阶段的主要参考 - [luolazyandlazy/cursorToApi](https://github.com/luolazyandlazy/cursorToApi) -## 偷偷写在最后的话 +### 偷偷写在最后的话 虽然作者觉得~骗~收点钱合理,但不强求,要是**主动自愿**发我我肯定收(因为真有人这么做,虽然不是赞助),赞助很合理吧 @@ -600,9 +717,9 @@ string 虽然不是很建议你赞助,但如果你赞助了,大概可以: -* 测试版更新 -* 要求功能 -* 问题更快解决 +* 测试版更新 +* 要求功能 +* 问题更快解决 即使如此,我也保留可以拒绝赞助和拒绝要求的权利。 diff --git a/build.rs b/build.rs index a6557a5..0af7697 100644 --- a/build.rs +++ b/build.rs @@ -1,13 +1,21 @@ +#[cfg(not(any(feature = "use-minified")))] use sha2::{Digest, Sha256}; +#[cfg(not(any(feature = "use-minified")))] use std::collections::HashMap; +#[cfg(not(any(feature = "use-minified")))] use std::fs; use std::io::Result; -use std::path::{Path, PathBuf}; +#[cfg(not(any(feature = "use-minified")))] +use std::path::Path; +use std::path::PathBuf; +#[cfg(not(any(feature = "use-minified")))] use std::process::Command; // 支持的文件类型 -const SUPPORTED_EXTENSIONS: [&str; 3] = ["html", "js", "css"]; +#[cfg(not(any(feature = "use-minified")))] +const SUPPORTED_EXTENSIONS: [&str; 4] = ["html", "js", "css", "md"]; +#[cfg(not(any(feature = "use-minified")))] fn check_and_install_deps() -> Result<()> { let scripts_dir = Path::new("scripts"); let node_modules = scripts_dir.join("node_modules"); @@ -27,10 +35,21 @@ fn check_and_install_deps() -> Result<()> { Ok(()) } +#[cfg(not(any(feature = "use-minified")))] fn get_files_hash() -> Result> { let mut file_hashes = HashMap::new(); let static_dir = Path::new("static"); + // 首先处理 README.md + let readme_path = Path::new("README.md"); + if readme_path.exists() { + let content = fs::read(readme_path)?; + let mut hasher = Sha256::new(); + hasher.update(&content); + let hash = format!("{:x}", hasher.finalize()); + file_hashes.insert(readme_path.to_path_buf(), hash); + } + if static_dir.exists() { for entry in fs::read_dir(static_dir)? { let entry = entry?; @@ -53,6 +72,7 @@ fn get_files_hash() -> Result> { Ok(file_hashes) } +#[cfg(not(any(feature = "use-minified")))] fn load_saved_hashes() -> Result> { let hash_file = Path::new("scripts/.asset-hashes.json"); if hash_file.exists() { @@ -67,6 +87,7 @@ fn load_saved_hashes() -> Result> { } } +#[cfg(not(any(feature = "use-minified")))] fn save_hashes(hashes: &HashMap) -> Result<()> { let hash_file = Path::new("scripts/.asset-hashes.json"); let string_map: HashMap = hashes @@ -78,6 +99,7 @@ fn save_hashes(hashes: &HashMap) -> Result<()> { Ok(()) } +#[cfg(not(any(feature = "use-minified")))] fn minify_assets() -> Result<()> { // 获取现有文件的哈希 let current_hashes = get_files_hash()?; @@ -94,14 +116,21 @@ fn minify_assets() -> Result<()> { let files_to_update: Vec<_> = current_hashes .iter() .filter(|(path, current_hash)| { + let is_readme = path.file_name().map_or(false, |f| f == "README.md"); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); - let min_path = path.with_file_name(format!( - "{}.min.{}", - path.file_stem().unwrap().to_string_lossy(), - ext - )); - // 检查压缩后的文件是否存在 + // 为 README.md 和其他文件使用不同的输出路径检查 + let min_path = if is_readme { + PathBuf::from("static/readme.min.html") + } else { + path.with_file_name(format!( + "{}.min.{}", + path.file_stem().unwrap().to_string_lossy(), + ext + )) + }; + + // 检查压缩/转换后的文件是否存在 if !min_path.exists() { return true; } @@ -140,26 +169,52 @@ fn minify_assets() -> Result<()> { fn main() -> Result<()> { // Proto 文件处理 println!("cargo:rerun-if-changed=src/chat/aiserver/v1/lite.proto"); + println!("cargo:rerun-if-changed=src/chat/config/key.proto"); + // 获取环境变量 PROTOC + let protoc_path = match std::env::var_os("PROTOC") { + Some(path) => PathBuf::from(path), + None => { + println!("cargo:warning=PROTOC environment variable not set, using default protoc."); + // 如果 PROTOC 未设置,则返回一个空的 PathBuf,prost-build 会尝试使用默认的 protoc + PathBuf::new() + } + }; let mut config = prost_build::Config::new(); + // 如果 protoc_path 不为空,则配置使用指定的 protoc + if !protoc_path.as_os_str().is_empty() { + config.protoc_executable(protoc_path); + } // config.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]"); - // config.type_attribute( - // "aiserver.v1.ThrowErrorCheckRequest", - // "#[derive(serde::Serialize, serde::Deserialize)]" - // ); config - .compile_protos(&["src/chat/aiserver/v1/lite.proto"], &["src/chat/aiserver/v1/"]) + .compile_protos( + &["src/chat/aiserver/v1/lite.proto"], + &["src/chat/aiserver/v1/"], + ) + .unwrap(); + config + .compile_protos(&["src/chat/config/key.proto"], &["src/chat/config/"]) .unwrap(); // 静态资源文件处理 println!("cargo:rerun-if-changed=scripts/minify.js"); println!("cargo:rerun-if-changed=scripts/package.json"); - println!("cargo:rerun-if-changed=static"); + println!("cargo:rerun-if-changed=static/api.html"); + println!("cargo:rerun-if-changed=static/build_key.html"); + println!("cargo:rerun-if-changed=static/config.html"); + println!("cargo:rerun-if-changed=static/logs.html"); + println!("cargo:rerun-if-changed=static/shared-styles.css"); + println!("cargo:rerun-if-changed=static/shared.js"); + println!("cargo:rerun-if-changed=static/tokens.html"); + println!("cargo:rerun-if-changed=README.md"); - // 检查并安装依赖 - check_and_install_deps()?; + #[cfg(not(any(feature = "use-minified")))] + { + // 检查并安装依赖 + check_and_install_deps()?; - // 运行资源压缩 - minify_assets()?; + // 运行资源压缩 + minify_assets()?; + } Ok(()) } diff --git a/scripts/minify.js b/scripts/minify.js index e4c1ed6..a706147 100644 --- a/scripts/minify.js +++ b/scripts/minify.js @@ -3,6 +3,7 @@ const { minify: minifyHtml } = require('html-minifier-terser'); const { minify: minifyJs } = require('terser'); const CleanCSS = require('clean-css'); +const MarkdownIt = require('markdown-it'); const fs = require('fs'); const path = require('path'); @@ -28,10 +29,75 @@ const cssOptions = { // 处理文件 async function minifyFile(inputPath, outputPath) { try { - const ext = path.extname(inputPath).toLowerCase(); - const content = fs.readFileSync(inputPath, 'utf8'); + let ext = path.extname(inputPath).toLowerCase(); + if (ext === '.md') ext = '.html'; + const filename = path.basename(inputPath); + let content = fs.readFileSync(inputPath, 'utf8'); let minified; + // 特殊处理 readme.html + if (filename.toLowerCase() === 'readme.md') { + const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true + }); + const readmeMdPath = path.join(__dirname, '..', 'README.md'); + const markdownContent = fs.readFileSync(readmeMdPath, 'utf8'); + // 添加基本的 markdown 样式 + const htmlContent = ` + + + + + + README + + + + ${md.render(markdownContent)} + + + `; + content = htmlContent; + } + switch (ext) { case '.html': minified = await minifyHtml(content, options); @@ -68,12 +134,22 @@ async function main() { const staticDir = path.join(__dirname, '..', 'static'); for (const file of files) { - const inputPath = path.join(staticDir, file); - const ext = path.extname(file); - const outputPath = path.join( - staticDir, - file.replace(ext, `.min${ext}`) - ); + // 特殊处理 README.md 的输入路径 + let inputPath; + let outputPath; + + if (file.toLowerCase() === 'readme.md') { + inputPath = path.join(__dirname, '..', 'README.md'); + outputPath = path.join(staticDir, 'readme.min.html'); + } else { + inputPath = path.join(staticDir, file); + const ext = path.extname(file); + outputPath = path.join( + staticDir, + file.replace(ext, `.min${ext}`) + ); + } + await minifyFile(inputPath, outputPath); } } diff --git a/scripts/package-lock.json b/scripts/package-lock.json index 82715b0..4b0405b 100644 --- a/scripts/package-lock.json +++ b/scripts/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "clean-css": "^5.3.3", "html-minifier-terser": "^7.2.0", + "markdown-it": "^14.1.0", "terser": "^5.37.0" }, "engines": { @@ -86,6 +87,12 @@ "node": ">=0.4.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -166,6 +173,15 @@ "node": "^14.13.1 || >=16.0.0" } }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -175,6 +191,29 @@ "tslib": "^2.0.3" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -205,6 +244,15 @@ "tslib": "^2.0.3" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -262,6 +310,12 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" } } } diff --git a/scripts/package.json b/scripts/package.json index c1c08da..b12a162 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,6 +8,7 @@ "dependencies": { "clean-css": "^5.3.3", "html-minifier-terser": "^7.2.0", + "markdown-it": "^14.1.0", "terser": "^5.37.0" } } diff --git a/src/app/config.rs b/src/app/config.rs index f990f96..c9a2bfb 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -1,9 +1,5 @@ -use super::{ - constant::AUTHORIZATION_BEARER_PREFIX, - lazy::AUTH_TOKEN, - model::AppConfig, -}; -use crate::common::models::{ +use super::{constant::AUTHORIZATION_BEARER_PREFIX, lazy::AUTH_TOKEN, model::AppConfig}; +use crate::common::model::{ config::{ConfigData, ConfigUpdateRequest}, ApiStatus, ErrorResponse, NormalResponse, }; @@ -13,40 +9,24 @@ use axum::{ }; // 定义处理更新操作的宏 -macro_rules! handle_update { - ($request:expr, $field:ident, $update_fn:expr, $field_name:expr) => { - if let Some($field) = $request.$field { - if let Err(e) = $update_fn($field) { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - status: ApiStatus::Failed, - code: Some(500), - error: Some(format!("更新 {} 失败: {}", $field_name, e)), - message: None, - }), - )); +macro_rules! handle_updates { + ($request:expr, $($field:ident => $update_fn:expr),* $(,)?) => { + $( + if let Some(value) = $request.$field { + $update_fn(value); } - } + )* }; } // 定义处理重置操作的宏 -macro_rules! handle_reset { - ($request:expr, $field:ident, $reset_fn:expr, $field_name:expr) => { - if $request.$field.is_some() { - if let Err(e) = $reset_fn() { - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - status: ApiStatus::Failed, - code: Some(500), - error: Some(format!("重置 {} 失败: {}", $field_name, e)), - message: None, - }), - )); +macro_rules! handle_resets { + ($request:expr, $($field:ident => $reset_fn:expr),* $(,)?) => { + $( + if $request.$field.is_some() { + $reset_fn(); } - } + )* }; } @@ -90,7 +70,10 @@ pub async fn handle_config_update( vision_ability: AppConfig::get_vision_ability(), enable_slow_pool: AppConfig::get_slow_pool(), enable_all_claude: AppConfig::get_allow_claude(), - check_usage_models: AppConfig::get_usage_check(), + usage_check_models: AppConfig::get_usage_check(), + enable_dynamic_key: AppConfig::get_dynamic_key(), + share_token: AppConfig::get_share_token(), + proxies: AppConfig::get_proxies(), }), message: None, })), @@ -112,41 +95,16 @@ pub async fn handle_config_update( } } - handle_update!( - request, - enable_stream_check, - AppConfig::update_stream_check, - "enable_stream_check" - ); - handle_update!( - request, - include_stop_stream, - AppConfig::update_stop_stream, - "include_stop_stream" - ); - handle_update!( - request, - vision_ability, - AppConfig::update_vision_ability, - "vision_ability" - ); - handle_update!( - request, - enable_slow_pool, - AppConfig::update_slow_pool, - "enable_slow_pool" - ); - handle_update!( - request, - enable_all_claude, - AppConfig::update_allow_claude, - "enable_all_claude" - ); - handle_update!( - request, - check_usage_models, - AppConfig::update_usage_check, - "check_usage_models" + handle_updates!(request, + enable_stream_check => AppConfig::update_stream_check, + include_stop_stream => AppConfig::update_stop_stream, + vision_ability => AppConfig::update_vision_ability, + enable_slow_pool => AppConfig::update_slow_pool, + enable_all_claude => AppConfig::update_allow_claude, + usage_check_models => AppConfig::update_usage_check, + enable_dynamic_key => AppConfig::update_dynamic_key, + share_token => AppConfig::update_share_token, + proxies => AppConfig::update_proxies, ); Ok(Json(NormalResponse { @@ -172,41 +130,16 @@ pub async fn handle_config_update( } } - handle_reset!( - request, - enable_stream_check, - AppConfig::reset_stream_check, - "enable_stream_check" - ); - handle_reset!( - request, - include_stop_stream, - AppConfig::reset_stop_stream, - "include_stop_stream" - ); - handle_reset!( - request, - vision_ability, - AppConfig::reset_vision_ability, - "vision_ability" - ); - handle_reset!( - request, - enable_slow_pool, - AppConfig::reset_slow_pool, - "enable_slow_pool" - ); - handle_reset!( - request, - enable_all_claude, - AppConfig::reset_allow_claude, - "enable_all_claude" - ); - handle_reset!( - request, - check_usage_models, - AppConfig::reset_usage_check, - "check_usage_models" + handle_resets!(request, + enable_stream_check => AppConfig::reset_stream_check, + include_stop_stream => AppConfig::reset_stop_stream, + vision_ability => AppConfig::reset_vision_ability, + enable_slow_pool => AppConfig::reset_slow_pool, + enable_all_claude => AppConfig::reset_allow_claude, + usage_check_models => AppConfig::reset_usage_check, + enable_dynamic_key => AppConfig::reset_dynamic_key, + share_token => AppConfig::reset_share_token, + proxies => AppConfig::reset_proxies, ); Ok(Json(NormalResponse { diff --git a/src/app/constant.rs b/src/app/constant.rs index 3ee795b..7b3d3db 100644 --- a/src/app/constant.rs +++ b/src/app/constant.rs @@ -4,6 +4,8 @@ macro_rules! def_pub_const { }; } +pub const COMMA: char = ','; + def_pub_const!(PKG_VERSION, env!("CARGO_PKG_VERSION")); // def_pub_const!(PKG_NAME, env!("CARGO_PKG_NAME")); // def_pub_const!(PKG_DESCRIPTION, env!("CARGO_PKG_DESCRIPTION")); @@ -12,6 +14,8 @@ def_pub_const!(PKG_VERSION, env!("CARGO_PKG_VERSION")); def_pub_const!(EMPTY_STRING, ""); +def_pub_const!(COMMA_STRING, ","); + def_pub_const!(ROUTE_ROOT_PATH, "/"); def_pub_const!(ROUTE_HEALTH_PATH, "/health"); def_pub_const!(ROUTE_GET_HASH, "/get-hash"); @@ -21,19 +25,22 @@ def_pub_const!(ROUTE_USER_INFO_PATH, "/userinfo"); def_pub_const!(ROUTE_API_PATH, "/api"); def_pub_const!(ROUTE_LOGS_PATH, "/logs"); def_pub_const!(ROUTE_CONFIG_PATH, "/config"); -def_pub_const!(ROUTE_TOKENINFO_PATH, "/tokeninfo"); -def_pub_const!(ROUTE_GET_TOKENINFO_PATH, "/get-tokeninfo"); -def_pub_const!(ROUTE_UPDATE_TOKENINFO_PATH, "/update-tokeninfo"); +def_pub_const!(ROUTE_TOKENS_PATH, "/tokens"); +def_pub_const!(ROUTE_TOKENS_GET_PATH, "/tokens/get"); +def_pub_const!(ROUTE_TOKENS_RELOAD_PATH, "/tokens/reload"); +def_pub_const!(ROUTE_TOKENS_UPDATE_PATH, "/tokens/update"); +def_pub_const!(ROUTE_TOKENS_ADD_PATH, "/tokens/add"); +def_pub_const!(ROUTE_TOKENS_DELETE_PATH, "/tokens/delete"); def_pub_const!(ROUTE_ENV_EXAMPLE_PATH, "/env-example"); -def_pub_const!(ROUTE_STATIC_PATH, "/static/:path"); +def_pub_const!(ROUTE_STATIC_PATH, "/static/{path}"); def_pub_const!(ROUTE_SHARED_STYLES_PATH, "/static/shared-styles.css"); def_pub_const!(ROUTE_SHARED_JS_PATH, "/static/shared.js"); def_pub_const!(ROUTE_ABOUT_PATH, "/about"); def_pub_const!(ROUTE_README_PATH, "/readme"); def_pub_const!(ROUTE_BASIC_CALIBRATION_PATH, "/basic-calibration"); +def_pub_const!(ROUTE_BUILD_KEY_PATH, "/build-key"); -def_pub_const!(DEFAULT_TOKEN_FILE_NAME, ".token"); -def_pub_const!(DEFAULT_TOKEN_LIST_FILE_NAME, ".token-list"); +def_pub_const!(DEFAULT_TOKEN_LIST_FILE_NAME, ".tokens"); def_pub_const!(STATUS_PENDING, "pending"); def_pub_const!(STATUS_SUCCESS, "success"); @@ -47,9 +54,15 @@ def_pub_const!(FALSE, "false"); // def_pub_const!(CONTENT_TYPE_PROTO, "application/proto"); def_pub_const!(CONTENT_TYPE_CONNECT_PROTO, "application/connect+proto"); def_pub_const!(CONTENT_TYPE_TEXT_HTML_WITH_UTF8, "text/html;charset=utf-8"); -def_pub_const!(CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8, "text/plain;charset=utf-8"); +def_pub_const!( + CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8, + "text/plain;charset=utf-8" +); def_pub_const!(CONTENT_TYPE_TEXT_CSS_WITH_UTF8, "text/css;charset=utf-8"); -def_pub_const!(CONTENT_TYPE_TEXT_JS_WITH_UTF8, "text/javascript;charset=utf-8"); +def_pub_const!( + CONTENT_TYPE_TEXT_JS_WITH_UTF8, + "text/javascript;charset=utf-8" +); def_pub_const!(AUTHORIZATION_BEARER_PREFIX, "Bearer "); @@ -65,8 +78,6 @@ def_pub_const!(OBJECT_CHAT_COMPLETION_CHUNK, "chat.completion.chunk"); def_pub_const!(FINISH_REASON_STOP, "stop"); -def_pub_const!(ERR_UPDATE_CONFIG, "无法更新配置"); -def_pub_const!(ERR_RESET_CONFIG, "无法重置配置"); def_pub_const!(ERR_INVALID_PATH, "无效的路径"); // def_pub_const!(ERR_CHECKSUM_NO_GOOD, "checksum no good"); diff --git a/src/app/lazy.rs b/src/app/lazy.rs index c3cb7bf..e98a851 100644 --- a/src/app/lazy.rs +++ b/src/app/lazy.rs @@ -1,11 +1,11 @@ -use crate::{ - app::constant::{ - CURSOR_API2_HOST, CURSOR_HOST, DEFAULT_TOKEN_FILE_NAME, DEFAULT_TOKEN_LIST_FILE_NAME, - EMPTY_STRING, - }, - common::utils::{parse_ascii_char_from_env, parse_string_from_env}, +use super::constant::{ + COMMA, CURSOR_API2_HOST, CURSOR_HOST, DEFAULT_TOKEN_LIST_FILE_NAME, EMPTY_STRING, +}; +use crate::common::utils::{ + parse_ascii_char_from_env, parse_bool_from_env, parse_string_from_env, parse_usize_from_env, }; use std::sync::LazyLock; +use tokio::sync::{Mutex, OnceCell}; macro_rules! def_pub_static { // 基础版本:直接存储 String @@ -32,7 +32,6 @@ macro_rules! def_pub_static { def_pub_static!(ROUTE_PREFIX, env: "ROUTE_PREFIX", default: EMPTY_STRING); def_pub_static!(AUTH_TOKEN, env: "AUTH_TOKEN", default: EMPTY_STRING); -def_pub_static!(TOKEN_FILE, env: "TOKEN_FILE", default: DEFAULT_TOKEN_FILE_NAME); def_pub_static!(TOKEN_LIST_FILE, env: "TOKEN_LIST_FILE", default: DEFAULT_TOKEN_LIST_FILE_NAME); def_pub_static!(ROUTE_MODELS_PATH, format!("{}/v1/models", *ROUTE_PREFIX)); def_pub_static!( @@ -51,70 +50,128 @@ def_pub_static!(DEFAULT_INSTRUCTIONS, env: "DEFAULT_INSTRUCTIONS", default: "Res def_pub_static!(REVERSE_PROXY_HOST, env: "REVERSE_PROXY_HOST", default: EMPTY_STRING); -def_pub_static!(SHARED_AUTH_TOKEN, env: "SHARED_AUTH_TOKEN", default: EMPTY_STRING); +const DEFAULT_KEY_PREFIX: &str = "sk-"; -pub static USE_SHARE: LazyLock = LazyLock::new(|| !SHARED_AUTH_TOKEN.is_empty()); +pub static KEY_PREFIX: LazyLock = LazyLock::new(|| { + let value = parse_string_from_env("KEY_PREFIX", DEFAULT_KEY_PREFIX) + .trim() + .to_string(); + if value.is_empty() { + DEFAULT_KEY_PREFIX.to_string() + } else { + value + } +}); + +pub static KEY_PREFIX_LEN: LazyLock = LazyLock::new(|| KEY_PREFIX.len()); pub static TOKEN_DELIMITER: LazyLock = LazyLock::new(|| { - let delimiter = parse_ascii_char_from_env("TOKEN_DELIMITER", ','); + let delimiter = parse_ascii_char_from_env("TOKEN_DELIMITER", COMMA); if delimiter.is_ascii_alphabetic() || delimiter.is_ascii_digit() || delimiter == '+' || delimiter == '/' { - ',' + COMMA } else { delimiter } }); -pub static TOKEN_DELIMITER_LEN: LazyLock = LazyLock::new(|| TOKEN_DELIMITER.len_utf8()); - -pub static USE_PROXY: LazyLock = LazyLock::new(|| !REVERSE_PROXY_HOST.is_empty()); - -pub static CURSOR_API2_CHAT_URL: LazyLock = LazyLock::new(|| { - let host = if *USE_PROXY { - &*REVERSE_PROXY_HOST +pub static USE_COMMA_DELIMITER: LazyLock = LazyLock::new(|| { + let enable = parse_bool_from_env("USE_COMMA_DELIMITER", true); + if enable && *TOKEN_DELIMITER == COMMA { + false } else { - CURSOR_API2_HOST - }; - format!("https://{}/aiserver.v1.AiService/StreamChat", host) + enable + } }); -pub static CURSOR_API2_STRIPE_URL: LazyLock = LazyLock::new(|| { - let host = if *USE_PROXY { - &*REVERSE_PROXY_HOST - } else { - CURSOR_API2_HOST +pub static USE_REVERSE_PROXY: LazyLock = LazyLock::new(|| !REVERSE_PROXY_HOST.is_empty()); + +macro_rules! def_cursor_api_url { + ($name:ident, $api_host:expr, $path:expr) => { + pub static $name: LazyLock = LazyLock::new(|| { + let host = if *USE_REVERSE_PROXY { + &*REVERSE_PROXY_HOST + } else { + $api_host + }; + format!("https://{}{}", host, $path) + }); }; - format!("https://{}/auth/full_stripe_profile", host) -}); +} -pub static CURSOR_USAGE_API_URL: LazyLock = LazyLock::new(|| { - let host = if *USE_PROXY { - &*REVERSE_PROXY_HOST - } else { - CURSOR_HOST +def_cursor_api_url!( + CURSOR_API2_CHAT_URL, + CURSOR_API2_HOST, + "/aiserver.v1.AiService/StreamChat" +); + +def_cursor_api_url!( + CURSOR_API2_STRIPE_URL, + CURSOR_API2_HOST, + "/auth/full_stripe_profile" +); + +def_cursor_api_url!(CURSOR_USAGE_API_URL, CURSOR_HOST, "/api/usage"); + +def_cursor_api_url!(CURSOR_USER_API_URL, CURSOR_HOST, "/api/auth/me"); + +pub static DEBUG: LazyLock = LazyLock::new(|| parse_bool_from_env("DEBUG", false)); + +// 使用环境变量 "DEBUG_LOG_FILE" 来指定日志文件路径,默认值为 "debug.log" +static DEBUG_LOG_FILE: LazyLock = + LazyLock::new(|| parse_string_from_env("DEBUG_LOG_FILE", "debug.log")); + +// 使用 OnceCell 结合 Mutex 来异步初始化 LOG_FILE +static LOG_FILE: OnceCell> = OnceCell::const_new(); + +pub(crate) async fn get_log_file() -> &'static Mutex { + LOG_FILE + .get_or_init(|| async { + Mutex::new( + tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&*DEBUG_LOG_FILE) + .await + .expect("无法打开日志文件"), + ) + }) + .await +} + +#[macro_export] +macro_rules! debug_println { + ($($arg:tt)*) => { + if *crate::app::lazy::DEBUG { + let time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let log_message = format!("{} - {}", time, format!($($arg)*)); + use tokio::io::AsyncWriteExt as _; + + // 使用 tokio 的 spawn 在后台异步写入日志 + tokio::spawn(async move { + let log_file = crate::app::lazy::get_log_file().await; + // 使用 MutexGuard 获取可变引用 + let mut file = log_file.lock().await; + if let Err(err) = file.write_all(log_message.as_bytes()).await { + eprintln!("写入日志文件失败: {}", err); + } + if let Err(err) = file.write_all(b"\n").await { + eprintln!("写入换行符失败: {}", err); + } + // 可以选择在写入失败时 panic,或者忽略 + // panic!("写入日志文件失败: {}", err); + }); + } }; - format!("https://{}/api/usage", host) +} + +pub static REQUEST_LOGS_LIMIT: LazyLock = + LazyLock::new(|| std::cmp::min(parse_usize_from_env("REQUEST_LOGS_LIMIT", 100), 2000)); + +pub static SERVICE_TIMEOUT: LazyLock = LazyLock::new(|| { + let timeout = parse_usize_from_env("SERVICE_TIMEOUT", 30); + u64::try_from(timeout).map(|t| t.min(600)).unwrap_or(30) }); - -pub static CURSOR_USER_API_URL: LazyLock = LazyLock::new(|| { - let host = if *USE_PROXY { - &*REVERSE_PROXY_HOST - } else { - CURSOR_HOST - }; - format!("https://{}/api/auth/me", host) -}); - -// pub static DEBUG: LazyLock = LazyLock::new(|| parse_bool_from_env("DEBUG", false)); - -// #[macro_export] -// macro_rules! debug_println { -// ($($arg:tt)*) => { -// if *crate::app::statics::DEBUG { -// println!($($arg)*); -// } -// }; -// } diff --git a/src/app/model.rs b/src/app/model.rs index ef82e60..2fbc88e 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -1,14 +1,26 @@ use crate::{ app::constant::{ - ERR_INVALID_PATH, ERR_RESET_CONFIG, ERR_UPDATE_CONFIG, ROUTE_ABOUT_PATH, ROUTE_CONFIG_PATH, - ROUTE_LOGS_PATH, ROUTE_README_PATH, ROUTE_ROOT_PATH, ROUTE_SHARED_JS_PATH, - ROUTE_SHARED_STYLES_PATH, ROUTE_TOKENINFO_PATH, ROUTE_API_PATH, + EMPTY_STRING, ERR_INVALID_PATH, ROUTE_ABOUT_PATH, ROUTE_API_PATH, ROUTE_BUILD_KEY_PATH, + ROUTE_CONFIG_PATH, ROUTE_LOGS_PATH, ROUTE_README_PATH, ROUTE_ROOT_PATH, + ROUTE_SHARED_JS_PATH, ROUTE_SHARED_STYLES_PATH, ROUTE_TOKENS_PATH, + }, + chat::model::Message, + common::{ + client::rebuild_http_client, + model::{userinfo::TokenProfile, ApiStatus}, + utils::{generate_checksum_with_repair, parse_bool_from_env, parse_string_from_env}, }, - common::models::userinfo::TokenProfile, }; -use crate::chat::model::Message; -use std::sync::{LazyLock, RwLock}; +use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; + +mod usage_check; +pub use usage_check::UsageCheck; +mod proxies; +pub use proxies::Proxies; +mod build_key; +pub use build_key::*; // 页面内容类型枚举 #[derive(Clone, Serialize, Deserialize)] @@ -28,9 +40,6 @@ impl Default for PageContent { } } -mod usage_check; -pub use usage_check::UsageCheck; - // 静态配置 #[derive(Clone)] pub struct AppConfig { @@ -41,9 +50,13 @@ pub struct AppConfig { allow_claude: bool, pages: Pages, usage_check: UsageCheck, + dynamic_key: bool, + share_token: String, + is_share: bool, + proxies: Proxies, } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, PartialEq)] pub enum VisionAbility { #[serde(rename = "none", alias = "disabled")] None, @@ -62,6 +75,10 @@ impl VisionAbility { _ => Self::default(), } } + + pub fn is_none(&self) -> bool { + matches!(self, VisionAbility::None) + } } impl Default for VisionAbility { @@ -81,6 +98,7 @@ pub struct Pages { pub about_content: PageContent, pub readme_content: PageContent, pub api_content: PageContent, + pub build_key_content: PageContent, } // 运行时状态 @@ -93,9 +111,8 @@ pub struct AppState { } // 全局配置实例 -pub static APP_CONFIG: LazyLock> = LazyLock::new(|| { - RwLock::new(AppConfig::default()) -}); +pub static APP_CONFIG: LazyLock> = + LazyLock::new(|| RwLock::new(AppConfig::default())); impl Default for AppConfig { fn default() -> Self { @@ -107,6 +124,10 @@ impl Default for AppConfig { allow_claude: false, pages: Pages::default(), usage_check: UsageCheck::Default, + dynamic_key: false, + share_token: String::default(), + is_share: false, + proxies: Proxies::default(), } } } @@ -115,28 +136,67 @@ macro_rules! config_methods { ($($field:ident: $type:ty, $default:expr;)*) => { $( paste::paste! { - pub fn []() -> $type { - APP_CONFIG - .read() - .map(|config| config.$field.clone()) - .unwrap_or($default) + pub fn []() -> $type + where + $type: Copy + PartialEq, + { + APP_CONFIG.read().$field } - pub fn [](value: $type) -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - config.$field = value; - Ok(()) - } else { - Err(ERR_UPDATE_CONFIG) + pub fn [](value: $type) + where + $type: Copy + PartialEq, + { + let current = Self::[](); + if current != value { + APP_CONFIG.write().$field = value; } } - pub fn []() -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - config.$field = $default; - Ok(()) - } else { - Err(ERR_RESET_CONFIG) + pub fn []() + where + $type: Copy + PartialEq, + { + let default_value = $default; + let current = Self::[](); + if current != default_value { + APP_CONFIG.write().$field = default_value; + } + } + } + )* + }; +} + +macro_rules! config_methods_clone { + ($($field:ident: $type:ty, $default:expr;)*) => { + $( + paste::paste! { + pub fn []() -> $type + where + $type: Clone + PartialEq, + { + APP_CONFIG.read().$field.clone() + } + + pub fn [](value: $type) + where + $type: Clone + PartialEq, + { + let current = Self::[](); + if current != value { + APP_CONFIG.write().$field = value; + } + } + + pub fn []() + where + $type: Clone + PartialEq, + { + let default_value = $default; + let current = Self::[](); + if current != default_value { + APP_CONFIG.write().$field = default_value; } } } @@ -145,19 +205,22 @@ macro_rules! config_methods { } impl AppConfig { - pub fn init( - stream_check: bool, - stop_stream: bool, - vision_ability: VisionAbility, - slow_pool: bool, - allow_claude: bool, - ) { - if let Ok(mut config) = APP_CONFIG.write() { - config.stream_check = stream_check; - config.stop_stream = stop_stream; - config.vision_ability = vision_ability; - config.slow_pool = slow_pool; - config.allow_claude = allow_claude; + pub fn init() { + let mut config = APP_CONFIG.write(); + config.stream_check = parse_bool_from_env("ENABLE_STREAM_CHECK", true); + config.stop_stream = parse_bool_from_env("INCLUDE_STOP_REASON_STREAM", true); + config.vision_ability = + VisionAbility::from_str(&parse_string_from_env("VISION_ABILITY", EMPTY_STRING)); + config.slow_pool = parse_bool_from_env("ENABLE_SLOW_POOL", false); + config.allow_claude = parse_bool_from_env("PASS_ANY_CLAUDE", false); + config.usage_check = + UsageCheck::from_str(&parse_string_from_env("USAGE_CHECK", EMPTY_STRING)); + config.dynamic_key = parse_bool_from_env("DYNAMIC_KEY", false); + config.share_token = parse_string_from_env("SHARED_TOKEN", EMPTY_STRING); + config.is_share = !config.share_token.is_empty(); + config.proxies = match std::env::var("PROXIES") { + Ok(proxies) => Proxies::from_str(proxies.as_str()), + Err(_) => Proxies::default(), } } @@ -166,113 +229,113 @@ impl AppConfig { stop_stream: bool, true; slow_pool: bool, false; allow_claude: bool, false; + dynamic_key: bool, false; } - pub fn get_vision_ability() -> VisionAbility { - APP_CONFIG - .read() - .map(|config| config.vision_ability.clone()) - .unwrap_or_default() + config_methods_clone! { + vision_ability: VisionAbility, VisionAbility::default(); + usage_check: UsageCheck, UsageCheck::default(); + } + + pub fn get_share_token() -> String { + APP_CONFIG.read().share_token.clone() + } + + pub fn update_share_token(value: String) { + let current = Self::get_share_token(); + if current != value { + let mut config = APP_CONFIG.write(); + config.share_token = value; + config.is_share = !config.share_token.is_empty(); + } + } + + pub fn reset_share_token() { + let current = Self::get_share_token(); + if !current.is_empty() { + let mut config = APP_CONFIG.write(); + config.share_token = String::new(); + config.is_share = false; + } + } + + pub fn get_proxies() -> Proxies { + APP_CONFIG.read().proxies.clone() + } + + pub fn update_proxies(value: Proxies) { + let current = Self::get_proxies(); + if current != value { + let mut config = APP_CONFIG.write(); + config.proxies = value; + rebuild_http_client(); + } + } + + pub fn reset_proxies() { + let default_value = Proxies::default(); + let current = Self::get_proxies(); + if current != default_value { + let mut config = APP_CONFIG.write(); + config.proxies = default_value; + rebuild_http_client(); + } } pub fn get_page_content(path: &str) -> Option { - APP_CONFIG.read().ok().map(|config| match path { - ROUTE_ROOT_PATH => config.pages.root_content.clone(), - ROUTE_LOGS_PATH => config.pages.logs_content.clone(), - ROUTE_CONFIG_PATH => config.pages.config_content.clone(), - ROUTE_TOKENINFO_PATH => config.pages.tokeninfo_content.clone(), - ROUTE_SHARED_STYLES_PATH => config.pages.shared_styles_content.clone(), - ROUTE_SHARED_JS_PATH => config.pages.shared_js_content.clone(), - ROUTE_ABOUT_PATH => config.pages.about_content.clone(), - ROUTE_README_PATH => config.pages.readme_content.clone(), - ROUTE_API_PATH => config.pages.api_content.clone(), - _ => PageContent::default(), - }) - } - - pub fn get_usage_check() -> UsageCheck { - APP_CONFIG - .read() - .map(|config| config.usage_check.clone()) - .unwrap_or_default() - } - - pub fn update_vision_ability(new_ability: VisionAbility) -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - config.vision_ability = new_ability; - Ok(()) - } else { - Err(ERR_UPDATE_CONFIG) + match path { + ROUTE_ROOT_PATH => Some(APP_CONFIG.read().pages.root_content.clone()), + ROUTE_LOGS_PATH => Some(APP_CONFIG.read().pages.logs_content.clone()), + ROUTE_CONFIG_PATH => Some(APP_CONFIG.read().pages.config_content.clone()), + ROUTE_TOKENS_PATH => Some(APP_CONFIG.read().pages.tokeninfo_content.clone()), + ROUTE_SHARED_STYLES_PATH => Some(APP_CONFIG.read().pages.shared_styles_content.clone()), + ROUTE_SHARED_JS_PATH => Some(APP_CONFIG.read().pages.shared_js_content.clone()), + ROUTE_ABOUT_PATH => Some(APP_CONFIG.read().pages.about_content.clone()), + ROUTE_README_PATH => Some(APP_CONFIG.read().pages.readme_content.clone()), + ROUTE_API_PATH => Some(APP_CONFIG.read().pages.api_content.clone()), + ROUTE_BUILD_KEY_PATH => Some(APP_CONFIG.read().pages.build_key_content.clone()), + _ => None, } } pub fn update_page_content(path: &str, content: PageContent) -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - match path { - ROUTE_ROOT_PATH => config.pages.root_content = content, - ROUTE_LOGS_PATH => config.pages.logs_content = content, - ROUTE_CONFIG_PATH => config.pages.config_content = content, - ROUTE_TOKENINFO_PATH => config.pages.tokeninfo_content = content, - ROUTE_SHARED_STYLES_PATH => config.pages.shared_styles_content = content, - ROUTE_SHARED_JS_PATH => config.pages.shared_js_content = content, - ROUTE_ABOUT_PATH => config.pages.about_content = content, - ROUTE_README_PATH => config.pages.readme_content = content, - ROUTE_API_PATH => config.pages.api_content = content, - _ => return Err(ERR_INVALID_PATH), - } - Ok(()) - } else { - Err(ERR_UPDATE_CONFIG) - } - } - - pub fn update_usage_check(rule: UsageCheck) -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - config.usage_check = rule; - Ok(()) - } else { - Err(ERR_UPDATE_CONFIG) - } - } - - pub fn reset_vision_ability() -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - config.vision_ability = VisionAbility::Base64; - Ok(()) - } else { - Err(ERR_RESET_CONFIG) + let mut config = APP_CONFIG.write(); + match path { + ROUTE_ROOT_PATH => config.pages.root_content = content, + ROUTE_LOGS_PATH => config.pages.logs_content = content, + ROUTE_CONFIG_PATH => config.pages.config_content = content, + ROUTE_TOKENS_PATH => config.pages.tokeninfo_content = content, + ROUTE_SHARED_STYLES_PATH => config.pages.shared_styles_content = content, + ROUTE_SHARED_JS_PATH => config.pages.shared_js_content = content, + ROUTE_ABOUT_PATH => config.pages.about_content = content, + ROUTE_README_PATH => config.pages.readme_content = content, + ROUTE_API_PATH => config.pages.api_content = content, + ROUTE_BUILD_KEY_PATH => config.pages.build_key_content = content, + _ => return Err(ERR_INVALID_PATH), } + Ok(()) } pub fn reset_page_content(path: &str) -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - match path { - ROUTE_ROOT_PATH => config.pages.root_content = PageContent::default(), - ROUTE_LOGS_PATH => config.pages.logs_content = PageContent::default(), - ROUTE_CONFIG_PATH => config.pages.config_content = PageContent::default(), - ROUTE_TOKENINFO_PATH => config.pages.tokeninfo_content = PageContent::default(), - ROUTE_SHARED_STYLES_PATH => { - config.pages.shared_styles_content = PageContent::default() - } - ROUTE_SHARED_JS_PATH => config.pages.shared_js_content = PageContent::default(), - ROUTE_ABOUT_PATH => config.pages.about_content = PageContent::default(), - ROUTE_README_PATH => config.pages.readme_content = PageContent::default(), - ROUTE_API_PATH => config.pages.api_content = PageContent::default(), - _ => return Err(ERR_INVALID_PATH), - } - Ok(()) - } else { - Err(ERR_RESET_CONFIG) + let mut config = APP_CONFIG.write(); + match path { + ROUTE_ROOT_PATH => config.pages.root_content = PageContent::default(), + ROUTE_LOGS_PATH => config.pages.logs_content = PageContent::default(), + ROUTE_CONFIG_PATH => config.pages.config_content = PageContent::default(), + ROUTE_TOKENS_PATH => config.pages.tokeninfo_content = PageContent::default(), + ROUTE_SHARED_STYLES_PATH => config.pages.shared_styles_content = PageContent::default(), + ROUTE_SHARED_JS_PATH => config.pages.shared_js_content = PageContent::default(), + ROUTE_ABOUT_PATH => config.pages.about_content = PageContent::default(), + ROUTE_README_PATH => config.pages.readme_content = PageContent::default(), + ROUTE_API_PATH => config.pages.api_content = PageContent::default(), + ROUTE_BUILD_KEY_PATH => config.pages.build_key_content = PageContent::default(), + _ => return Err(ERR_INVALID_PATH), } + Ok(()) } - pub fn reset_usage_check() -> Result<(), &'static str> { - if let Ok(mut config) = APP_CONFIG.write() { - config.usage_check = UsageCheck::default(); - Ok(()) - } else { - Err(ERR_RESET_CONFIG) - } + pub fn is_share() -> bool { + APP_CONFIG.read().is_share } } @@ -286,6 +349,12 @@ impl AppState { token_infos, } } + + pub fn update_checksum(&mut self) { + for token_info in self.token_infos.iter_mut() { + token_info.checksum = generate_checksum_with_repair(&token_info.checksum); + } + } } // 请求日志 @@ -306,9 +375,9 @@ pub struct RequestLog { #[derive(Serialize, Clone)] pub struct TimingInfo { - pub total: f64, // 总用时(秒) + pub total: f64, // 总用时(秒) #[serde(skip_serializing_if = "Option::is_none")] - pub first: Option, // 首字时间(秒) + pub first: Option, // 首字时间(秒) } // 聊天请求 @@ -333,6 +402,58 @@ pub struct TokenInfo { #[derive(Deserialize)] pub struct TokenUpdateRequest { pub tokens: String, - #[serde(default)] - pub token_list: Option, +} + +#[derive(Deserialize)] +pub struct TokenAddRequestTokenInfo { + pub token: String, + #[serde(default)] + pub checksum: Option, +} + +// TokensDeleteRequest 结构体 +#[derive(Deserialize)] +pub struct TokensDeleteRequest { + #[serde(default)] + pub tokens: Vec, + #[serde(default)] + pub expectation: TokensDeleteResponseExpectation, +} + +#[derive(Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TokensDeleteResponseExpectation { + #[default] + Simple, + UpdatedTokens, + FailedTokens, + Detailed, +} + +impl TokensDeleteResponseExpectation { + pub fn needs_updated_tokens(&self) -> bool { + matches!( + self, + TokensDeleteResponseExpectation::UpdatedTokens + | TokensDeleteResponseExpectation::Detailed + ) + } + + pub fn needs_failed_tokens(&self) -> bool { + matches!( + self, + TokensDeleteResponseExpectation::FailedTokens + | TokensDeleteResponseExpectation::Detailed + ) + } +} + +// TokensDeleteResponse 结构体 +#[derive(Serialize)] +pub struct TokensDeleteResponse { + pub status: ApiStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_tokens: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub failed_tokens: Option>, } diff --git a/src/app/model/build_key.rs b/src/app/model/build_key.rs new file mode 100644 index 0000000..98966a1 --- /dev/null +++ b/src/app/model/build_key.rs @@ -0,0 +1,82 @@ +use serde::{Deserialize, Serialize}; + +use crate::{app::constant::COMMA, chat::constant::AVAILABLE_MODELS}; + +#[derive(Deserialize)] +pub struct BuildKeyRequest { + // 认证令牌(必需) + pub auth_token: String, + // 流第一个块检查 + #[serde(default)] + pub enable_stream_check: Option, + // 包含停止流 + #[serde(default)] + pub include_stop_stream: Option, + // 是否禁用图片处理能力 + #[serde(default)] + pub disable_vision: Option, + // 慢速池 + #[serde(default)] + pub enable_slow_pool: Option, + // 使用量检查模型规则 + #[serde(default)] + pub usage_check_models: Option, +} +pub struct UsageCheckModelConfig { + pub model_type: UsageCheckModelType, + pub model_ids: Vec<&'static str>, +} + +impl<'de> Deserialize<'de> for UsageCheckModelConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + #[serde(rename = "type")] + model_type: UsageCheckModelType, + #[serde(default)] + model_ids: String, + } + + let helper = Helper::deserialize(deserializer)?; + + let model_ids = if helper.model_ids.is_empty() { + Vec::new() + } else { + helper + .model_ids + .split(COMMA) + .filter_map(|model| { + let model = model.trim(); + AVAILABLE_MODELS + .iter() + .find(|m| m.id == model) + .map(|m| m.id) + }) + .collect() + }; + + Ok(UsageCheckModelConfig { + model_type: helper.model_type, + model_ids, + }) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum UsageCheckModelType { + Default, + Disabled, + All, + Custom, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BuildKeyResponse { + Key(String), + Error(String), +} diff --git a/src/app/model/proxies.rs b/src/app/model/proxies.rs new file mode 100644 index 0000000..bf73b17 --- /dev/null +++ b/src/app/model/proxies.rs @@ -0,0 +1,80 @@ +use reqwest::{Client, Proxy}; +use serde::{Serialize, Serializer}; +use serde::{Deserialize, Deserializer}; + +use crate::app::constant::COMMA_STRING; + +#[derive(Clone, Default, PartialEq)] +pub enum Proxies { + No, + #[default] + System, + List(Vec), +} + +impl Serialize for Proxies { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Proxies::No => serializer.serialize_str(""), + Proxies::System => serializer.serialize_str("system"), + Proxies::List(urls) => serializer.serialize_str(&urls.join(COMMA_STRING)), + } + } +} + +impl<'de> Deserialize<'de> for Proxies { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Proxies::from_str(&s)) + } +} + +impl Proxies { + /// 从字符串创建 Proxies + /// + /// # Arguments + /// * `s` - 代理字符串: + /// - "" 或 "no": 不使用代理 + /// - "system": 使用系统代理 + /// - 其他: 尝试解析为代理列表,无效则返回 System + pub fn from_str(s: &str) -> Self { + match s.trim() { + "" | "no" => Self::No, + "system" => Self::System, + urls => { + let valid_proxies: Vec = urls + .split(',') + .filter_map(|url| { + let trimmed = url.trim(); + (!trimmed.is_empty() && Proxy::all(trimmed).is_ok()) + .then(|| trimmed.to_string()) + }) + .collect(); + + if valid_proxies.is_empty() { + Self::default() + } else { + Self::List(valid_proxies) + } + } + } + } + + pub fn get_client(&self) -> Client { + match self { + Proxies::No => Client::builder().no_proxy().build().unwrap(), + Proxies::System => Client::new(), + Proxies::List(list) => { + // 使用第一个代理(已经确保是有效的) + let proxy = Proxy::all(list[0].clone()).unwrap(); + Client::builder().proxy(proxy).build().unwrap() + } + } + } +} diff --git a/src/app/model/usage_check.rs b/src/app/model/usage_check.rs index b7c4848..c186070 100644 --- a/src/app/model/usage_check.rs +++ b/src/app/model/usage_check.rs @@ -1,7 +1,10 @@ -use crate::chat::constant::AVAILABLE_MODELS; +use crate::{ + app::constant::{COMMA, COMMA_STRING}, + chat::{config::key_config, constant::AVAILABLE_MODELS}, +}; use serde::{Deserialize, Serialize}; -#[derive(Clone)] +#[derive(Clone, PartialEq)] pub enum UsageCheck { None, Default, @@ -9,6 +12,52 @@ pub enum UsageCheck { Custom(Vec<&'static str>), } +impl UsageCheck { + pub fn from_proto(model: Option<&key_config::UsageCheckModel>) -> Option { + model.map(|model| { + use key_config::usage_check_model::Type; + match Type::try_from(model.r#type).unwrap_or(Type::Default) { + Type::Default | Type::Disabled => Self::None, + Type::All => Self::All, + Type::Custom => { + let models: Vec<&'static str> = model + .model_ids + .iter() + .filter_map(|id| AVAILABLE_MODELS.iter().find(|m| m.id == id).map(|m| m.id)) + .collect(); + if models.is_empty() { + Self::None + } else { + Self::Custom(models) + } + } + } + }) + } + + // pub fn to_proto(&self) -> key_config::UsageCheckModel { + // use key_config::usage_check_model::Type; + // match self { + // Self::None => key_config::UsageCheckModel { + // r#type: Type::Disabled.into(), + // model_ids: vec![], + // }, + // Self::Default => key_config::UsageCheckModel { + // r#type: Type::Default.into(), + // model_ids: vec![], + // }, + // Self::All => key_config::UsageCheckModel { + // r#type: Type::All.into(), + // model_ids: vec![], + // }, + // Self::Custom(models) => key_config::UsageCheckModel { + // r#type: Type::Custom.into(), + // model_ids: models.iter().map(|&s| s.to_string()).collect(), + // }, + // } + // } +} + impl Default for UsageCheck { fn default() -> Self { Self::Default @@ -34,7 +83,7 @@ impl Serialize for UsageCheck { } UsageCheck::Custom(models) => { state.serialize_field("type", "list")?; - state.serialize_field("content", &models.join(","))?; + state.serialize_field("content", &models.join(COMMA_STRING))?; } } state.end() @@ -70,7 +119,7 @@ impl<'de> Deserialize<'de> for UsageCheck { } let models: Vec<&'static str> = list - .split(',') + .split(COMMA) .filter_map(|model| { let model = model.trim(); AVAILABLE_MODELS @@ -89,3 +138,34 @@ impl<'de> Deserialize<'de> for UsageCheck { }) } } + +impl UsageCheck { + pub fn from_str(s: &str) -> Self { + match s.trim().to_lowercase().as_str() { + "none" | "disabled" => Self::None, + "default" => Self::Default, + "all" | "everything" => Self::All, + list => { + if list.is_empty() { + return Self::default(); + } + let models: Vec<&'static str> = list + .split(COMMA) + .filter_map(|model| { + let model = model.trim(); + AVAILABLE_MODELS + .iter() + .find(|m| m.id == model) + .map(|m| m.id) + }) + .collect(); + + if models.is_empty() { + Self::default() + } else { + Self::Custom(models) + } + } + } + } +} diff --git a/src/chat.rs b/src/chat.rs index b557892..f46dddb 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,7 +1,9 @@ pub mod adapter; pub mod aiserver; +pub mod config; pub mod constant; pub mod error; +// pub mod middleware; pub mod model; pub mod route; pub mod service; diff --git a/src/chat/adapter.rs b/src/chat/adapter.rs index 17e53c1..a05f6cf 100644 --- a/src/chat/adapter.rs +++ b/src/chat/adapter.rs @@ -1,23 +1,31 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use image::guess_format; use prost::Message as _; +use reqwest::Client; use uuid::Uuid; -use crate::app::{ - constant::EMPTY_STRING, - lazy::DEFAULT_INSTRUCTIONS, - model::{AppConfig, VisionAbility}, +use crate::{ + app::{ + constant::EMPTY_STRING, + lazy::DEFAULT_INSTRUCTIONS, + model::{AppConfig, VisionAbility}, + }, + common::client::HTTP_CLIENT, }; use super::{ aiserver::v1::{ - conversation_message, image_proto, AzureState, ConversationMessage, ExplicitContext, GetChatRequest, ImageProto, ModelDetails + conversation_message, image_proto, AzureState, ConversationMessage, ExplicitContext, + GetChatRequest, ImageProto, ModelDetails, }, constant::{ERR_UNSUPPORTED_GIF, ERR_UNSUPPORTED_IMAGE_FORMAT, LONG_CONTEXT_MODELS}, model::{Message, MessageContent, Role}, }; -async fn process_chat_inputs(inputs: Vec) -> (String, Vec) { +async fn process_chat_inputs( + inputs: Vec, + disable_vision: bool, +) -> (String, Vec) { // 收集 system 指令 let instructions = inputs .iter() @@ -155,15 +163,19 @@ async fn process_chat_inputs(inputs: Vec) -> (String, Vec { - if let Some(image_url) = &content.image_url { - let url = image_url.url.clone(); - let result = - tokio::spawn(async move { fetch_image_data(&url).await }); - if let Ok(Ok((image_data, dimensions))) = result.await { - images.push(ImageProto { - data: image_data, - dimension: dimensions, + if !disable_vision { + if let Some(image_url) = &content.image_url { + let url = image_url.url.clone(); + let client = HTTP_CLIENT.read().clone(); + let result = tokio::spawn(async move { + fetch_image_data(&url, client).await }); + if let Ok(Ok((image_data, dimensions))) = result.await { + images.push(ImageProto { + data: image_data, + dimension: dimensions, + }); + } } } } @@ -219,6 +231,7 @@ async fn process_chat_inputs(inputs: Vec) -> (String, Vec Result<(Vec, Option), Box> { // 在进入异步操作前获取并释放锁 let vision_ability = AppConfig::get_vision_ability(); @@ -237,7 +250,7 @@ async fn fetch_image_data( if url.starts_with("data:image/") { process_base64_image(url) } else { - process_http_image(url).await + process_http_image(url, client).await } } } @@ -290,8 +303,9 @@ fn process_base64_image( // 处理 HTTP 图片 URL async fn process_http_image( url: &str, + client: Client, ) -> Result<(Vec, Option), Box> { - let response = reqwest::get(url).await?; + let response = client.get(url).send().await?; let image_data = response.bytes().await?.to_vec(); let format = guess_format(&image_data)?; @@ -328,17 +342,19 @@ async fn process_http_image( pub async fn encode_chat_message( inputs: Vec, model_name: &str, + disable_vision: bool, + enable_slow_pool: bool, ) -> Result, Box> { // 在进入异步操作前获取并释放锁 let enable_slow_pool = { - if AppConfig::get_slow_pool() { + if enable_slow_pool { Some(true) } else { None } }; - let (instructions, messages) = process_chat_inputs(inputs).await; + let (instructions, messages) = process_chat_inputs(inputs, disable_vision).await; let explicit_context = if !instructions.trim().is_empty() { Some(ExplicitContext { diff --git a/src/chat/aiserver/v1.rs b/src/chat/aiserver/v1.rs index f886b8b..5660b2d 100644 --- a/src/chat/aiserver/v1.rs +++ b/src/chat/aiserver/v1.rs @@ -1 +1,57 @@ include!(concat!(env!("OUT_DIR"), "/aiserver.v1.rs")); +use error_details::Error; + +impl ErrorDetails { + pub fn status_code(&self) -> u16 { + match Error::try_from(self.error) { + Ok(error) => match error { + Error::Unspecified => 500, + Error::BadApiKey + | Error::InvalidAuthId + | Error::AuthTokenNotFound + | Error::AuthTokenExpired + | Error::Unauthorized => 401, + Error::NotLoggedIn + | Error::NotHighEnoughPermissions + | Error::AgentRequiresLogin + | Error::ProUserOnly + | Error::TaskNoPermissions => 403, + Error::NotFound + | Error::UserNotFound + | Error::TaskUuidNotFound + | Error::AgentEngineNotFound + | Error::GitgraphNotFound + | Error::FileNotFound => 404, + Error::FreeUserRateLimitExceeded + | Error::ProUserRateLimitExceeded + | Error::OpenaiRateLimitExceeded + | Error::OpenaiAccountLimitExceeded + | Error::GenericRateLimitExceeded + | Error::Gpt4VisionPreviewRateLimit + | Error::ApiKeyRateLimit => 429, + Error::BadRequest + | Error::BadModelName + | Error::SlashEditFileTooLong + | Error::FileUnsupported + | Error::ClaudeImageTooLarge => 400, + Error::Deprecated + | Error::FreeUserUsageLimit + | Error::ProUserUsageLimit + | Error::ResourceExhausted + | Error::Openai + | Error::MaxTokens + | Error::ApiKeyNotSupported + | Error::UserAbortedRequest + | Error::CustomMessage + | Error::OutdatedClient + | Error::Debounced + | Error::RepositoryServiceRepositoryIsNotInitialized => 500, + }, + Err(_) => 500, + } + } + + // pub fn is_expected(&self) -> bool { + // self.is_expected.unwrap_or_default() + // } +} diff --git a/src/chat/config.rs b/src/chat/config.rs new file mode 100644 index 0000000..de61eb5 --- /dev/null +++ b/src/chat/config.rs @@ -0,0 +1,34 @@ +use crate::AppConfig; + +include!(concat!(env!("OUT_DIR"), "/key.rs")); + +impl KeyConfig { + pub fn new_with_global() -> Self { + Self { + auth_token: None, + enable_stream_check: Some(AppConfig::get_stream_check()), + include_stop_stream: Some(AppConfig::get_stop_stream()), + disable_vision: Some(AppConfig::get_vision_ability().is_none()), + enable_slow_pool: Some(AppConfig::get_slow_pool()), + usage_check_models: None, + } + } + + pub fn copy_without_auth_token(&self, config: &mut Self) { + if self.enable_stream_check.is_some() { + config.enable_stream_check = self.enable_stream_check; + } + if self.include_stop_stream.is_some() { + config.include_stop_stream = self.include_stop_stream; + } + if self.disable_vision.is_some() { + config.disable_vision = self.disable_vision; + } + if self.enable_slow_pool.is_some() { + config.enable_slow_pool = self.enable_slow_pool; + } + if self.usage_check_models.is_some() { + config.usage_check_models = self.usage_check_models.clone(); + } + } +} diff --git a/src/chat/config/key.proto b/src/chat/config/key.proto new file mode 100644 index 0000000..4806294 --- /dev/null +++ b/src/chat/config/key.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; + +package key; + +// 动态配置的 API KEY +message KeyConfig { + // 认证令牌信息 + message TokenInfo { + string sub = 1; // 用户标识符 + int64 exp = 2; // 过期时间(Unix 时间戳) + string randomness = 3; // 随机字符串 + string signature = 4; // 签名 + bytes machine_id = 5; // 机器ID的SHA256哈希值 + bytes mac_id = 6; // MAC地址的SHA256哈希值 + } + + // 认证令牌(必需) + TokenInfo auth_token = 1; + + // 是否启用流检查 + optional bool enable_stream_check = 2; + + // 是否包含停止流 + optional bool include_stop_stream = 3; + + // 是否禁用图片处理能力 + optional bool disable_vision = 4; + + // 是否启用慢速池 + optional bool enable_slow_pool = 5; + + // 使用量检查模型规则 + message UsageCheckModel { + // 检查类型 + enum Type { + TYPE_DEFAULT = 0; // 未指定 + TYPE_DISABLED = 1; // 禁用 + TYPE_ALL = 2; // 全部 + TYPE_CUSTOM = 3; // 自定义列表 + } + Type type = 1; // 检查类型 + repeated string model_ids = 2; // 模型 ID 列表,当 type 为 TYPE_CUSTOM 时生效 + } + // 使用量检查模型规则 + optional UsageCheckModel usage_check_models = 6; + + // 密码SHA256哈希值 + // bytes secret = 2; +} \ No newline at end of file diff --git a/src/chat/constant.rs b/src/chat/constant.rs index 84a8d6a..bbc10b6 100644 --- a/src/chat/constant.rs +++ b/src/chat/constant.rs @@ -16,6 +16,7 @@ def_pub_const!(ANTHROPIC, "anthropic"); def_pub_const!(CURSOR, "cursor"); def_pub_const!(GOOGLE, "google"); def_pub_const!(OPENAI, "openai"); +def_pub_const!(DEEPSEEK, "deepseek"); def_pub_const!(CLAUDE_3_5_SONNET, "claude-3.5-sonnet"); def_pub_const!(GPT_4, "gpt-4"); @@ -41,8 +42,10 @@ def_pub_const!( "gemini-2.0-flash-thinking-exp" ); def_pub_const!(GEMINI_2_0_FLASH_EXP, "gemini-2.0-flash-exp"); +def_pub_const!(DEEPSEEK_V3, "deepseek-v3"); +def_pub_const!(DEEPSEEK_R1, "deepseek-r1"); -pub const AVAILABLE_MODELS: [Model; 21] = [ +pub const AVAILABLE_MODELS: [Model; 23] = [ Model { id: CLAUDE_3_5_SONNET, created: CREATED, @@ -169,6 +172,18 @@ pub const AVAILABLE_MODELS: [Model; 21] = [ object: MODEL_OBJECT, owned_by: GOOGLE, }, + Model { + id: DEEPSEEK_V3, + created: CREATED, + object: MODEL_OBJECT, + owned_by: DEEPSEEK, + }, + Model { + id: DEEPSEEK_R1, + created: CREATED, + object: MODEL_OBJECT, + owned_by: DEEPSEEK, + }, ]; pub const USAGE_CHECK_MODELS: [&str; 11] = [ @@ -191,3 +206,5 @@ pub const LONG_CONTEXT_MODELS: [&str; 4] = [ CLAUDE_3_HAIKU_200K, CLAUDE_3_5_SONNET_200K, ]; + +// include!("constant/models.rs"); diff --git a/src/chat/constant/models.rs b/src/chat/constant/models.rs new file mode 100644 index 0000000..bf010b4 --- /dev/null +++ b/src/chat/constant/models.rs @@ -0,0 +1,118 @@ +pub struct DefaultModel { + pub default_on: bool, + pub is_long_context_only: Option, + pub name: &'static str, +} + +pub const AVAILABLE_MODELS2: [DefaultModel; 22] = [ + DefaultModel { + default_on: true, + is_long_context_only: Some(false), + name: CLAUDE_3_5_SONNET, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: GPT_4, + }, + DefaultModel { + default_on: true, + is_long_context_only: None, + name: GPT_4O, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: CLAUDE_3_OPUS, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: CURSOR_FAST, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: CURSOR_SMALL, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: GPT_3_5_TURBO, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: GPT_4_TURBO_2024_04_09, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(true), + name: GPT_4O_128K, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(true), + name: GEMINI_1_5_FLASH_500K, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(true), + name: CLAUDE_3_HAIKU_200K, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(true), + name: CLAUDE_3_5_SONNET_200K, + }, + DefaultModel { + default_on: false, + is_long_context_only: Some(false), + name: CLAUDE_3_5_SONNET_20241022, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(false), + name: GPT_4O_MINI, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(false), + name: O1_MINI, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(false), + name: O1_PREVIEW, + }, + DefaultModel { + default_on: true, + is_long_context_only: Some(false), + name: O1, + }, + DefaultModel { + default_on: false, + is_long_context_only: Some(false), + name: CLAUDE_3_5_HAIKU, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: GEMINI_EXP_1206, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: GEMINI_2_0_FLASH_THINKING_EXP, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: GEMINI_2_0_FLASH_EXP, + }, + DefaultModel { + default_on: false, + is_long_context_only: None, + name: DEEPSEEK_V3, + }, +]; \ No newline at end of file diff --git a/src/chat/error.rs b/src/chat/error.rs index be4db01..2ae3f44 100644 --- a/src/chat/error.rs +++ b/src/chat/error.rs @@ -1,4 +1,7 @@ -use super::aiserver::v1::error_details::Error as ErrorType; +use super::aiserver::v1::ErrorDetails; +use crate::common::model::{ApiStatus, ErrorResponse as CommonErrorResponse}; +use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; +use prost::Message as _; use reqwest::StatusCode; use serde::{Deserialize, Serialize}; @@ -18,30 +21,28 @@ pub struct ErrorBody { pub struct ErrorDetail { // #[serde(rename = "type")] // error_type: String, always: aiserver.v1.ErrorDetails - debug: ErrorDebug, + // debug: ErrorDebug, value: String, } -#[derive(Deserialize)] -pub struct ErrorDebug { - error: String, - details: ErrorDetails, - // #[serde(rename = "isExpected")] - // is_expected: Option, -} +// #[derive(Deserialize)] +// pub struct ErrorDebug { +// error: String, +// details: ErrorDetails, +// // #[serde(rename = "isExpected")] +// // is_expected: Option, +// } -#[derive(Deserialize)] -pub struct ErrorDetails { - title: String, - detail: String, - // #[serde(rename = "isRetryable")] - // is_retryable: Option, -} - -use crate::common::models::{ApiStatus, ErrorResponse as CommonErrorResponse}; +// #[derive(Deserialize)] +// pub struct ErrorDetails { +// title: String, +// detail: String, +// // #[serde(rename = "isRetryable")] +// // is_retryable: Option, +// } impl ChatError { - pub fn to_error_response(&self) -> ErrorResponse { + pub fn to_error_response(self) -> ErrorResponse { if self.error.details.is_empty() { return ErrorResponse { status: 500, @@ -49,69 +50,31 @@ impl ChatError { error: None, }; } + + let error_details = self.error.details.first().and_then(|detail| { + STANDARD_NO_PAD + .decode(&detail.value) + .ok() + .map(bytes::Bytes::from) + .and_then(|buf| ErrorDetails::decode(buf).ok()) + }); + + let status = error_details + .as_ref() + .map(|details| details.status_code()) + .unwrap_or(500); + ErrorResponse { - status: self.status_code(), - code: self.error.code.clone(), - error: Some(Error { - message: self.error.details[0].debug.details.title.clone(), - details: self.error.details[0].debug.details.detail.clone(), - value: self.error.details[0].value.clone(), - }), + status, + code: self.error.code, + error: error_details + .and_then(|details| details.details) + .map(|custom_details| Error { + message: custom_details.title, + details: custom_details.detail, + }), } } - - pub fn status_code(&self) -> u16 { - match ErrorType::from_str_name(&self.error.details[0].debug.error) { - Some(error) => match error { - ErrorType::Unspecified => 500, - ErrorType::BadApiKey - | ErrorType::InvalidAuthId - | ErrorType::AuthTokenNotFound - | ErrorType::AuthTokenExpired - | ErrorType::Unauthorized => 401, - ErrorType::NotLoggedIn - | ErrorType::NotHighEnoughPermissions - | ErrorType::AgentRequiresLogin - | ErrorType::ProUserOnly - | ErrorType::TaskNoPermissions => 403, - ErrorType::NotFound - | ErrorType::UserNotFound - | ErrorType::TaskUuidNotFound - | ErrorType::AgentEngineNotFound - | ErrorType::GitgraphNotFound - | ErrorType::FileNotFound => 404, - ErrorType::FreeUserRateLimitExceeded - | ErrorType::ProUserRateLimitExceeded - | ErrorType::OpenaiRateLimitExceeded - | ErrorType::OpenaiAccountLimitExceeded - | ErrorType::GenericRateLimitExceeded - | ErrorType::Gpt4VisionPreviewRateLimit - | ErrorType::ApiKeyRateLimit => 429, - ErrorType::BadRequest - | ErrorType::BadModelName - | ErrorType::SlashEditFileTooLong - | ErrorType::FileUnsupported - | ErrorType::ClaudeImageTooLarge => 400, - ErrorType::Deprecated - | ErrorType::FreeUserUsageLimit - | ErrorType::ProUserUsageLimit - | ErrorType::ResourceExhausted - | ErrorType::Openai - | ErrorType::MaxTokens - | ErrorType::ApiKeyNotSupported - | ErrorType::UserAbortedRequest - | ErrorType::CustomMessage - | ErrorType::OutdatedClient - | ErrorType::Debounced - | ErrorType::RepositoryServiceRepositoryIsNotInitialized => 500, - }, - None => 500, - } - } - - // pub fn is_expected(&self) -> bool { - // self.error.details[0].debug.is_expected.unwrap_or_default() - // } } #[derive(Serialize)] @@ -126,7 +89,7 @@ pub struct ErrorResponse { pub struct Error { pub message: String, pub details: String, - pub value: String, + // pub value: String, } impl ErrorResponse { @@ -135,18 +98,25 @@ impl ErrorResponse { // } pub fn status_code(&self) -> StatusCode { - StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + StatusCode::from_u16(self.status).unwrap() } pub fn native_code(&self) -> String { - self.code.replace("_", " ") + self.error.as_ref().map_or_else( + || self.code.replace("_", " "), + |error| error.message.clone(), + ) } pub fn to_common(self) -> CommonErrorResponse { CommonErrorResponse { status: ApiStatus::Error, code: Some(self.status), - error: self.error.as_ref().map(|error| error.message.clone()).or(Some(self.code.clone())), + error: self + .error + .as_ref() + .map(|error| error.message.clone()) + .or(Some(self.code.clone())), message: self.error.as_ref().map(|error| error.details.clone()), } } @@ -161,7 +131,7 @@ pub enum StreamError { impl std::fmt::Display for StreamError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - StreamError::ChatError(error) => write!(f, "{}", error.error.details[0].debug.details.title), + StreamError::ChatError(error) => write!(f, "{}", error.error.code), StreamError::DataLengthLessThan5 => write!(f, "data length less than 5"), StreamError::EmptyMessage => write!(f, "empty message"), } diff --git a/src/chat/middleware.rs b/src/chat/middleware.rs new file mode 100644 index 0000000..da14983 --- /dev/null +++ b/src/chat/middleware.rs @@ -0,0 +1,2 @@ +mod auth; +pub use auth::*; diff --git a/src/chat/middleware/auth.rs b/src/chat/middleware/auth.rs new file mode 100644 index 0000000..ba1bd7f --- /dev/null +++ b/src/chat/middleware/auth.rs @@ -0,0 +1,23 @@ +use crate::app::{constant::AUTHORIZATION_BEARER_PREFIX, lazy::AUTH_TOKEN}; +use axum::{ + body::Body, + http::{header::AUTHORIZATION, Request, StatusCode}, + middleware::Next, + response::Response, +}; + +// 认证中间件函数 +pub async fn auth_middleware(request: Request, next: Next) -> Result { + let auth_header = request + .headers() + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if auth_header != AUTH_TOKEN.as_str() { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(next.run(request).await) +} diff --git a/src/chat/model.rs b/src/chat/model.rs index 0091dc6..bda8024 100644 --- a/src/chat/model.rs +++ b/src/chat/model.rs @@ -90,8 +90,8 @@ use crate::app::model::{AppConfig, UsageCheck}; use super::constant::USAGE_CHECK_MODELS; impl Model { - pub fn is_usage_check(&self) -> bool { - match AppConfig::get_usage_check() { + pub fn is_usage_check(&self, usage_check: Option) -> bool { + match usage_check.unwrap_or(AppConfig::get_usage_check()) { UsageCheck::None => false, UsageCheck::Default => USAGE_CHECK_MODELS.contains(&self.id), UsageCheck::All => true, diff --git a/src/chat/route.rs b/src/chat/route.rs index e78523a..cf12473 100644 --- a/src/chat/route.rs +++ b/src/chat/route.rs @@ -2,17 +2,18 @@ mod logs; pub use logs::{handle_logs, handle_logs_post}; mod health; pub use health::{handle_health, handle_root}; -mod token; -pub use token::{ - handle_basic_calibration, handle_get_checksum, handle_get_hash, handle_get_timestamp_header, - handle_get_tokeninfo, handle_tokeninfo_page, handle_update_tokeninfo, - handle_update_tokeninfo_post, +mod tokens; +pub use tokens::{ + handle_add_tokens, handle_basic_calibration, handle_delete_tokens, handle_get_checksum, + handle_get_hash, handle_get_timestamp_header, handle_get_tokens, handle_reload_tokens, + handle_tokens_page, handle_update_tokens, }; mod profile; pub use profile::handle_user_info; mod config; pub use config::{ - handle_about, handle_config_page, handle_env_example, handle_readme, handle_static, + handle_about, handle_build_key, handle_build_key_page, handle_config_page, handle_env_example, + handle_readme, handle_static, }; mod api; pub use api::handle_api_page; diff --git a/src/chat/route/config.rs b/src/chat/route/config.rs index ec20956..3629cfc 100644 --- a/src/chat/route/config.rs +++ b/src/chat/route/config.rs @@ -1,20 +1,25 @@ -use crate::app::{ - constant::{ - CONTENT_TYPE_TEXT_CSS_WITH_UTF8, CONTENT_TYPE_TEXT_HTML_WITH_UTF8, - CONTENT_TYPE_TEXT_JS_WITH_UTF8, CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8, ROUTE_ABOUT_PATH, - ROUTE_CONFIG_PATH, ROUTE_README_PATH, ROUTE_SHARED_JS_PATH, ROUTE_SHARED_STYLES_PATH, +use crate::{ + app::{ + constant::{ + AUTHORIZATION_BEARER_PREFIX, CONTENT_TYPE_TEXT_CSS_WITH_UTF8, CONTENT_TYPE_TEXT_HTML_WITH_UTF8, CONTENT_TYPE_TEXT_JS_WITH_UTF8, CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8, ROUTE_ABOUT_PATH, ROUTE_BUILD_KEY_PATH, ROUTE_CONFIG_PATH, ROUTE_README_PATH, ROUTE_SHARED_JS_PATH, ROUTE_SHARED_STYLES_PATH + }, + lazy::{AUTH_TOKEN, KEY_PREFIX}, + model::{AppConfig, BuildKeyRequest, BuildKeyResponse, PageContent, UsageCheckModelType}, }, - model::{AppConfig, PageContent}, + chat::config::{key_config, KeyConfig}, + common::utils::{to_base64, token_to_tokeninfo}, }; use axum::{ body::Body, extract::Path, http::{ - header::{CONTENT_TYPE, LOCATION}, - StatusCode, + header::{AUTHORIZATION, CONTENT_TYPE, LOCATION}, + HeaderMap, StatusCode, }, response::{IntoResponse, Response}, + Json, }; +use prost::Message as _; pub async fn handle_env_example() -> impl IntoResponse { Response::builder() @@ -108,3 +113,93 @@ pub async fn handle_about() -> impl IntoResponse { .unwrap(), } } + +pub async fn handle_build_key_page() -> impl IntoResponse { + match AppConfig::get_page_content(ROUTE_BUILD_KEY_PATH).unwrap_or_default() { + PageContent::Default => Response::builder() + .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_HTML_WITH_UTF8) + .body(include_str!("../../../static/build_key.min.html").to_string()) + .unwrap(), + PageContent::Text(content) => Response::builder() + .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8) + .body(content.clone()) + .unwrap(), + PageContent::Html(content) => Response::builder() + .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_HTML_WITH_UTF8) + .body(content.clone()) + .unwrap(), + } +} + +pub async fn handle_build_key( + headers: HeaderMap, + Json(request): Json, +) -> (StatusCode, Json) { + // 验证认证令牌 + if AppConfig::is_share() { + let auth_header = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)); + + if auth_header.map_or(true, |h| h != AppConfig::get_share_token().as_str() && h != AUTH_TOKEN.as_str()) { + return ( + StatusCode::UNAUTHORIZED, + Json(BuildKeyResponse::Error("Unauthorized".to_owned())), + ); + } + } + + // 验证并解析 auth_token + let token_info = match token_to_tokeninfo(&request.auth_token) { + Some(info) => info, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(BuildKeyResponse::Error("Invalid auth token".to_owned())), + ) + } + }; + + // 构建 proto 消息 + let mut key_config = KeyConfig { + auth_token: Some(token_info), + enable_stream_check: request.enable_stream_check, + include_stop_stream: request.include_stop_stream, + disable_vision: request.disable_vision, + enable_slow_pool: request.enable_slow_pool, + usage_check_models: None, + }; + + if let Some(usage_check_models) = request.usage_check_models { + let usage_check = key_config::UsageCheckModel { + r#type: match usage_check_models.model_type { + UsageCheckModelType::Default => { + key_config::usage_check_model::Type::Default as i32 + } + UsageCheckModelType::Disabled => { + key_config::usage_check_model::Type::Disabled as i32 + } + UsageCheckModelType::All => key_config::usage_check_model::Type::All as i32, + UsageCheckModelType::Custom => key_config::usage_check_model::Type::Custom as i32, + }, + model_ids: if matches!(usage_check_models.model_type, UsageCheckModelType::Custom) { + usage_check_models + .model_ids + .iter() + .map(|s| s.to_string()) + .collect() + } else { + Vec::new() + }, + }; + key_config.usage_check_models = Some(usage_check); + } + + // 序列化 + let encoded = key_config.encode_to_vec(); + + let key = format!("{}{}", *KEY_PREFIX, to_base64(&encoded)); + + (StatusCode::OK, Json(BuildKeyResponse::Key(key))) +} diff --git a/src/chat/route/health.rs b/src/chat/route/health.rs index 91c7917..4623af6 100644 --- a/src/chat/route/health.rs +++ b/src/chat/route/health.rs @@ -3,17 +3,18 @@ use crate::{ constant::{ AUTHORIZATION_BEARER_PREFIX, CONTENT_TYPE_TEXT_HTML_WITH_UTF8, CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8, PKG_VERSION, ROUTE_ABOUT_PATH, ROUTE_API_PATH, - ROUTE_BASIC_CALIBRATION_PATH, ROUTE_CONFIG_PATH, ROUTE_ENV_EXAMPLE_PATH, - ROUTE_GET_CHECKSUM, ROUTE_GET_HASH, ROUTE_GET_TIMESTAMP_HEADER, - ROUTE_GET_TOKENINFO_PATH, ROUTE_HEALTH_PATH, ROUTE_LOGS_PATH, ROUTE_README_PATH, - ROUTE_ROOT_PATH, ROUTE_STATIC_PATH, ROUTE_TOKENINFO_PATH, ROUTE_UPDATE_TOKENINFO_PATH, + ROUTE_BASIC_CALIBRATION_PATH, ROUTE_BUILD_KEY_PATH, ROUTE_CONFIG_PATH, + ROUTE_ENV_EXAMPLE_PATH, ROUTE_GET_CHECKSUM, ROUTE_GET_HASH, ROUTE_GET_TIMESTAMP_HEADER, + ROUTE_HEALTH_PATH, ROUTE_LOGS_PATH, ROUTE_README_PATH, ROUTE_ROOT_PATH, + ROUTE_STATIC_PATH, ROUTE_TOKENS_ADD_PATH, ROUTE_TOKENS_DELETE_PATH, + ROUTE_TOKENS_GET_PATH, ROUTE_TOKENS_PATH, ROUTE_TOKENS_UPDATE_PATH, ROUTE_USER_INFO_PATH, }, lazy::{get_start_time, AUTH_TOKEN, ROUTE_CHAT_PATH, ROUTE_MODELS_PATH}, model::{AppConfig, AppState, PageContent}, }, chat::constant::AVAILABLE_MODELS, - common::models::{ + common::model::{ health::{CpuInfo, HealthCheckResponse, MemoryInfo, SystemInfo, SystemStats}, ApiStatus, }, @@ -116,9 +117,11 @@ pub async fn handle_health( endpoints: vec![ ROUTE_CHAT_PATH.as_str(), ROUTE_MODELS_PATH.as_str(), - ROUTE_TOKENINFO_PATH, - ROUTE_UPDATE_TOKENINFO_PATH, - ROUTE_GET_TOKENINFO_PATH, + ROUTE_TOKENS_PATH, + ROUTE_TOKENS_GET_PATH, + ROUTE_TOKENS_UPDATE_PATH, + ROUTE_TOKENS_ADD_PATH, + ROUTE_TOKENS_DELETE_PATH, ROUTE_LOGS_PATH, ROUTE_ENV_EXAMPLE_PATH, ROUTE_CONFIG_PATH, @@ -131,6 +134,7 @@ pub async fn handle_health( ROUTE_GET_TIMESTAMP_HEADER, ROUTE_BASIC_CALIBRATION_PATH, ROUTE_USER_INFO_PATH, + ROUTE_BUILD_KEY_PATH, ], }) } diff --git a/src/chat/route/logs.rs b/src/chat/route/logs.rs index 0076b7e..2e6d18f 100644 --- a/src/chat/route/logs.rs +++ b/src/chat/route/logs.rs @@ -7,7 +7,7 @@ use crate::{ lazy::AUTH_TOKEN, model::{AppConfig, AppState, PageContent, RequestLog}, }, - common::{models::ApiStatus, utils::extract_token}, + common::{model::ApiStatus, utils::extract_token}, }; use axum::{ body::Body, diff --git a/src/chat/route/profile.rs b/src/chat/route/profile.rs index 07987fa..6d3af8b 100644 --- a/src/chat/route/profile.rs +++ b/src/chat/route/profile.rs @@ -1,10 +1,10 @@ use crate::{ chat::constant::ERR_NODATA, - common::{models::userinfo::GetUserInfo, utils::{extract_token, get_token_profile}}, + common::{model::userinfo::GetUserInfo, utils::{extract_token, get_token_profile}}, }; use axum::Json; -use super::token::TokenRequest; +use super::tokens::TokenRequest; pub async fn handle_user_info(Json(request): Json) -> Json { let auth_token = match request.token { diff --git a/src/chat/route/token.rs b/src/chat/route/token.rs deleted file mode 100644 index 89eb57f..0000000 --- a/src/chat/route/token.rs +++ /dev/null @@ -1,276 +0,0 @@ -use crate::{ - app::{ - constant::{ - AUTHORIZATION_BEARER_PREFIX, CONTENT_TYPE_TEXT_HTML_WITH_UTF8, - CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8, ROUTE_TOKENINFO_PATH, - }, - lazy::{AUTH_TOKEN, TOKEN_FILE, TOKEN_LIST_FILE}, - model::{AppConfig, AppState, PageContent, TokenUpdateRequest}, - }, - common::{ - models::{ApiStatus, NormalResponseNoData}, - utils::{ - extract_time, extract_time_ks, extract_user_id, generate_checksum_with_default, generate_checksum_with_repair, generate_hash, generate_timestamp_header, load_tokens, validate_token_and_checksum - }, - }, -}; -use axum::{ - extract::{Query, State}, - http::{ - header::{AUTHORIZATION, CONTENT_TYPE}, - HeaderMap, - }, - response::{IntoResponse, Response}, - Json, -}; -use reqwest::StatusCode; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use tokio::sync::Mutex; - -pub async fn handle_get_hash() -> Response { - let hash = generate_hash(); - - let mut headers = HeaderMap::new(); - headers.insert( - CONTENT_TYPE, - CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8.parse().unwrap(), - ); - - (headers, hash).into_response() -} - -#[derive(Deserialize)] -pub struct ChecksumQuery { - #[serde(default)] - pub checksum: Option, -} - -pub async fn handle_get_checksum(Query(query): Query) -> Response { - let checksum = match query.checksum { - None => generate_checksum_with_default(), - Some(checksum) => generate_checksum_with_repair(&checksum), - }; - - let mut headers = HeaderMap::new(); - headers.insert( - CONTENT_TYPE, - CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8.parse().unwrap(), - ); - - (headers, checksum).into_response() -} - -pub async fn handle_get_timestamp_header() -> Response { - let timestamp_header = generate_timestamp_header(); - - let mut headers = HeaderMap::new(); - headers.insert( - CONTENT_TYPE, - CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8.parse().unwrap(), - ); - - (headers, timestamp_header).into_response() -} - -// 更新 TokenInfo 处理 -pub async fn handle_update_tokeninfo( - State(state): State>>, -) -> Json { - // 重新加载 tokens - let token_infos = load_tokens(); - - // 更新应用状态 - { - let mut state = state.lock().await; - state.token_infos = token_infos; - } - - Json(NormalResponseNoData { - status: ApiStatus::Success, - message: Some("Token list has been reloaded".to_string()), - }) -} - -// 获取 TokenInfo 处理 -pub async fn handle_get_tokeninfo( - headers: HeaderMap, -) -> Result, StatusCode> { - // 验证 AUTH_TOKEN - let auth_header = headers - .get(AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) - .ok_or(StatusCode::UNAUTHORIZED)?; - - if auth_header != AUTH_TOKEN.as_str() { - return Err(StatusCode::UNAUTHORIZED); - } - - let token_file = TOKEN_FILE.as_str(); - let token_list_file = TOKEN_LIST_FILE.as_str(); - - // 读取文件内容 - let tokens = std::fs::read_to_string(&token_file).unwrap_or_else(|_| String::new()); - let token_list = std::fs::read_to_string(&token_list_file).unwrap_or_else(|_| String::new()); - - // 获取 tokens_count - let tokens_count = { - { - tokens.len() - } - }; - - Ok(Json(TokenInfoResponse { - status: ApiStatus::Success, - token_file: token_file.to_string(), - token_list_file: token_list_file.to_string(), - tokens: Some(tokens), - tokens_count: Some(tokens_count), - token_list: Some(token_list), - message: None, - })) -} - -#[derive(Serialize)] -pub struct TokenInfoResponse { - pub status: ApiStatus, - pub token_file: String, - pub token_list_file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub tokens: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub tokens_count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub token_list: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} - -pub async fn handle_update_tokeninfo_post( - State(state): State>>, - headers: HeaderMap, - Json(request): Json, -) -> Result, StatusCode> { - // 验证 AUTH_TOKEN - let auth_header = headers - .get(AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) - .ok_or(StatusCode::UNAUTHORIZED)?; - - if auth_header != AUTH_TOKEN.as_str() { - return Err(StatusCode::UNAUTHORIZED); - } - - let token_file = TOKEN_FILE.as_str(); - let token_list_file = TOKEN_LIST_FILE.as_str(); - - // 写入文件 - std::fs::write(&token_file, &request.tokens).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if let Some(token_list) = &request.token_list { - std::fs::write(&token_list_file, token_list) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - // 重新加载 tokens - let token_infos = load_tokens(); - let token_infos_len = token_infos.len(); - - // 更新应用状态 - { - let mut state = state.lock().await; - state.token_infos = token_infos; - } - - Ok(Json(TokenInfoResponse { - status: ApiStatus::Success, - token_file: token_file.to_string(), - token_list_file: token_list_file.to_string(), - tokens: None, - tokens_count: Some(token_infos_len), - token_list: None, - message: Some("Token files have been updated and reloaded".to_string()), - })) -} - -pub async fn handle_tokeninfo_page() -> impl IntoResponse { - match AppConfig::get_page_content(ROUTE_TOKENINFO_PATH).unwrap_or_default() { - PageContent::Default => Response::builder() - .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_HTML_WITH_UTF8) - .body(include_str!("../../../static/tokeninfo.min.html").to_string()) - .unwrap(), - PageContent::Text(content) => Response::builder() - .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8) - .body(content.clone()) - .unwrap(), - PageContent::Html(content) => Response::builder() - .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_HTML_WITH_UTF8) - .body(content.clone()) - .unwrap(), - } -} - -#[derive(Deserialize)] -pub struct TokenRequest { - pub token: Option, -} - -#[derive(Serialize)] -pub struct BasicCalibrationResponse { - pub status: ApiStatus, - pub message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub create_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub checksum_time: Option, -} - -pub async fn handle_basic_calibration( - Json(request): Json, -) -> Json { - // 从请求头中获取并验证 auth token - let auth_token = match request.token { - Some(token) => token, - None => { - return Json(BasicCalibrationResponse { - status: ApiStatus::Error, - message: Some("未提供授权令牌".to_string()), - user_id: None, - create_at: None, - checksum_time: None, - }) - } - }; - - // 校验 token 和 checksum - let (token, checksum) = match validate_token_and_checksum(&auth_token) { - Some(parts) => parts, - None => { - return Json(BasicCalibrationResponse { - status: ApiStatus::Error, - message: Some("无效令牌或无效校验和".to_string()), - user_id: None, - create_at: None, - checksum_time: None, - }) - } - }; - - // 提取用户ID和创建时间 - let user_id = extract_user_id(&token); - let create_at = extract_time(&token).map(|dt| dt.to_string()); - let checksum_time = extract_time_ks(&checksum[..8]); - - // 返回校验结果 - Json(BasicCalibrationResponse { - status: ApiStatus::Success, - message: Some("校验成功".to_string()), - user_id, - create_at, - checksum_time, - }) -} diff --git a/src/chat/route/tokens.rs b/src/chat/route/tokens.rs new file mode 100644 index 0000000..b5ce309 --- /dev/null +++ b/src/chat/route/tokens.rs @@ -0,0 +1,481 @@ +use crate::{ + app::{ + constant::{ + AUTHORIZATION_BEARER_PREFIX, CONTENT_TYPE_TEXT_HTML_WITH_UTF8, + CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8, ROUTE_TOKENS_PATH, + }, + lazy::{AUTH_TOKEN, TOKEN_LIST_FILE}, + model::{ + AppConfig, AppState, PageContent, TokenAddRequestTokenInfo, TokenInfo, + TokenUpdateRequest, TokensDeleteRequest, TokensDeleteResponse, + }, + }, + common::{ + model::{error::ChatError, ApiStatus, ErrorResponse}, + utils::{ + extract_time, extract_time_ks, extract_user_id, generate_checksum_with_default, + generate_checksum_with_repair, generate_hash, generate_timestamp_header, load_tokens, + parse_token, validate_token, validate_token_and_checksum, write_tokens, + }, + }, +}; +use axum::{ + extract::{Query, State}, + http::{ + header::{AUTHORIZATION, CONTENT_TYPE}, + HeaderMap, + }, + response::{IntoResponse, Response}, + Json, +}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub async fn handle_get_hash() -> Response { + let hash = generate_hash(); + + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8.parse().unwrap(), + ); + + (headers, hash).into_response() +} + +#[derive(Deserialize)] +pub struct ChecksumQuery { + #[serde(default)] + pub checksum: Option, +} + +pub async fn handle_get_checksum(Query(query): Query) -> Response { + let checksum = match query.checksum { + None => generate_checksum_with_default(), + Some(checksum) => generate_checksum_with_repair(&checksum), + }; + + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8.parse().unwrap(), + ); + + (headers, checksum).into_response() +} + +pub async fn handle_get_timestamp_header() -> Response { + let timestamp_header = generate_timestamp_header(); + + let mut headers = HeaderMap::new(); + headers.insert( + CONTENT_TYPE, + CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8.parse().unwrap(), + ); + + (headers, timestamp_header).into_response() +} + +pub async fn handle_get_tokens( + State(state): State>>, + headers: HeaderMap, +) -> Result, StatusCode> { + // 验证 AUTH_TOKEN + let auth_header = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if auth_header != AUTH_TOKEN.as_str() { + return Err(StatusCode::UNAUTHORIZED); + } + + let tokens = state.lock().await.token_infos.clone(); + let tokens_count = tokens.len(); + + Ok(Json(TokenInfoResponse { + status: ApiStatus::Success, + tokens: Some(tokens), + tokens_count, + message: None, + })) +} + +#[derive(Serialize)] +pub struct TokenInfoResponse { + pub status: ApiStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub tokens: Option>, + pub tokens_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +pub async fn handle_reload_tokens( + State(state): State>>, + headers: HeaderMap, +) -> Result, StatusCode> { + // 验证 AUTH_TOKEN + let auth_header = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if auth_header != AUTH_TOKEN.as_str() { + return Err(StatusCode::UNAUTHORIZED); + } + + // 重新加载 tokens + let tokens = load_tokens(); + let tokens_count = tokens.len(); + + // 更新应用状态 + { + let mut state = state.lock().await; + state.token_infos = tokens; + } + + Ok(Json(TokenInfoResponse { + status: ApiStatus::Success, + tokens: None, + tokens_count, + message: Some("Token list has been reloaded".to_string()), + })) +} + +pub async fn handle_update_tokens( + State(state): State>>, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + // 验证 AUTH_TOKEN + let auth_header = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if auth_header != AUTH_TOKEN.as_str() { + return Err(StatusCode::UNAUTHORIZED); + } + + let token_list_file = TOKEN_LIST_FILE.as_str(); + + std::fs::write(&token_list_file, &request.tokens) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // 重新加载 tokens + let token_infos = load_tokens(); + let tokens_count = token_infos.len(); + + // 更新应用状态 + { + let mut state = state.lock().await; + state.token_infos = token_infos; + } + + Ok(Json(TokenInfoResponse { + status: ApiStatus::Success, + tokens: None, + tokens_count, + message: Some("Token files have been updated and reloaded".to_string()), + })) +} + +pub async fn handle_add_tokens( + State(state): State>>, + headers: HeaderMap, + Json(request): Json>, +) -> Result, (StatusCode, Json)> { + // 验证 AUTH_TOKEN + let auth_header = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) + .ok_or(( + StatusCode::UNAUTHORIZED, + Json(ChatError::Unauthorized.to_json()), + ))?; + + if auth_header != AUTH_TOKEN.as_str() { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ChatError::Unauthorized.to_json()), + )); + } + + let token_list_file = TOKEN_LIST_FILE.as_str(); + + // 获取当前的 tokens 并创建新的 token_infos + let mut token_infos = { + let state = state.lock().await; + state.token_infos.clone() + }; + + // 创建现有token的集合 + let existing_tokens: std::collections::HashSet<_> = + token_infos.iter().map(|info| info.token.as_str()).collect(); + + // 预分配容量 + let mut new_tokens = Vec::with_capacity(request.len()); + + // 处理新的tokens + for token_info in request { + let parsed_token = parse_token(&token_info.token); + if !existing_tokens.contains(parsed_token.as_str()) && validate_token(&parsed_token) { + new_tokens.push(TokenInfo { + token: parsed_token, + // 如果提供了checksum就使用提供的,否则生成新的 + checksum: token_info + .checksum + .as_deref() + .map(generate_checksum_with_repair) + .unwrap_or_else(generate_checksum_with_default), + profile: None, + }); + } + } + + // 如果有新tokens才进行后续操作 + if !new_tokens.is_empty() { + // 预分配足够的容量 + token_infos.reserve(new_tokens.len()); + token_infos.extend(new_tokens); + + // 写入文件 + write_tokens(&token_infos, token_list_file).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + status: ApiStatus::Error, + code: None, + error: Some("Failed to update token list file".to_string()), + message: Some("无法更新token list文件".to_string()), + }), + ) + })?; + + // 获取最终的tokens数量(在更新状态之前) + let tokens_count = token_infos.len(); + + // 更新应用状态 + { + let mut state = state.lock().await; + state.token_infos = token_infos; + } + + Ok(Json(TokenInfoResponse { + status: ApiStatus::Success, + tokens: None, + tokens_count, + message: Some("New tokens have been added and reloaded".to_string()), + })) + } else { + // 如果没有新tokens,使用原始数量 + let tokens_count = token_infos.len(); + + Ok(Json(TokenInfoResponse { + status: ApiStatus::Success, + tokens: None, + tokens_count, + message: Some("No new tokens were added".to_string()), + })) + } +} + +pub async fn handle_delete_tokens( + State(state): State>>, + headers: HeaderMap, + Json(request): Json, +) -> Result, (StatusCode, Json)> { + // 验证 AUTH_TOKEN + let auth_header = headers + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix(AUTHORIZATION_BEARER_PREFIX)) + .ok_or(( + StatusCode::UNAUTHORIZED, + Json(ChatError::Unauthorized.to_json()), + ))?; + + if auth_header != AUTH_TOKEN.as_str() { + return Err(( + StatusCode::UNAUTHORIZED, + Json(ChatError::Unauthorized.to_json()), + )); + } + + let token_infos = state.lock().await.token_infos.clone(); + let original_count = token_infos.len(); // 提前存储原始长度 + + // 获取token_list文件路径 + let token_list_file = TOKEN_LIST_FILE.as_str(); + + // 创建要删除的tokens的HashSet,提高查找效率 + let tokens_to_delete: std::collections::HashSet<_> = request.tokens.iter().collect(); + + // 如果需要的话计算 failed_tokens + let failed_tokens = if request.expectation.needs_failed_tokens() { + Some( + request + .tokens + .iter() + .filter(|token| !token_infos.iter().any(|info| &info.token == *token)) + .cloned() + .collect::>(), + ) + } else { + None + }; + + // 预分配容量并过滤掉要删除的tokens + let estimated_capacity = original_count.saturating_sub(tokens_to_delete.len()); + let mut filtered_token_infos = Vec::with_capacity(estimated_capacity); + + // 一次性过滤tokens + for info in token_infos { + if !tokens_to_delete.contains(&info.token) { + filtered_token_infos.push(info); + } + } + + // 如果有tokens被删除才进行更新操作 + if filtered_token_infos.len() < original_count { + // 写入文件 + write_tokens(&filtered_token_infos, token_list_file).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + status: ApiStatus::Error, + code: None, + error: Some("Failed to update token list file".to_string()), + message: Some("无法更新token list文件".to_string()), + }), + ) + })?; + + // 如果需要的话计算 updated_tokens + let updated_tokens = if request.expectation.needs_updated_tokens() { + Some( + filtered_token_infos + .iter() + .map(|info| info.token.clone()) + .collect(), + ) + } else { + None + }; + + // 更新状态 + { + let mut state = state.lock().await; + state.token_infos = filtered_token_infos; + } + + Ok(Json(TokensDeleteResponse { + status: ApiStatus::Success, + updated_tokens, + failed_tokens, + })) + } else { + // 如果没有tokens被删除 + Ok(Json(TokensDeleteResponse { + status: ApiStatus::Success, + updated_tokens: if request.expectation.needs_updated_tokens() { + Some( + filtered_token_infos + .iter() + .map(|info| info.token.clone()) + .collect(), + ) + } else { + None + }, + failed_tokens, + })) + } +} + +pub async fn handle_tokens_page() -> impl IntoResponse { + match AppConfig::get_page_content(ROUTE_TOKENS_PATH).unwrap_or_default() { + PageContent::Default => Response::builder() + .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_HTML_WITH_UTF8) + .body(include_str!("../../../static/tokens.min.html").to_string()) + .unwrap(), + PageContent::Text(content) => Response::builder() + .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN_WITH_UTF8) + .body(content.clone()) + .unwrap(), + PageContent::Html(content) => Response::builder() + .header(CONTENT_TYPE, CONTENT_TYPE_TEXT_HTML_WITH_UTF8) + .body(content.clone()) + .unwrap(), + } +} + +#[derive(Deserialize)] +pub struct TokenRequest { + pub token: Option, +} + +#[derive(Serialize)] +pub struct BasicCalibrationResponse { + pub status: ApiStatus, + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub create_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub checksum_time: Option, +} + +pub async fn handle_basic_calibration( + Json(request): Json, +) -> Json { + // 从请求头中获取并验证 auth token + let auth_token = match request.token { + Some(token) => token, + None => { + return Json(BasicCalibrationResponse { + status: ApiStatus::Error, + message: Some("未提供授权令牌".to_string()), + user_id: None, + create_at: None, + checksum_time: None, + }) + } + }; + + // 校验 token 和 checksum + let (token, checksum) = match validate_token_and_checksum(&auth_token) { + Some(parts) => parts, + None => { + return Json(BasicCalibrationResponse { + status: ApiStatus::Error, + message: Some("无效令牌或无效校验和".to_string()), + user_id: None, + create_at: None, + checksum_time: None, + }) + } + }; + + // 提取用户ID和创建时间 + let user_id = extract_user_id(&token); + let create_at = extract_time(&token).map(|dt| dt.to_string()); + let checksum_time = extract_time_ks(&checksum[..8]); + + // 返回校验结果 + Json(BasicCalibrationResponse { + status: ApiStatus::Success, + message: Some("校验成功".to_string()), + user_id, + create_at, + checksum_time, + }) +} diff --git a/src/chat/service.rs b/src/chat/service.rs index 9fc2959..00376f9 100644 --- a/src/chat/service.rs +++ b/src/chat/service.rs @@ -4,10 +4,13 @@ use crate::{ AUTHORIZATION_BEARER_PREFIX, FINISH_REASON_STOP, OBJECT_CHAT_COMPLETION, OBJECT_CHAT_COMPLETION_CHUNK, STATUS_FAILED, STATUS_PENDING, STATUS_SUCCESS, }, - lazy::{AUTH_TOKEN, SHARED_AUTH_TOKEN, USE_SHARE}, - model::{AppConfig, AppState, ChatRequest, RequestLog, TimingInfo, TokenInfo}, + lazy::{ + AUTH_TOKEN, KEY_PREFIX, KEY_PREFIX_LEN, REQUEST_LOGS_LIMIT, SERVICE_TIMEOUT, + }, + model::{AppConfig, AppState, ChatRequest, RequestLog, TimingInfo, TokenInfo, UsageCheck}, }, chat::{ + config::KeyConfig, constant::{AVAILABLE_MODELS, USAGE_CHECK_MODELS}, error::StreamError, model::{ @@ -17,8 +20,11 @@ use crate::{ }, common::{ client::build_client, - models::{error::ChatError, userinfo::MembershipType, ErrorResponse}, - utils::{format_time_ms, get_token_profile, validate_token_and_checksum}, + model::{error::ChatError, userinfo::MembershipType, ErrorResponse}, + utils::{ + format_time_ms, from_base64, get_token_profile, tokeninfo_to_token, + validate_token_and_checksum, + }, }, }; use axum::{ @@ -33,6 +39,7 @@ use axum::{ }; use bytes::Bytes; use futures::{Stream, StreamExt}; +use prost::Message as _; use std::{ convert::Infallible, sync::{atomic::AtomicBool, Arc}, @@ -44,8 +51,6 @@ use std::{ use tokio::sync::Mutex; use uuid::Uuid; -const REQUEST_LOGS_LIMIT: usize = 1000; - // 模型列表处理 pub async fn handle_models() -> Json { Json(ModelsResponse { @@ -92,10 +97,15 @@ pub async fn handle_chat( Json(ChatError::Unauthorized.to_json()), ))?; + let mut current_config = KeyConfig::new_with_global(); + // 验证认证token并获取token信息 let (auth_token, checksum) = match auth_header { // 管理员Token验证逻辑 - token if token == AUTH_TOKEN.as_str() || (*USE_SHARE && token == SHARED_AUTH_TOKEN.as_str()) => { + token + if token == AUTH_TOKEN.as_str() + || (AppConfig::is_share() && token == AppConfig::get_share_token().as_str()) => + { static CURRENT_KEY_INDEX: AtomicUsize = AtomicUsize::new(0); let state_guard = state.lock().await; let token_infos = &state_guard.token_infos; @@ -103,7 +113,7 @@ pub async fn handle_chat( // 检查是否存在可用的token if token_infos.is_empty() { return Err(( - StatusCode::SERVICE_UNAVAILABLE, + StatusCode::SERVICE_UNAVAILABLE, Json(ChatError::NoTokens.to_json()), )); } @@ -112,7 +122,21 @@ pub async fn handle_chat( let index = CURRENT_KEY_INDEX.fetch_add(1, Ordering::SeqCst) % token_infos.len(); let token_info = &token_infos[index]; (token_info.token.clone(), token_info.checksum.clone()) - }, + } + + token if AppConfig::get_dynamic_key() && token.starts_with(&*KEY_PREFIX) => { + from_base64(&token[*KEY_PREFIX_LEN..]) + .and_then(|decoded_bytes| KeyConfig::decode(&decoded_bytes[..]).ok()) + .and_then(|key_config| { + key_config.copy_without_auth_token(&mut current_config); + key_config.auth_token + }) + .and_then(|token_info| tokeninfo_to_token(&token_info)) + .ok_or(( + StatusCode::UNAUTHORIZED, + Json(ChatError::Unauthorized.to_json()), + ))? + } // 普通用户Token验证逻辑 token => validate_token_and_checksum(token).ok_or(( @@ -121,6 +145,8 @@ pub async fn handle_chat( ))?, }; + let current_config = current_config; + let current_id: u64; // 更新请求日志 @@ -172,7 +198,14 @@ pub async fn handle_chat( current_id = next_id; // 如果需要获取用户使用情况,创建后台任务获取profile - if model.map(|m| m.is_usage_check()).unwrap_or(false) { + if model + .map(|m| { + m.is_usage_check(UsageCheck::from_proto( + current_config.usage_check_models.as_ref(), + )) + }) + .unwrap_or(false) + { let auth_token_clone = auth_token.clone(); let state_clone = state_clone.clone(); let log_id = next_id; @@ -211,13 +244,19 @@ pub async fn handle_chat( error: None, }); - if state.request_logs.len() > REQUEST_LOGS_LIMIT { + if state.request_logs.len() > *REQUEST_LOGS_LIMIT { state.request_logs.remove(0); } } // 将消息转换为hex格式 - let hex_data = match super::adapter::encode_chat_message(request.messages, &request.model).await + let hex_data = match super::adapter::encode_chat_message( + request.messages, + &request.model, + current_config.disable_vision(), + current_config.enable_slow_pool(), + ) + .await { Ok(data) => data, Err(e) => { @@ -244,27 +283,55 @@ pub async fn handle_chat( // 构建请求客户端 let client = build_client(&auth_token, &checksum); - let response = client.body(hex_data).send().await; + // 添加超时设置 + let response = tokio::time::timeout( + std::time::Duration::from_secs(*SERVICE_TIMEOUT), + client.body(hex_data).send(), + ) + .await; // 处理请求结果 let response = match response { - Ok(resp) => { - // 更新请求日志为成功 - { - let mut state = state.lock().await; - if let Some(log) = state - .request_logs - .iter_mut() - .rev() - .find(|log| log.id == current_id) + Ok(inner_response) => match inner_response { + Ok(resp) => { + // 更新请求日志为成功 { - log.status = STATUS_SUCCESS; + let mut state = state.lock().await; + if let Some(log) = state + .request_logs + .iter_mut() + .rev() + .find(|log| log.id == current_id) + { + log.status = STATUS_SUCCESS; + } } + resp } - resp - } - Err(e) => { - // 更新请求日志为失败 + Err(e) => { + // 更新请求日志为失败 + { + let mut state = state.lock().await; + if let Some(log) = state + .request_logs + .iter_mut() + .rev() + .find(|log| log.id == current_id) + { + log.status = STATUS_FAILED; + log.error = Some(e.to_string()); + } + state.active_requests -= 1; + state.error_requests += 1; + } + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ChatError::RequestFailed(e.to_string()).to_json()), + )); + } + }, + Err(_) => { + // 处理超时错误 { let mut state = state.lock().await; if let Some(log) = state @@ -274,14 +341,14 @@ pub async fn handle_chat( .find(|log| log.id == current_id) { log.status = STATUS_FAILED; - log.error = Some(e.to_string()); + log.error = Some("Request timeout".to_string()); } state.active_requests -= 1; state.error_requests += 1; } return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ChatError::RequestFailed(e.to_string()).to_json()), + StatusCode::GATEWAY_TIMEOUT, + Json(ChatError::RequestFailed("Request timeout".to_string()).to_json()), )); } }; @@ -303,9 +370,7 @@ pub async fn handle_chat( // 创建新的 stream let mut stream = response.bytes_stream(); - let enable_stream_check = AppConfig::get_stream_check(); - - if enable_stream_check { + if current_config.enable_stream_check() { // 检查第一个 chunk match stream.next().await { Some(first_chunk) => { @@ -399,6 +464,8 @@ pub async fn handle_chat( let full_text = full_text.clone(); let first_chunk_time = first_chunk_time.clone(); let state = state.clone(); + // 根据配置决定是否发送最后的 finish_reason + let include_finish_reason = current_config.include_stop_stream(); async move { let chunk = chunk.unwrap_or_default(); @@ -484,8 +551,6 @@ pub async fn handle_chat( } Ok(StreamMessage::StreamEnd) => { buffer_guard.clear(); - // 根据配置决定是否发送最后的 finish_reason - let include_finish_reason = AppConfig::get_stop_stream(); // 计算总时间和首次片段时间 let total_time = format_time_ms(start_time.elapsed().as_secs_f64()); diff --git a/src/chat/stream.rs b/src/chat/stream.rs index 25ac524..84f67bf 100644 --- a/src/chat/stream.rs +++ b/src/chat/stream.rs @@ -91,16 +91,17 @@ pub fn parse_stream_data(data: &[u8]) -> Result { // gzip压缩消息 1 => { if let Some(text) = decompress_gzip(msg_data) { - let response = StreamChatResponse::decode(&text[..]).unwrap_or_default(); - // crate::debug_println!("[gzip] StreamChatResponse: {:?}", response); - if !response.text.is_empty() { - messages.push(response.text); - } else { - // println!("[gzip] StreamChatResponse: {:?}", response); - return Ok(StreamMessage::Debug( - response.filled_prompt.unwrap_or_default(), - // response.is_using_slow_request, - )); + if let Ok(response) = StreamChatResponse::decode(&text[..]) { + // crate::debug_println!("[gzip] StreamChatResponse: {:?}", response); + if !response.text.is_empty() { + messages.push(response.text); + } else { + // println!("[gzip] StreamChatResponse: {:?}", response); + return Ok(StreamMessage::Debug( + response.filled_prompt.unwrap_or_default(), + // response.is_using_slow_request, + )); + } } } } @@ -118,8 +119,27 @@ pub fn parse_stream_data(data: &[u8]) -> Result { // messages.push(text); } } + // gzip压缩消息 + 3 => { + if let Some(text) = decompress_gzip(msg_data) { + if text.len() == 2 { + return Ok(StreamMessage::StreamEnd); + } + if let Ok(text) = String::from_utf8(text) { + // println!("JSON消息: {}", text); + if let Ok(error) = serde_json::from_str::(&text) { + return Err(StreamError::ChatError(error)); + } + // 未预计 + // messages.push(text); + } + } + } // 其他类型暂不处理 - t => eprintln!("收到未知消息类型: {},请尝试联系开发者以获取支持", t), + t => { + eprintln!("收到未知消息类型: {},请尝试联系开发者以获取支持", t); + crate::debug_println!("消息类型: {},消息内容: {}", t, hex::encode(msg_data)); + } } offset += 5 + msg_len; @@ -158,7 +178,8 @@ fn test_parse_stream_data() { // 辅助函数:将字节转换为hex字符串 fn bytes_to_hex(bytes: &[u8]) -> String { - bytes.iter() + bytes + .iter() .map(|b| format!("{:02X}", b)) .collect::>() .join("") @@ -171,7 +192,7 @@ fn test_parse_stream_data() { let msg_boundary = find_next_message_boundary(remaining_bytes); let current_msg_bytes = &remaining_bytes[..msg_boundary]; let hex_str = bytes_to_hex(current_msg_bytes); - + match parse_stream_data(current_msg_bytes) { Ok(message) => { match message { diff --git a/src/common.rs b/src/common.rs index 757ddf3..a1c74da 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,3 +1,3 @@ -pub mod models; +pub mod model; pub mod utils; pub mod client; diff --git a/src/common/client.rs b/src/common/client.rs index e8f8035..11851cb 100644 --- a/src/common/client.rs +++ b/src/common/client.rs @@ -1,22 +1,22 @@ -use crate::app::{ +use super::utils::generate_hash; +use crate::{app::{ constant::{ CONTENT_TYPE_CONNECT_PROTO, CURSOR_API2_HOST, CURSOR_HOST, CURSOR_SETTINGS_URL, HEADER_NAME_GHOST_MODE, TRUE, }, lazy::{ CURSOR_API2_CHAT_URL, CURSOR_API2_STRIPE_URL, CURSOR_USAGE_API_URL, CURSOR_USER_API_URL, - REVERSE_PROXY_HOST, USE_PROXY, + REVERSE_PROXY_HOST, USE_REVERSE_PROXY, }, -}; +}, AppConfig}; use reqwest::header::{ - ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, DNT, - HOST, ORIGIN, PRAGMA, REFERER, TE, TRANSFER_ENCODING, USER_AGENT, -}; + ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, COOKIE, + DNT, HOST, ORIGIN, PRAGMA, REFERER, TE, TRANSFER_ENCODING, USER_AGENT, + }; use reqwest::{Client, RequestBuilder}; +use std::sync::LazyLock; use uuid::Uuid; -use super::utils::generate_hash; - macro_rules! def_const { ($name:ident, $value:expr) => { const $name: &'static str = $value; @@ -44,6 +44,18 @@ def_const!(U_EQ_4, "u=4"); def_const!(PROXY_HOST, "x-co"); +pub(crate) static HTTP_CLIENT: LazyLock> = + LazyLock::new(|| parking_lot::RwLock::new(AppConfig::get_proxies().get_client())); + +/// 重新构建 HTTP 客户端 +/// +/// 当需要更新代理设置时,可以调用此方法重新创建客户端 +pub fn rebuild_http_client() { + let new_client = AppConfig::get_proxies().get_client(); + let mut client = HTTP_CLIENT.write(); + *client = new_client; +} + /// 返回预构建的 Cursor API 客户端 /// /// # 参数 @@ -58,13 +70,15 @@ def_const!(PROXY_HOST, "x-co"); pub fn build_client(auth_token: &str, checksum: &str) -> RequestBuilder { let trace_id = Uuid::new_v4().to_string(); - let client = if *USE_PROXY { - Client::new() + let client = if *USE_REVERSE_PROXY { + HTTP_CLIENT + .read() .post(&*CURSOR_API2_CHAT_URL) .header(HOST, &*REVERSE_PROXY_HOST) .header(PROXY_HOST, CURSOR_API2_HOST) } else { - Client::new() + HTTP_CLIENT + .read() .post(&*CURSOR_API2_CHAT_URL) .header(HOST, CURSOR_API2_HOST) }; @@ -96,13 +110,15 @@ pub fn build_client(auth_token: &str, checksum: &str) -> RequestBuilder { /// /// * `reqwest::RequestBuilder` - 配置好的请求构建器 pub fn build_profile_client(auth_token: &str) -> RequestBuilder { - let client = if *USE_PROXY { - Client::new() + let client = if *USE_REVERSE_PROXY { + HTTP_CLIENT + .read() .get(&*CURSOR_API2_STRIPE_URL) .header(HOST, &*REVERSE_PROXY_HOST) .header(PROXY_HOST, CURSOR_API2_HOST) } else { - Client::new() + HTTP_CLIENT + .read() .get(&*CURSOR_API2_STRIPE_URL) .header(HOST, CURSOR_API2_HOST) }; @@ -140,13 +156,15 @@ pub fn build_profile_client(auth_token: &str) -> RequestBuilder { pub fn build_usage_client(user_id: &str, auth_token: &str) -> RequestBuilder { let session_token = format!("{}%3A%3A{}", user_id, auth_token); - let client = if *USE_PROXY { - Client::new() + let client = if *USE_REVERSE_PROXY { + HTTP_CLIENT + .read() .get(&*CURSOR_USAGE_API_URL) .header(HOST, &*REVERSE_PROXY_HOST) .header(PROXY_HOST, CURSOR_HOST) } else { - Client::new() + HTTP_CLIENT + .read() .get(&*CURSOR_USAGE_API_URL) .header(HOST, CURSOR_HOST) }; @@ -187,13 +205,15 @@ pub fn build_usage_client(user_id: &str, auth_token: &str) -> RequestBuilder { pub fn build_userinfo_client(user_id: &str, auth_token: &str) -> RequestBuilder { let session_token = format!("{}%3A%3A{}", user_id, auth_token); - let client = if *USE_PROXY { - Client::new() + let client = if *USE_REVERSE_PROXY { + HTTP_CLIENT + .read() .get(&*CURSOR_USER_API_URL) .header(HOST, &*REVERSE_PROXY_HOST) .header(PROXY_HOST, CURSOR_HOST) } else { - Client::new() + HTTP_CLIENT + .read() .get(&*CURSOR_USER_API_URL) .header(HOST, CURSOR_HOST) }; diff --git a/src/common/models.rs b/src/common/model.rs similarity index 88% rename from src/common/models.rs rename to src/common/model.rs index 9333c5c..2662a28 100644 --- a/src/common/models.rs +++ b/src/common/model.rs @@ -1,6 +1,7 @@ pub mod error; pub mod health; pub mod config; +pub mod token; pub mod userinfo; use config::ConfigData; @@ -48,12 +49,12 @@ impl std::fmt::Display for NormalResponse { } } -#[derive(Serialize)] -pub struct NormalResponseNoData { - pub status: ApiStatus, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, -} +// #[derive(Serialize)] +// pub struct NormalResponseNoData { +// pub status: ApiStatus, +// #[serde(skip_serializing_if = "Option::is_none")] +// pub message: Option, +// } #[derive(Serialize)] pub struct ErrorResponse { diff --git a/src/common/models/config.rs b/src/common/model/config.rs similarity index 59% rename from src/common/models/config.rs rename to src/common/model/config.rs index 8c14448..493fad6 100644 --- a/src/common/models/config.rs +++ b/src/common/model/config.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::app::model::{PageContent, UsageCheck, VisionAbility}; +use crate::app::model::{PageContent, UsageCheck, VisionAbility, Proxies}; #[derive(Serialize)] pub struct ConfigData { @@ -10,27 +10,26 @@ pub struct ConfigData { pub vision_ability: VisionAbility, pub enable_slow_pool: bool, pub enable_all_claude: bool, - pub check_usage_models: UsageCheck, + pub usage_check_models: UsageCheck, + pub enable_dynamic_key: bool, + #[serde(skip_serializing_if = "String::is_empty")] + pub share_token: String, + pub proxies: Proxies, } -#[derive(Deserialize)] +#[derive(Deserialize, Default)] +#[serde(default)] pub struct ConfigUpdateRequest { - #[serde(default)] pub action: String, // "get", "update", "reset" - #[serde(default)] pub path: String, - #[serde(default)] pub content: Option, // "default", "text", "html" - #[serde(default)] pub enable_stream_check: Option, - #[serde(default)] pub include_stop_stream: Option, - #[serde(default)] pub vision_ability: Option, - #[serde(default)] pub enable_slow_pool: Option, - #[serde(default)] pub enable_all_claude: Option, - #[serde(default)] - pub check_usage_models: Option, + pub usage_check_models: Option, + pub enable_dynamic_key: Option, + pub share_token: Option, + pub proxies: Option, } diff --git a/src/common/models/error.rs b/src/common/model/error.rs similarity index 100% rename from src/common/models/error.rs rename to src/common/model/error.rs diff --git a/src/common/models/health.rs b/src/common/model/health.rs similarity index 100% rename from src/common/models/health.rs rename to src/common/model/health.rs diff --git a/src/common/model/token.rs b/src/common/model/token.rs new file mode 100644 index 0000000..14242f3 --- /dev/null +++ b/src/common/model/token.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub struct TokenPayload { + pub sub: String, + pub time: String, + pub randomness: String, + pub exp: i64, + pub iss: String, + pub scope: String, + pub aud: String, +} diff --git a/src/common/models/userinfo.rs b/src/common/model/userinfo.rs similarity index 100% rename from src/common/models/userinfo.rs rename to src/common/model/userinfo.rs diff --git a/src/common/utils.rs b/src/common/utils.rs index bbe20c8..7d85634 100644 --- a/src/common/utils.rs +++ b/src/common/utils.rs @@ -1,12 +1,15 @@ mod checksum; +use ::base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; pub use checksum::*; -mod tokens; -pub use tokens::*; +mod token; +pub use token::*; +mod base64; +pub use base64::*; -use super::models::userinfo::{StripeProfile, TokenProfile, UsageProfile, UserProfile}; +use super::model::{token::TokenPayload, userinfo::{StripeProfile, TokenProfile, UsageProfile, UserProfile}}; use crate::app::{ - constant::{FALSE, TRUE}, - lazy::{TOKEN_DELIMITER, TOKEN_DELIMITER_LEN}, + constant::{COMMA, FALSE, TRUE}, + lazy::{TOKEN_DELIMITER, USE_COMMA_DELIMITER}, }; pub fn parse_bool_from_env(key: &str, default: bool) -> bool { @@ -102,10 +105,20 @@ pub async fn get_user_profile(auth_token: &str) -> Option { } pub fn validate_token_and_checksum(auth_token: &str) -> Option<(String, String)> { - // 找最后一个逗号 - let comma_pos = auth_token.rfind(*TOKEN_DELIMITER)?; + // 尝试使用自定义分隔符查找 + let mut delimiter_pos = auth_token.rfind(*TOKEN_DELIMITER); + + // 如果自定义分隔符未找到,并且 USE_COMMA_DELIMITER 为 true,则尝试使用逗号 + if delimiter_pos.is_none() && *USE_COMMA_DELIMITER { + delimiter_pos = auth_token.rfind(COMMA); + } + + // 如果最终都没有找到分隔符,则返回 None + let comma_pos = delimiter_pos?; + + // 使用找到的分隔符位置分割字符串 let (token_part, checksum) = auth_token.split_at(comma_pos); - let checksum = &checksum[*TOKEN_DELIMITER_LEN..]; // 跳过逗号 + let checksum = &checksum[1..]; // 跳过逗号 // 解析 token - 为了向前兼容,忽略最后一个:或%3A前的内容 let colon_pos = token_part.rfind(':'); @@ -124,15 +137,23 @@ pub fn validate_token_and_checksum(auth_token: &str) -> Option<(String, String)> // 验证 token 和 checksum 有效性 if validate_token(token) && validate_checksum(checksum) { - Some((token.to_string(), checksum.to_string())) + Some((token.to_string(), generate_checksum_with_repair(checksum))) } else { None } } pub fn extract_token(auth_token: &str) -> Option { - // 解析 token - let token_part = match auth_token.rfind(*TOKEN_DELIMITER) { + // 尝试使用自定义分隔符查找 + let mut delimiter_pos = auth_token.rfind(*TOKEN_DELIMITER); + + // 如果自定义分隔符未找到,并且 USE_COMMA_DELIMITER 为 true,则尝试使用逗号 + if delimiter_pos.is_none() && *USE_COMMA_DELIMITER { + delimiter_pos = auth_token.rfind(COMMA); + } + + // 根据是否找到分隔符来确定 token_part + let token_part = match delimiter_pos { Some(pos) => &auth_token[..pos], None => auth_token, }; @@ -163,3 +184,78 @@ pub fn extract_token(auth_token: &str) -> Option { pub fn format_time_ms(seconds: f64) -> f64 { (seconds * 1000.0).round() / 1000.0 } + +use crate::chat::config::key_config; + +/// 将 JWT token 转换为 TokenInfo +pub fn token_to_tokeninfo(auth_token: &str) -> Option { + let (token, checksum) = validate_token_and_checksum(auth_token)?; + + // JWT token 由3部分组成,用 . 分隔 + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + + // 解码 payload (第二部分) + let payload = match URL_SAFE_NO_PAD.decode(parts[1]) { + Ok(decoded) => decoded, + Err(_) => return None, + }; + + // 将 payload 转换为字符串 + let payload_str = match String::from_utf8(payload) { + Ok(s) => s, + Err(_) => return None, + }; + + // 解析为 TokenPayload + let payload: TokenPayload = match serde_json::from_str(&payload_str) { + Ok(p) => p, + Err(_) => return None, + }; + + let (machine_id_hash, mac_id_hash) = extract_hashes(&checksum)?; + + // 构建 TokenInfo + Some(key_config::TokenInfo { + sub: payload.sub, + exp: payload.exp, + randomness: payload.randomness, + signature: parts[2].to_string(), + machine_id: machine_id_hash, + mac_id: mac_id_hash, + }) +} + +/// 将 TokenInfo 转换为 JWT token +pub fn tokeninfo_to_token(info: &key_config::TokenInfo) -> Option<(String, String)> { + // 构建 payload + let payload = TokenPayload { + sub: info.sub.clone(), + exp: info.exp, + randomness: info.randomness.clone(), + time: (info.exp - 2592000000).to_string(), // exp - 30000天 + iss: ISSUER.to_string(), + scope: SCOPE.to_string(), + aud: AUDIENCE.to_string(), + }; + + let payload_str = match serde_json::to_string(&payload) { + Ok(s) => s, + Err(_) => return None, + }; + + let payload_b64 = URL_SAFE_NO_PAD.encode(payload_str.as_bytes()); + + // 从 TokenInfo 中获取 machine_id 和 mac_id 的 hex 字符串 + let device_id = hex::encode(&info.machine_id); + let mac_addr = if !info.mac_id.is_empty() { + Some(hex::encode(&info.mac_id)) + } else { + None + }; + + // 组合 token + Some((format!("{}.{}.{}", HEADER_B64, payload_b64, info.signature), generate_checksum(&device_id, mac_addr.as_deref()))) +} diff --git a/src/common/utils/base64.rs b/src/common/utils/base64.rs new file mode 100644 index 0000000..59bfe9a --- /dev/null +++ b/src/common/utils/base64.rs @@ -0,0 +1,148 @@ +// Base64 字符集 (a-z, A-Z, 0-9, -, _) +const BASE64_CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + +// 预计算的 Base64 查找表,用于快速解码 +const BASE64_LOOKUP: [i8; 256] = { + let mut lookup = [-1i8; 256]; + let mut i = 0; + while i < BASE64_CHARS.len() { + lookup[BASE64_CHARS[i] as usize] = i as i8; + i += 1; + } + lookup +}; + +/// 将字节切片编码为 Base64 字符串。 +/// +/// # Arguments +/// +/// * `bytes`: 要编码的字节切片 +/// +/// # Returns +/// +/// 编码后的 Base64 字符串 +pub fn to_base64(bytes: &[u8]) -> String { + // 预分配足够容量,避免多次分配内存 + let capacity = (bytes.len() + 2) / 3 * 4; + let mut result = Vec::with_capacity(capacity); + + // 每三个字节为一组进行处理 + for chunk in bytes.chunks(3) { + // 将三个字节合并为一个 u32 + let b1 = chunk[0] as u32; + let b2 = chunk.get(1).map_or(0, |&b| b as u32); + let b3 = chunk.get(2).map_or(0, |&b| b as u32); + + let n = (b1 << 16) | (b2 << 8) | b3; + + // 将 u32 拆分成四个 6 位的值,并根据查找表转换为 Base64 字符 + result.push(BASE64_CHARS[(n >> 18) as usize]); + result.push(BASE64_CHARS[((n >> 12) & 0x3F) as usize]); + + // 如果 chunk 长度大于 1,则需要处理第二个字符 + if chunk.len() > 1 { + result.push(BASE64_CHARS[((n >> 6) & 0x3F) as usize]); + // 如果 chunk 长度大于 2,则需要处理第三个字符 + if chunk.len() > 2 { + result.push(BASE64_CHARS[(n & 0x3F) as usize]); + } + } + } + + // 使用 from_utf8_unchecked 提高性能,因为 BASE64_CHARS 都是有效的 ASCII 字符 + unsafe { String::from_utf8_unchecked(result) } +} + +/// 将 Base64 字符串解码为字节数组。 +/// +/// # Arguments +/// +/// * `input`: 要解码的 Base64 字符串 +/// +/// # Returns +/// +/// 如果解码成功,返回 Some(解码后的字节数组);如果输入无效,返回 None +pub fn from_base64(input: &str) -> Option> { + let input = input.as_bytes(); + + // 检查输入长度,Base64 编码的长度必须是 4 的倍数或余 2/3 + if input.is_empty() || input.len() % 4 == 1 { + return None; + } + + // 检查是否包含无效字符,无效字符直接返回None + if input.iter().any(|&b| BASE64_LOOKUP[b as usize] == -1) { + return None; + } + + // 预分配足够容量,避免多次分配内存 + let capacity = input.len() / 4 * 3; + let mut result = Vec::with_capacity(capacity); + + // 每四个字符为一组进行处理 + let mut chunks = input.chunks_exact(4); + for chunk in &mut chunks { + // 使用查找表将 Base64 字符转换为 6 位的值 + let n1 = BASE64_LOOKUP[chunk[0] as usize] as u32; + let n2 = BASE64_LOOKUP[chunk[1] as usize] as u32; + let n3 = BASE64_LOOKUP[chunk[2] as usize] as u32; + let n4 = BASE64_LOOKUP[chunk[3] as usize] as u32; + + // 将四个 6 位的值合并为一个 u32,并拆分成三个字节 + let n = (n1 << 18) | (n2 << 12) | (n3 << 6) | n4; + result.push((n >> 16) as u8); + result.push(((n >> 8) & 0xFF) as u8); + result.push((n & 0xFF) as u8); + } + + // 处理剩余的字符 + let remainder = chunks.remainder(); + if !remainder.is_empty() { + let n1 = BASE64_LOOKUP[remainder[0] as usize] as u32; + let n2 = BASE64_LOOKUP[remainder[1] as usize] as u32; + + let mut n = (n1 << 18) | (n2 << 12); + result.push((n >> 16) as u8); + + // 如果剩余字符长度大于 2,则需要处理第二个字节 + if remainder.len() > 2 { + let n3 = BASE64_LOOKUP[remainder[2] as usize] as u32; + n |= n3 << 6; + result.push(((n >> 8) & 0xFF) as u8); + } + } + + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_base64_roundtrip() { + let test_cases = vec![ + vec![0u8, 1, 2, 3], + vec![255u8, 254, 253], + vec![0u8], + vec![0u8, 1], + vec![0u8, 1, 2], + vec![255u8; 1000], + ]; + + for case in test_cases { + let encoded = to_base64(&case); + let decoded = from_base64(&encoded).unwrap(); + assert_eq!(case, decoded); + } + } + + #[test] + fn test_invalid_input() { + assert_eq!(from_base64(""), None); // 空字符串 + assert_eq!(from_base64("a"), None); // 长度为 1 + assert_eq!(from_base64("!@#$"), None); // 无效字符 + assert_eq!(from_base64("YWJj!"), None); // 包含无效字符 + assert!(from_base64("YWJj").is_some()); // 有效输入 + } +} diff --git a/src/common/utils/checksum.rs b/src/common/utils/checksum.rs index 2a62796..1616c20 100644 --- a/src/common/utils/checksum.rs +++ b/src/common/utils/checksum.rs @@ -47,7 +47,7 @@ pub fn generate_timestamp_header() -> String { BASE64.encode(×tamp_bytes) } -fn generate_checksum(device_id: &str, mac_addr: Option<&str>) -> String { +pub fn generate_checksum(device_id: &str, mac_addr: Option<&str>) -> String { let encoded = generate_timestamp_header(); match mac_addr { Some(mac) => format!("{}{}/{}", encoded, device_id, mac), @@ -60,141 +60,66 @@ pub fn generate_checksum_with_default() -> String { } pub fn generate_checksum_with_repair(checksum: &str) -> String { - // 预校验:检查字符串是否为空或只包含合法的Base64字符和'/' - if checksum.is_empty() - || !checksum - .chars() - .all(|c| (c.is_ascii_alphanumeric() || c == '/' || c == '+' || c == '=')) - { + let bytes = checksum.as_bytes(); + let len = bytes.len(); + + // 长度快速检查 + if len != 72 && len != 129 && len != 137 { return generate_checksum_with_default(); } - // 尝试修复时间戳头的函数 - fn try_fix_timestamp(timestamp_base64: &str) -> Option { - if let Ok(timestamp_bytes) = BASE64.decode(timestamp_base64) { - if timestamp_bytes.len() == 6 { - let mut fixed_bytes = timestamp_bytes.clone(); - deobfuscate_bytes(&mut fixed_bytes); + // 单次遍历完成所有字符校验 + for (i, &b) in bytes.iter().enumerate() { + let valid = match (len, i) { + // 通用字符校验(排除非法字符) + (_, _) if !b.is_ascii_alphanumeric() && b != b'/' && b != b'+' && b != b'=' => false, - // 检查前3位是否为0 - if fixed_bytes[0..3].iter().all(|&x| x == 0) { - // 从后四位构建时间戳 - let timestamp = ((fixed_bytes[2] as u64) << 24) - | ((fixed_bytes[3] as u64) << 16) - | ((fixed_bytes[4] as u64) << 8) - | (fixed_bytes[5] as u64); + // 72字节格式:时间戳(8) + 设备哈希(64) + (72, 8..=71) => b.is_ascii_hexdigit(), - let current_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - / 1_000; + // 129字节格式:设备哈希(64) + '/' + MAC哈希(64) + (129, 0..=63) => b.is_ascii_hexdigit(), + (129, 64) => b == b'/', + (129, 65..=128) => b.is_ascii_hexdigit(), - if timestamp <= current_timestamp { - // 修复时间戳字节 - fixed_bytes[0] = fixed_bytes[4]; - fixed_bytes[1] = fixed_bytes[5]; + // 137字节格式:时间戳(8) + 设备哈希(64) + '/' + MAC哈希(64) + (137, 8..=71) => b.is_ascii_hexdigit(), + (137, 72) => b == b'/', + (137, 73..=136) => b.is_ascii_hexdigit(), - obfuscate_bytes(&mut fixed_bytes); - return Some(BASE64.encode(&fixed_bytes)); - } - } - } - } - None - } + // 时间戳部分不需要校验 + (72 | 137, 0..=7) => true, - if checksum.len() == 8 { - // 尝试修复时间戳头 - if let Some(fixed_timestamp) = try_fix_timestamp(checksum) { - return format!("{}{}/{}", fixed_timestamp, generate_hash(), generate_hash()); - } + _ => unreachable!(), + }; - // 验证原始时间戳 - if let Some(timestamp) = extract_time_ks(checksum) { - let current_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - / 1_000; - - if timestamp <= current_timestamp { - return format!("{}{}/{}", checksum, generate_hash(), generate_hash()); - } - } - } else if checksum.len() > 8 { - // 处理可能包含hash的情况 - let parts: Vec<&str> = checksum.split('/').collect(); - match parts.len() { - 1 => { - let timestamp_base64 = &checksum[..8]; - let device_id = &checksum[8..]; - - if is_valid_hash(device_id) { - // 先尝试修复时间戳 - if let Some(fixed_timestamp) = try_fix_timestamp(timestamp_base64) { - return format!("{}{}/{}", fixed_timestamp, device_id, generate_hash()); - } - - // 验证原始时间戳 - if let Some(timestamp) = extract_time_ks(timestamp_base64) { - let current_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - / 1_000; - - if timestamp <= current_timestamp { - return format!( - "{}{}/{}", - generate_timestamp_header(), - device_id, - generate_hash() - ); - } - } - } - } - 2 => { - let first_part = parts[0]; - let mac_hash = parts[1]; - - if is_valid_hash(mac_hash) && first_part.len() == mac_hash.len() + 8 { - let timestamp_base64 = &first_part[..8]; - let device_id = &first_part[8..]; - - if is_valid_hash(device_id) { - // 先尝试修复时间戳 - if let Some(fixed_timestamp) = try_fix_timestamp(timestamp_base64) { - return format!("{}{}/{}", fixed_timestamp, device_id, mac_hash); - } - - // 验证原始时间戳 - if let Some(timestamp) = extract_time_ks(timestamp_base64) { - let current_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - / 1_000; - - if timestamp <= current_timestamp { - return format!( - "{}{}/{}", - generate_timestamp_header(), - device_id, - mac_hash - ); - } - } - } - } - } - _ => {} + if !valid { + return generate_checksum_with_default(); } } - // 如果所有修复尝试都失败,返回默认值 - generate_checksum_with_default() + // 校验通过后构造结果 + match len { + 72 => format!( + "{}{}/{}", + generate_timestamp_header(), + unsafe { std::str::from_utf8_unchecked(&bytes[8..]) }, + generate_hash() + ), + 129 => format!( + "{}{}/{}", + generate_timestamp_header(), + unsafe { std::str::from_utf8_unchecked(&bytes[..64]) }, + unsafe { std::str::from_utf8_unchecked(&bytes[65..]) } + ), + 137 => format!( + "{}{}/{}", + generate_timestamp_header(), + unsafe { std::str::from_utf8_unchecked(&bytes[8..72]) }, + unsafe { std::str::from_utf8_unchecked(&bytes[73..]) } + ), + _ => unreachable!(), + } } pub fn extract_time_ks(timestamp_base64: &str) -> Option { @@ -220,72 +145,73 @@ pub fn extract_time_ks(timestamp_base64: &str) -> Option { } pub fn validate_checksum(checksum: &str) -> bool { - // 预校验:检查字符串是否为空或只包含合法的Base64字符和'/' - if checksum.is_empty() - || !checksum - .chars() - .all(|c| (c.is_ascii_alphanumeric() || c == '/' || c == '+' || c == '=')) - { - return false; - } - // 首先检查是否包含基本的 base64 编码部分和 hash 格式的 device_id - let parts: Vec<&str> = checksum.split('/').collect(); + let bytes = checksum.as_bytes(); + let len = bytes.len(); - match parts.len() { - // 没有 MAC 地址的情况 - 1 => { - if checksum.len() < 72 { - // 8 + 64 = 72 - return false; - } - - // 解码前8个字符的base64时间戳 - let timestamp_base64 = &checksum[..8]; - let timestamp = match extract_time_ks(timestamp_base64) { - Some(ts) => ts, - None => return false, - }; - - let current_timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() - / 1_000; - - if current_timestamp < timestamp { - return false; - } - - // 验证 device_id hash 部分 - is_valid_hash(&checksum[8..]) - } - // 包含 MAC hash 的情况 - 2 => { - let first_part = parts[0]; - let mac_hash = parts[1]; - - // MAC hash 必须是64字符的十六进制 - if !is_valid_hash(mac_hash) { - return false; - } - - // 检查第一部分比MAC hash多8个字符 - if first_part.len() != mac_hash.len() + 8 { - return false; - } - - // 递归验证第一部分 - validate_checksum(first_part) - } - _ => false, - } -} - -fn is_valid_hash(hash: &str) -> bool { - if hash.len() < 64 { + // 长度门控 + if len != 72 && len != 137 { return false; } - // 检查是否都是有效的十六进制字符 - hash.chars().all(|c| c.is_ascii_hexdigit()) + // 单次遍历完成所有字符校验 + for (i, &b) in bytes.iter().enumerate() { + let valid = match (len, i) { + // 通用字符校验(排除非法字符) + (_, _) if !b.is_ascii_alphanumeric() && b != b'/' && b != b'+' && b != b'=' => false, + + // 格式校验 + (72, 0..=7) => true, // 时间戳部分(由extract_time_ks验证) + (72, 8..=71) => b.is_ascii_hexdigit(), + + (137, 0..=7) => true, // 时间戳 + (137, 8..=71) => b.is_ascii_hexdigit(), // 设备哈希 + (137, 72) => b == b'/', // 分割符(索引72是第73个字符) + (137, 73..=136) => b.is_ascii_hexdigit(), // MAC哈希 + + _ => unreachable!(), + }; + + if !valid { + return false; + } + } + + // 统一时间戳验证(无需分层) + let time_valid = extract_time_ks(&checksum[..8]).is_some(); + + // 附加MAC哈希长度校验(仅137字符需要) + let mac_hash_valid = if len == 137 { + checksum[73..].len() == 64 // 确保MAC哈希长度为64 + } else { + true // 72字符无需此检查 + }; + + time_valid && mac_hash_valid +} + +/// 从校验通过的checksum中提取哈希值(需先通过validate_checksum验证) +/// 返回 (device_hash, mac_hash) ,mac_hash可能为空Vec +pub fn extract_hashes(checksum: &str) -> Option<(Vec, Vec)> { + // 前置条件:必须通过校验(确保长度和格式正确) + if !validate_checksum(checksum) { + return None; + } + + // 根据长度直接切割,无需字符级验证(validate_checksum已保证) + match checksum.len() { + 72 => { + // 格式:8字节时间戳 + 64字节设备哈希 + let device_hash = hex::decode(&checksum[8..]).ok()?; // 8..72 + Some((device_hash, Vec::new())) + } + 137 => { + // 格式:8时间戳 + 64设备哈希 + '/' + 64MAC哈希 + // 直接按固定位置切割(validate_checksum已确保索引72是'/') + let device_hash = hex::decode(&checksum[8..72]).ok()?; + let mac_hash = hex::decode(&checksum[73..]).ok()?; // 73..137 + Some((device_hash, mac_hash)) + } + // validate_checksum已过滤其他长度,此处应为不可达代码 + _ => unreachable!("Invalid length after validation: {}", checksum.len()), + } } diff --git a/src/common/utils/tokens.rs b/src/common/utils/token.rs similarity index 56% rename from src/common/utils/tokens.rs rename to src/common/utils/token.rs index 2ea41af..65fe259 100644 --- a/src/common/utils/tokens.rs +++ b/src/common/utils/token.rs @@ -1,11 +1,12 @@ -use crate::{ - app::{ - constant::EMPTY_STRING, - model::TokenInfo, - lazy::{TOKEN_FILE, TOKEN_LIST_FILE}, - }, - common::utils::generate_checksum_with_default, +use super::generate_checksum_with_repair; +use crate::app::{ + constant::{COMMA, EMPTY_STRING}, + lazy::TOKEN_LIST_FILE, + model::TokenInfo, }; +use crate::common::model::token::TokenPayload; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use chrono::{DateTime, Local, TimeZone}; // 规范化文件内容并写入 fn normalize_and_write(content: &str, file_path: &str) -> String { @@ -19,65 +20,37 @@ fn normalize_and_write(content: &str, file_path: &str) -> String { } // 解析token -fn parse_token(token_part: &str) -> Option { +pub fn parse_token(token_part: &str) -> String { // 查找最后一个:或%3A的位置 let colon_pos = token_part.rfind(':'); let encoded_colon_pos = token_part.rfind("%3A"); - + match (colon_pos, encoded_colon_pos) { - (None, None) => Some(token_part.to_string()), - (Some(pos1), None) => Some(token_part[(pos1 + 1)..].to_string()), - (None, Some(pos2)) => Some(token_part[(pos2 + 3)..].to_string()), + (None, None) => token_part.to_string(), + (Some(pos1), None) => token_part[(pos1 + 1)..].to_string(), + (None, Some(pos2)) => token_part[(pos2 + 3)..].to_string(), (Some(pos1), Some(pos2)) => { // 取较大的位置作为分隔点 let pos = pos1.max(pos2); let start = if pos == pos2 { pos + 3 } else { pos + 1 }; - Some(token_part[start..].to_string()) + token_part[start..].to_string() } } } // Token 加载函数 pub fn load_tokens() -> Vec { - let token_file = TOKEN_FILE.as_str(); let token_list_file = TOKEN_LIST_FILE.as_str(); // 确保文件存在 - for file in [&token_file, &token_list_file] { - if !std::path::Path::new(file).exists() { - if let Err(e) = std::fs::write(file, EMPTY_STRING) { - eprintln!("警告: 无法创建文件 '{}': {}", file, e); - } + if !std::path::Path::new(&token_list_file).exists() { + if let Err(e) = std::fs::write(&token_list_file, EMPTY_STRING) { + eprintln!("警告: 无法创建文件 '{}': {}", &token_list_file, e); } } - // 读取和规范化 token 文件 - let token_entries = match std::fs::read_to_string(&token_file) { - Ok(content) => { - let normalized = content.replace("\r\n", "\n"); - normalized - .lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') { - return None; - } - let parsed = parse_token(line); - if parsed.is_none() || !validate_token(&parsed.as_ref().unwrap()) { - return None; - } - parsed - }) - .collect::>() - } - Err(e) => { - eprintln!("警告: 无法读取token文件 '{}': {}", token_file, e); - Vec::new() - } - }; - // 读取和规范化 token-list 文件 - let mut token_map: std::collections::HashMap = + let token_map: std::collections::HashMap = match std::fs::read_to_string(&token_list_file) { Ok(content) => { let normalized = normalize_and_write(&content, &token_list_file); @@ -89,11 +62,11 @@ pub fn load_tokens() -> Vec { return None; } - let parts: Vec<&str> = line.split(',').collect(); + let parts: Vec<&str> = line.split(COMMA).collect(); match parts[..] { [token_part, checksum] => { - let token = parse_token(token_part)?; - Some((token, checksum.to_string())) + let token = parse_token(token_part); + Some((token, generate_checksum_with_repair(checksum))) } _ => { eprintln!("警告: 忽略无效的token-list行: {}", line); @@ -109,21 +82,10 @@ pub fn load_tokens() -> Vec { } }; - // 更新或添加新token - for token in token_entries { - if !token_map.contains_key(&token) { - // 为新token生成checksum - let checksum = generate_checksum_with_default(); - token_map.insert(token, checksum); - } - } - // 更新 token-list 文件 let token_list_content = token_map .iter() - .map(|(token, checksum)| { - format!("{},{}", token, checksum) - }) + .map(|(token, checksum)| format!("{},{}", token, checksum)) .collect::>() .join("\n"); @@ -142,8 +104,20 @@ pub fn load_tokens() -> Vec { .collect() } -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use chrono::{DateTime, Local, TimeZone}; +pub fn write_tokens(token_infos: &[TokenInfo], file_path: &str) -> std::io::Result<()> { + let content = token_infos + .iter() + .map(|info| format!("{},{}", info.token, info.checksum)) + .collect::>() + .join("\n"); + + std::fs::write(file_path, content) +} + +pub(super) const HEADER_B64: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; +pub(super) const ISSUER: &str = "https://authentication.cursor.sh"; +pub(super) const SCOPE: &str = "openid profile email offline_access"; +pub(super) const AUDIENCE: &str = "https://cursor.com"; // 验证jwt token是否有效 pub fn validate_token(token: &str) -> bool { @@ -153,7 +127,7 @@ pub fn validate_token(token: &str) -> bool { return false; } - if parts[0] != "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" { + if parts[0] != HEADER_B64 { return false; } @@ -169,66 +143,61 @@ pub fn validate_token(token: &str) -> bool { Err(_) => return false, }; - // 解析 JSON - let payload_json: serde_json::Value = match serde_json::from_str(&payload_str) { - Ok(v) => v, + // 解析为 TokenPayload + let payload: TokenPayload = match serde_json::from_str(&payload_str) { + Ok(p) => p, Err(_) => return false, }; - // 验证必要字段是否存在且有效 - let required_fields = ["sub", "time", "randomness", "exp", "iss", "scope", "aud"]; - for field in required_fields { - if !payload_json.get(field).is_some() { - return false; - } - } - // 验证 time 字段 - if let Some(time) = payload_json["time"].as_str() { - // 验证 time 是否为有效的数字字符串 - if let Ok(time_value) = time.parse::() { - let current_time = chrono::Utc::now().timestamp(); - if time_value > current_time { - return false; - } - } else { + if let Ok(time_value) = payload.time.parse::() { + let current_time = chrono::Utc::now().timestamp(); + if time_value > current_time { return false; } } else { return false; } - // 验证 randomness 长度 - if let Some(randomness) = payload_json["randomness"].as_str() { - if randomness.len() != 18 { + // 验证 randomness 格式 + let bytes = payload.randomness.as_bytes(); + if bytes.len() != 18 { + return false; + } + + // 单次遍历完成所有字符校验 + for (i, &b) in bytes.iter().enumerate() { + let valid = match i { + // 16进制数字部分 + 0..=7 | 9..=12 | 14..=17 => b.is_ascii_hexdigit(), + // 连字符部分 + 8 | 13 => b == b'-', + _ => unreachable!(), + }; + + if !valid { return false; } - } else { - return false; } // 验证过期时间 - if let Some(exp) = payload_json["exp"].as_i64() { - let current_time = chrono::Utc::now().timestamp(); - if current_time > exp { - return false; - } - } else { + let current_time = chrono::Utc::now().timestamp(); + if current_time > payload.exp { return false; } // 验证发行者 - if payload_json["iss"].as_str() != Some("https://authentication.cursor.sh") { + if payload.iss != ISSUER { return false; } // 验证授权范围 - if payload_json["scope"].as_str() != Some("openid profile email offline_access") { + if payload.scope != SCOPE { return false; } // 验证受众 - if payload_json["aud"].as_str() != Some("https://cursor.com") { + if payload.aud != AUDIENCE { return false; } @@ -255,16 +224,21 @@ pub fn extract_user_id(token: &str) -> Option { Err(_) => return None, }; - // 解析 JSON - let payload_json: serde_json::Value = match serde_json::from_str(&payload_str) { - Ok(v) => v, + // 解析为 TokenPayload + let payload: TokenPayload = match serde_json::from_str(&payload_str) { + Ok(p) => p, Err(_) => return None, }; // 提取 sub 字段 - payload_json["sub"] - .as_str() - .map(|s| s.split('|').nth(1).unwrap_or(s).to_string()) + Some( + payload + .sub + .split('|') + .nth(1) + .unwrap_or(&payload.sub) + .to_string(), + ) } // 从 JWT token 中提取 time 字段 @@ -287,15 +261,16 @@ pub fn extract_time(token: &str) -> Option> { Err(_) => return None, }; - // 解析 JSON - let payload_json: serde_json::Value = match serde_json::from_str(&payload_str) { - Ok(v) => v, + // 解析为 TokenPayload + let payload: TokenPayload = match serde_json::from_str(&payload_str) { + Ok(p) => p, Err(_) => return None, }; // 提取时间戳并转换为本地时间 - payload_json["time"] - .as_str() - .and_then(|t| t.parse::().ok()) + payload + .time + .parse::() + .ok() .and_then(|timestamp| Local.timestamp_opt(timestamp, 0).single()) } diff --git a/src/main.rs b/src/main.rs index a249f27..0c36c7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,12 @@ mod common; use app::{ config::handle_config_update, constant::{ - EMPTY_STRING, PKG_VERSION, ROUTE_ABOUT_PATH, ROUTE_API_PATH, ROUTE_BASIC_CALIBRATION_PATH, ROUTE_CONFIG_PATH, ROUTE_ENV_EXAMPLE_PATH, ROUTE_GET_CHECKSUM, ROUTE_GET_HASH, ROUTE_GET_TIMESTAMP_HEADER, ROUTE_GET_TOKENINFO_PATH, ROUTE_HEALTH_PATH, ROUTE_LOGS_PATH, ROUTE_README_PATH, ROUTE_ROOT_PATH, ROUTE_STATIC_PATH, ROUTE_TOKENINFO_PATH, ROUTE_UPDATE_TOKENINFO_PATH, ROUTE_USER_INFO_PATH + PKG_VERSION, ROUTE_ABOUT_PATH, ROUTE_API_PATH, ROUTE_BASIC_CALIBRATION_PATH, + ROUTE_BUILD_KEY_PATH, ROUTE_CONFIG_PATH, ROUTE_ENV_EXAMPLE_PATH, ROUTE_GET_CHECKSUM, + ROUTE_GET_HASH, ROUTE_GET_TIMESTAMP_HEADER, ROUTE_HEALTH_PATH, ROUTE_LOGS_PATH, + ROUTE_README_PATH, ROUTE_ROOT_PATH, ROUTE_STATIC_PATH, ROUTE_TOKENS_ADD_PATH, + ROUTE_TOKENS_DELETE_PATH, ROUTE_TOKENS_GET_PATH, ROUTE_TOKENS_PATH, + ROUTE_TOKENS_RELOAD_PATH, ROUTE_TOKENS_UPDATE_PATH, ROUTE_USER_INFO_PATH, }, lazy::{AUTH_TOKEN, ROUTE_CHAT_PATH, ROUTE_MODELS_PATH}, model::*, @@ -16,13 +21,16 @@ use axum::{ }; use chat::{ route::{ - handle_about, handle_api_page, handle_basic_calibration, handle_config_page, handle_env_example, handle_get_checksum, handle_get_hash, handle_get_timestamp_header, handle_get_tokeninfo, handle_health, handle_logs, handle_logs_post, handle_readme, handle_root, handle_static, handle_tokeninfo_page, handle_update_tokeninfo, handle_update_tokeninfo_post, handle_user_info + handle_about, handle_add_tokens, handle_api_page, handle_basic_calibration, + handle_build_key, handle_build_key_page, handle_config_page, handle_delete_tokens, + handle_env_example, handle_get_checksum, handle_get_hash, handle_get_timestamp_header, + handle_get_tokens, handle_health, handle_logs, handle_logs_post, handle_readme, + handle_reload_tokens, handle_root, handle_static, handle_tokens_page, handle_update_tokens, + handle_user_info, }, service::{handle_chat, handle_models}, }; -use common::utils::{ - load_tokens, parse_bool_from_env, parse_string_from_env, parse_usize_from_env, -}; +use common::utils::{load_tokens, parse_string_from_env, parse_usize_from_env}; use std::sync::Arc; use tokio::sync::Mutex; use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer}; @@ -47,13 +55,7 @@ async fn main() { }; // 初始化全局配置 - AppConfig::init( - parse_bool_from_env("ENABLE_STREAM_CHECK", true), - parse_bool_from_env("INCLUDE_STOP_REASON_STREAM", true), - VisionAbility::from_str(&parse_string_from_env("VISION_ABILITY", EMPTY_STRING)), - parse_bool_from_env("ENABLE_SLOW_POOL", false), - parse_bool_from_env("PASS_ANY_CLAUDE", false), - ); + AppConfig::init(); // 加载 tokens let token_infos = load_tokens(); @@ -61,18 +63,42 @@ async fn main() { // 初始化应用状态 let state = Arc::new(Mutex::new(AppState::new(token_infos))); + // 创建一个克隆用于后台任务 + let state_for_reload = state.clone(); + + // 启动后台任务在每个整1000秒时更新 checksum + tokio::spawn(async move { + loop { + // 获取当前时间戳 + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + // 计算距离下一个整1000秒的等待时间 + let next_reload = (now / 1000 + 1) * 1000; + let wait_duration = next_reload - now; + + // 等待到下一个整1000秒 + tokio::time::sleep(std::time::Duration::from_secs(wait_duration)).await; + + let mut app_state = state_for_reload.lock().await; + app_state.update_checksum(); + debug_println!("checksum 自动刷新: {}", next_reload); + } + }); + // 设置路由 let app = Router::new() .route(ROUTE_ROOT_PATH, get(handle_root)) .route(ROUTE_HEALTH_PATH, get(handle_health)) - .route(ROUTE_TOKENINFO_PATH, get(handle_tokeninfo_page)) + .route(ROUTE_TOKENS_PATH, get(handle_tokens_page)) .route(ROUTE_MODELS_PATH.as_str(), get(handle_models)) - .route(ROUTE_UPDATE_TOKENINFO_PATH, get(handle_update_tokeninfo)) - .route(ROUTE_GET_TOKENINFO_PATH, post(handle_get_tokeninfo)) - .route( - ROUTE_UPDATE_TOKENINFO_PATH, - post(handle_update_tokeninfo_post), - ) + .route(ROUTE_TOKENS_GET_PATH, post(handle_get_tokens)) + .route(ROUTE_TOKENS_RELOAD_PATH, post(handle_reload_tokens)) + .route(ROUTE_TOKENS_UPDATE_PATH, post(handle_update_tokens)) + .route(ROUTE_TOKENS_ADD_PATH, post(handle_add_tokens)) + .route(ROUTE_TOKENS_DELETE_PATH, post(handle_delete_tokens)) .route(ROUTE_CHAT_PATH.as_str(), post(handle_chat)) .route(ROUTE_LOGS_PATH, get(handle_logs)) .route(ROUTE_LOGS_PATH, post(handle_logs_post)) @@ -88,6 +114,8 @@ async fn main() { .route(ROUTE_GET_TIMESTAMP_HEADER, get(handle_get_timestamp_header)) .route(ROUTE_BASIC_CALIBRATION_PATH, post(handle_basic_calibration)) .route(ROUTE_USER_INFO_PATH, post(handle_user_info)) + .route(ROUTE_BUILD_KEY_PATH, get(handle_build_key_page)) + .route(ROUTE_BUILD_KEY_PATH, post(handle_build_key)) .layer(RequestBodyLimitLayer::new( 1024 * 1024 * parse_usize_from_env("REQUEST_BODY_LIMIT_MB", 2), )) @@ -99,6 +127,9 @@ async fn main() { let addr = format!("0.0.0.0:{}", port); println!("服务器运行在端口 {}", port); println!("当前版本: v{}", PKG_VERSION); + // if PKG_VERSION.contains("pre") { + println!("当前是测试版,有问题及时反馈哦~"); + // } let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); diff --git a/static/api.html b/static/api.html index 5228d64..58bccee 100644 --- a/static/api.html +++ b/static/api.html @@ -140,7 +140,7 @@
-
+