mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
fix: Kiro OAuth token refresh and health check
- Fix health check request format with correct fields (chatTriggerType, modelId, origin) - Load clientIdHash file correctly for IdC authentication - Add success notifications for token refresh and health check - Clean up debug println statements, use tracing instead - Fix ESLint warnings Version: 0.6.1
This commit is contained in:
@@ -13,7 +13,10 @@
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo fmt:*)"
|
||||
"Bash(cargo fmt:*)",
|
||||
"Bash(lsof:*)",
|
||||
"Bash(xargs kill:*)",
|
||||
"Bash(cargo run:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "proxycast",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
@@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -2592,6 +2593,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.4.2.tgz",
|
||||
"integrity": "sha512-lNIn5CZuw8WZOn8zHzmFmDSzg5zfohWoa3mdULP0YFh/VogVdMVWZPcWSHlydsiJhRQYaTNSYKN7RmZKE2lCYQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.3.tgz",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -22,6 +22,7 @@
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+461
-2
@@ -70,6 +70,50 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "ashpd"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.2",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"url",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
@@ -484,6 +528,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
@@ -671,6 +724,19 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.5"
|
||||
@@ -780,6 +846,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.3",
|
||||
]
|
||||
|
||||
@@ -794,6 +862,15 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlib"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlopen2"
|
||||
version = "0.8.2"
|
||||
@@ -817,6 +894,12 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
@@ -882,6 +965,33 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -909,6 +1019,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -1077,6 +1208,19 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
@@ -2225,6 +2369,19 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodrop"
|
||||
version = "0.1.14"
|
||||
@@ -2609,6 +2766,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.3"
|
||||
@@ -2644,6 +2811,12 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2862,7 +3035,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.12.1",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -2980,12 +3153,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"axum",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"indexmap 2.12.1",
|
||||
@@ -2999,6 +3173,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-autostart",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-shell",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -3008,6 +3183,15 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -3057,6 +3241,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -3077,6 +3271,16 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.5.1"
|
||||
@@ -3095,6 +3299,15 @@ dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -3242,6 +3455,31 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
|
||||
dependencies = [
|
||||
"ashpd",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.3",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3406,6 +3644,12 @@ dependencies = [
|
||||
"syn 2.0.111",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -3829,6 +4073,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -4155,6 +4405,46 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.17",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.17",
|
||||
"toml 0.9.8",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.3"
|
||||
@@ -4405,6 +4695,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4692,6 +4983,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unic-char-property"
|
||||
version = "0.9.0"
|
||||
@@ -4966,6 +5268,66 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix",
|
||||
"scoped-tls",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"rustix",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.37.5",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.83"
|
||||
@@ -5705,6 +6067,62 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 0.7.14",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"winnow 0.7.14",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.31"
|
||||
@@ -5833,3 +6251,44 @@ dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 0.7.14",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.111",
|
||||
"winnow 0.7.14",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -36,6 +37,7 @@ serde_yaml = "0.9"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
zip = "0.6"
|
||||
anyhow = "1"
|
||||
dashmap = "5"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open"
|
||||
"shell:allow-open",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for ProxyCast","local":true,"windows":["main"],"permissions":["core:default","shell:allow-open","dialog:default"]}}
|
||||
@@ -2396,6 +2396,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2396,6 +2396,72 @@
|
||||
"const": "core:window:deny-unminimize",
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-message",
|
||||
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-open",
|
||||
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-save",
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-message",
|
||||
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the open command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-open",
|
||||
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the save command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-save",
|
||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n\n#### This default permission set includes:\n\n- `allow-open`",
|
||||
"type": "string",
|
||||
|
||||
@@ -2,5 +2,7 @@ pub mod config_cmd;
|
||||
pub mod mcp_cmd;
|
||||
pub mod oauth_cmd;
|
||||
pub mod prompt_cmd;
|
||||
pub mod provider_pool_cmd;
|
||||
pub mod route_cmd;
|
||||
pub mod skill_cmd;
|
||||
pub mod switch_cmd;
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
//! Provider Pool Tauri 命令
|
||||
|
||||
use crate::database::DbConnection;
|
||||
use crate::database::dao::provider_pool::ProviderPoolDao;
|
||||
use crate::models::provider_pool_model::{
|
||||
AddCredentialRequest, CredentialData, CredentialDisplay, HealthCheckResult, OAuthStatus,
|
||||
ProviderCredential, ProviderPoolOverview, UpdateCredentialRequest,
|
||||
};
|
||||
use crate::services::provider_pool_service::ProviderPoolService;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
|
||||
pub struct ProviderPoolServiceState(pub Arc<ProviderPoolService>);
|
||||
|
||||
/// 展开路径中的 ~ 为用户主目录
|
||||
fn expand_tilde(path: &str) -> String {
|
||||
if path.starts_with("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(&path[2..]).to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
path.to_string()
|
||||
}
|
||||
|
||||
/// 获取应用凭证存储目录
|
||||
fn get_credentials_dir() -> Result<PathBuf, String> {
|
||||
let app_data_dir = dirs::data_dir()
|
||||
.ok_or_else(|| "无法获取应用数据目录".to_string())?
|
||||
.join("proxycast")
|
||||
.join("credentials");
|
||||
|
||||
// 确保目录存在
|
||||
if !app_data_dir.exists() {
|
||||
fs::create_dir_all(&app_data_dir)
|
||||
.map_err(|e| format!("创建凭证存储目录失败: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(app_data_dir)
|
||||
}
|
||||
|
||||
/// 复制并重命名 OAuth 凭证文件
|
||||
fn copy_and_rename_credential_file(
|
||||
source_path: &str,
|
||||
provider_type: &str
|
||||
) -> Result<String, String> {
|
||||
let expanded_source = expand_tilde(source_path);
|
||||
let source = Path::new(&expanded_source);
|
||||
|
||||
// 验证源文件存在
|
||||
if !source.exists() {
|
||||
return Err(format!("凭证文件不存在: {}", expanded_source));
|
||||
}
|
||||
|
||||
// 生成新的文件名:{provider_type}_{uuid}_{timestamp}.json
|
||||
let uuid = Uuid::new_v4().to_string();
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let new_filename = format!("{}_{}_{}_{}.json",
|
||||
provider_type,
|
||||
&uuid[..8], // 使用 UUID 前8位
|
||||
timestamp,
|
||||
provider_type
|
||||
);
|
||||
|
||||
// 获取目标目录
|
||||
let credentials_dir = get_credentials_dir()?;
|
||||
let target_path = credentials_dir.join(&new_filename);
|
||||
|
||||
// 复制文件
|
||||
fs::copy(&source, &target_path)
|
||||
.map_err(|e| format!("复制凭证文件失败: {}", e))?;
|
||||
|
||||
// 返回新的文件路径
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 删除凭证文件(如果在应用存储目录中)
|
||||
fn cleanup_credential_file(file_path: &str) -> Result<(), String> {
|
||||
let path = Path::new(file_path);
|
||||
|
||||
// 只删除在应用凭证存储目录中的文件
|
||||
if let Ok(credentials_dir) = get_credentials_dir() {
|
||||
if let Ok(canonical_path) = path.canonicalize() {
|
||||
if let Ok(canonical_dir) = credentials_dir.canonicalize() {
|
||||
if canonical_path.starts_with(canonical_dir) {
|
||||
if let Err(e) = fs::remove_file(&canonical_path) {
|
||||
// 只记录警告,不中断删除过程
|
||||
println!("Warning: Failed to delete credential file: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取凭证池概览
|
||||
#[tauri::command]
|
||||
pub fn get_provider_pool_overview(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
) -> Result<Vec<ProviderPoolOverview>, String> {
|
||||
pool_service.0.get_overview(&db)
|
||||
}
|
||||
|
||||
/// 获取指定类型的凭证列表
|
||||
#[tauri::command]
|
||||
pub fn get_provider_pool_credentials(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
provider_type: String,
|
||||
) -> Result<Vec<CredentialDisplay>, String> {
|
||||
pool_service.0.get_by_type(&db, &provider_type)
|
||||
}
|
||||
|
||||
/// 添加凭证
|
||||
#[tauri::command]
|
||||
pub fn add_provider_pool_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
request: AddCredentialRequest,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
&request.provider_type,
|
||||
request.credential,
|
||||
request.name,
|
||||
request.check_health,
|
||||
request.check_model_name,
|
||||
)
|
||||
}
|
||||
|
||||
/// 更新凭证
|
||||
#[tauri::command]
|
||||
pub fn update_provider_pool_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
uuid: String,
|
||||
request: UpdateCredentialRequest,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 如果需要重新上传文件,先处理文件上传
|
||||
if let Some(new_file_path) = request.new_creds_file_path {
|
||||
// 获取当前凭证以确定类型
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let current_credential = ProviderPoolDao::get_by_uuid(&conn, &uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("凭证不存在: {}", uuid))?;
|
||||
|
||||
// 根据凭证类型复制新文件
|
||||
let new_stored_path = match ¤t_credential.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
// 清理旧文件
|
||||
cleanup_credential_file(creds_file_path)?;
|
||||
copy_and_rename_credential_file(&new_file_path, "kiro")?
|
||||
},
|
||||
CredentialData::GeminiOAuth { creds_file_path, .. } => {
|
||||
// 清理旧文件
|
||||
cleanup_credential_file(creds_file_path)?;
|
||||
copy_and_rename_credential_file(&new_file_path, "gemini")?
|
||||
},
|
||||
CredentialData::QwenOAuth { creds_file_path } => {
|
||||
// 清理旧文件
|
||||
cleanup_credential_file(creds_file_path)?;
|
||||
copy_and_rename_credential_file(&new_file_path, "qwen")?
|
||||
},
|
||||
_ => {
|
||||
return Err("只有 OAuth 凭证支持重新上传文件".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
// 更新凭证数据
|
||||
let mut updated_cred = current_credential;
|
||||
|
||||
// 更新凭证数据中的文件路径
|
||||
match &mut updated_cred.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
*creds_file_path = new_stored_path;
|
||||
},
|
||||
CredentialData::GeminiOAuth { creds_file_path, project_id } => {
|
||||
*creds_file_path = new_stored_path;
|
||||
if let Some(new_pid) = request.new_project_id {
|
||||
*project_id = Some(new_pid);
|
||||
}
|
||||
},
|
||||
CredentialData::QwenOAuth { creds_file_path } => {
|
||||
*creds_file_path = new_stored_path;
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 应用其他更新
|
||||
if let Some(name) = request.name {
|
||||
updated_cred.name = Some(name);
|
||||
}
|
||||
if let Some(is_disabled) = request.is_disabled {
|
||||
updated_cred.is_disabled = is_disabled;
|
||||
}
|
||||
if let Some(check_health) = request.check_health {
|
||||
updated_cred.check_health = check_health;
|
||||
}
|
||||
if let Some(check_model_name) = request.check_model_name {
|
||||
updated_cred.check_model_name = Some(check_model_name);
|
||||
}
|
||||
if let Some(not_supported_models) = request.not_supported_models {
|
||||
updated_cred.not_supported_models = not_supported_models;
|
||||
}
|
||||
|
||||
updated_cred.updated_at = Utc::now();
|
||||
|
||||
// 保存到数据库
|
||||
ProviderPoolDao::update(&conn, &updated_cred).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(updated_cred)
|
||||
} else {
|
||||
// 常规更新,不涉及文件
|
||||
pool_service.0.update_credential(
|
||||
&db,
|
||||
&uuid,
|
||||
request.name,
|
||||
request.is_disabled,
|
||||
request.check_health,
|
||||
request.check_model_name,
|
||||
request.not_supported_models,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除凭证
|
||||
#[tauri::command]
|
||||
pub fn delete_provider_pool_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
uuid: String,
|
||||
) -> Result<bool, String> {
|
||||
pool_service.0.delete_credential(&db, &uuid)
|
||||
}
|
||||
|
||||
/// 切换凭证启用/禁用状态
|
||||
#[tauri::command]
|
||||
pub fn toggle_provider_pool_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
uuid: String,
|
||||
is_disabled: bool,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
pool_service.0.update_credential(
|
||||
&db,
|
||||
&uuid,
|
||||
None,
|
||||
Some(is_disabled),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 重置凭证计数器
|
||||
#[tauri::command]
|
||||
pub fn reset_provider_pool_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
uuid: String,
|
||||
) -> Result<(), String> {
|
||||
pool_service.0.reset_counters(&db, &uuid)
|
||||
}
|
||||
|
||||
/// 重置指定类型的所有凭证健康状态
|
||||
#[tauri::command]
|
||||
pub fn reset_provider_pool_health(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
provider_type: String,
|
||||
) -> Result<usize, String> {
|
||||
pool_service.0.reset_health_by_type(&db, &provider_type)
|
||||
}
|
||||
|
||||
/// 执行单个凭证的健康检查
|
||||
#[tauri::command]
|
||||
pub async fn check_provider_pool_credential_health(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
uuid: String,
|
||||
) -> Result<HealthCheckResult, String> {
|
||||
tracing::info!("[DEBUG] 开始健康检查 for uuid: {}", uuid);
|
||||
let result = pool_service.0.check_credential_health(&db, &uuid).await;
|
||||
match &result {
|
||||
Ok(health) => tracing::info!("[DEBUG] 健康检查完成: success={}, message={:?}", health.success, health.message),
|
||||
Err(err) => tracing::error!("[DEBUG] 健康检查失败: {}", err),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 执行指定类型的所有凭证健康检查
|
||||
#[tauri::command]
|
||||
pub async fn check_provider_pool_type_health(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
provider_type: String,
|
||||
) -> Result<Vec<HealthCheckResult>, String> {
|
||||
pool_service.0.check_type_health(&db, &provider_type).await
|
||||
}
|
||||
|
||||
/// 添加 Kiro OAuth 凭证(通过文件路径)
|
||||
#[tauri::command]
|
||||
pub fn add_kiro_oauth_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
creds_file_path: String,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "kiro")?;
|
||||
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"kiro",
|
||||
CredentialData::KiroOAuth {
|
||||
creds_file_path: stored_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 Gemini OAuth 凭证(通过文件路径)
|
||||
#[tauri::command]
|
||||
pub fn add_gemini_oauth_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
creds_file_path: String,
|
||||
project_id: Option<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "gemini")?;
|
||||
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"gemini",
|
||||
CredentialData::GeminiOAuth {
|
||||
creds_file_path: stored_file_path,
|
||||
project_id,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 Qwen OAuth 凭证(通过文件路径)
|
||||
#[tauri::command]
|
||||
pub fn add_qwen_oauth_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
creds_file_path: String,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "qwen")?;
|
||||
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"qwen",
|
||||
CredentialData::QwenOAuth {
|
||||
creds_file_path: stored_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 OpenAI API Key 凭证
|
||||
#[tauri::command]
|
||||
pub fn add_openai_key_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
api_key: String,
|
||||
base_url: Option<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"openai",
|
||||
CredentialData::OpenAIKey { api_key, base_url },
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 Claude API Key 凭证
|
||||
#[tauri::command]
|
||||
pub fn add_claude_key_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
api_key: String,
|
||||
base_url: Option<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"claude",
|
||||
CredentialData::ClaudeKey { api_key, base_url },
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新凭证的 OAuth Token
|
||||
#[tauri::command]
|
||||
pub async fn refresh_pool_credential_token(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
uuid: String,
|
||||
) -> Result<String, String> {
|
||||
tracing::info!("[DEBUG] 开始刷新 Token for uuid: {}", uuid);
|
||||
let result = pool_service.0.refresh_credential_token(&db, &uuid).await;
|
||||
match &result {
|
||||
Ok(msg) => tracing::info!("[DEBUG] Token 刷新成功: {}", msg),
|
||||
Err(err) => tracing::error!("[DEBUG] Token 刷新失败: {}", err),
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 获取凭证的 OAuth 状态
|
||||
#[tauri::command]
|
||||
pub fn get_pool_credential_oauth_status(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
uuid: String,
|
||||
) -> Result<OAuthStatus, String> {
|
||||
pool_service.0.get_credential_oauth_status(&db, &uuid)
|
||||
}
|
||||
|
||||
/// 调试 Kiro 凭证加载(从默认路径)
|
||||
#[tauri::command]
|
||||
pub async fn debug_kiro_credentials() -> Result<String, String> {
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
|
||||
let mut provider = KiroProvider::new();
|
||||
|
||||
let mut result = String::new();
|
||||
result.push_str("🔍 开始 Kiro 凭证调试 (默认路径)...\n\n");
|
||||
|
||||
match provider.load_credentials().await {
|
||||
Ok(_) => {
|
||||
result.push_str("✅ 凭证加载成功!\n");
|
||||
result.push_str(&format!("📄 认证方式: {:?}\n", provider.credentials.auth_method));
|
||||
result.push_str(&format!("🔑 有 client_id: {}\n", provider.credentials.client_id.is_some()));
|
||||
result.push_str(&format!("🔒 有 client_secret: {}\n", provider.credentials.client_secret.is_some()));
|
||||
result.push_str(&format!("🏷️ 有 clientIdHash: {}\n", provider.credentials.client_id_hash.is_some()));
|
||||
|
||||
if let Some(hash) = &provider.credentials.client_id_hash {
|
||||
result.push_str(&format!("🔗 clientIdHash: {}\n", hash));
|
||||
}
|
||||
|
||||
let detected_method = provider.detect_auth_method();
|
||||
result.push_str(&format!("🎯 检测到的认证方式: {}\n", detected_method));
|
||||
|
||||
let refresh_url = provider.get_refresh_url();
|
||||
result.push_str(&format!("🌐 刷新端点: {}\n", refresh_url));
|
||||
|
||||
if let Some(client_id) = &provider.credentials.client_id {
|
||||
result.push_str(&format!("🆔 client_id 前缀: {}...\n", &client_id[..std::cmp::min(20, client_id.len())]));
|
||||
}
|
||||
|
||||
result.push_str("\n🚀 尝试刷新 token...\n");
|
||||
match provider.refresh_token().await {
|
||||
Ok(token) => {
|
||||
result.push_str(&format!("✅ Token 刷新成功! Token 长度: {}\n", token.len()));
|
||||
result.push_str(&format!("🎫 Token 前缀: {}...\n", &token[..std::cmp::min(50, token.len())]));
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ Token 刷新失败: {}\n", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ 凭证加载失败: {}\n", e));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 测试用户上传的凭证文件
|
||||
#[tauri::command]
|
||||
pub async fn test_user_credentials() -> Result<String, String> {
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let mut result = String::new();
|
||||
result.push_str("🧪 测试用户上传的凭证文件...\n\n");
|
||||
|
||||
// 测试用户上传的凭证文件路径
|
||||
let user_creds_path = dirs::home_dir()
|
||||
.ok_or("无法获取用户主目录".to_string())?
|
||||
.join("Library/Application Support/proxycast/credentials/kiro_d8da9d58_1765757992_kiro.json");
|
||||
|
||||
result.push_str(&format!("📂 用户凭证路径: {}\n", user_creds_path.display()));
|
||||
|
||||
// 检查文件是否存在
|
||||
if !user_creds_path.exists() {
|
||||
result.push_str("❌ 用户凭证文件不存在!\n");
|
||||
result.push_str("💡 请确保文件路径正确,或重新上传凭证文件\n");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
result.push_str("✅ 用户凭证文件存在\n\n");
|
||||
|
||||
// 读取并解析用户凭证文件
|
||||
match std::fs::read_to_string(&user_creds_path) {
|
||||
Ok(content) => {
|
||||
result.push_str("✅ 成功读取凭证文件\n");
|
||||
result.push_str(&format!("📄 文件大小: {} 字节\n", content.len()));
|
||||
|
||||
// 尝试解析 JSON
|
||||
match serde_json::from_str::<serde_json::Value>(&content) {
|
||||
Ok(json) => {
|
||||
result.push_str("✅ JSON 格式有效\n");
|
||||
|
||||
// 检查关键字段
|
||||
let has_access_token = json.get("accessToken").and_then(|v| v.as_str()).is_some();
|
||||
let has_refresh_token = json.get("refreshToken").and_then(|v| v.as_str()).is_some();
|
||||
let auth_method = json.get("authMethod").and_then(|v| v.as_str());
|
||||
let client_id_hash = json.get("clientIdHash").and_then(|v| v.as_str());
|
||||
let region = json.get("region").and_then(|v| v.as_str());
|
||||
|
||||
result.push_str(&format!("🔑 有 accessToken: {}\n", has_access_token));
|
||||
result.push_str(&format!("🔄 有 refreshToken: {}\n", has_refresh_token));
|
||||
result.push_str(&format!("📄 authMethod: {:?}\n", auth_method));
|
||||
result.push_str(&format!("🏷️ clientIdHash: {:?}\n", client_id_hash));
|
||||
result.push_str(&format!("🌍 region: {:?}\n", region));
|
||||
|
||||
if let Some(hash) = client_id_hash {
|
||||
// 检查 clientIdHash 对应的文件
|
||||
let hash_file_path = dirs::home_dir()
|
||||
.unwrap()
|
||||
.join(".aws/sso/cache")
|
||||
.join(format!("{}.json", hash));
|
||||
|
||||
result.push_str(&format!("\n🔗 检查 clientIdHash 文件: {}\n", hash_file_path.display()));
|
||||
|
||||
if hash_file_path.exists() {
|
||||
result.push_str("✅ clientIdHash 文件存在\n");
|
||||
|
||||
match std::fs::read_to_string(&hash_file_path) {
|
||||
Ok(hash_content) => {
|
||||
match serde_json::from_str::<serde_json::Value>(&hash_content) {
|
||||
Ok(hash_json) => {
|
||||
let has_client_id = hash_json.get("clientId").and_then(|v| v.as_str()).is_some();
|
||||
let has_client_secret = hash_json.get("clientSecret").and_then(|v| v.as_str()).is_some();
|
||||
|
||||
result.push_str(&format!("🆔 hash 文件有 clientId: {}\n", has_client_id));
|
||||
result.push_str(&format!("🔒 hash 文件有 clientSecret: {}\n", has_client_secret));
|
||||
|
||||
if has_client_id && has_client_secret {
|
||||
result.push_str("✅ IdC 认证配置完整!\n");
|
||||
} else {
|
||||
result.push_str("⚠️ IdC 认证配置不完整,将使用 social 认证\n");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ 无法解析 hash 文件 JSON: {}\n", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ 无法读取 hash 文件: {}\n", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push_str("❌ clientIdHash 文件不存在\n");
|
||||
}
|
||||
}
|
||||
|
||||
// 现在使用我们的 KiroProvider 来测试加载
|
||||
result.push_str("\n🔧 使用 KiroProvider 测试加载...\n");
|
||||
|
||||
let mut provider = KiroProvider::new();
|
||||
// 设置凭证路径到用户文件
|
||||
provider.creds_path = Some(user_creds_path.clone());
|
||||
|
||||
match provider.load_credentials_from_path(&user_creds_path.to_string_lossy()).await {
|
||||
Ok(_) => {
|
||||
result.push_str("✅ KiroProvider 加载成功!\n");
|
||||
result.push_str(&format!("📄 最终认证方式: {:?}\n", provider.credentials.auth_method));
|
||||
result.push_str(&format!("🔑 最终有 client_id: {}\n", provider.credentials.client_id.is_some()));
|
||||
result.push_str(&format!("🔒 最终有 client_secret: {}\n", provider.credentials.client_secret.is_some()));
|
||||
|
||||
let detected_method = provider.detect_auth_method();
|
||||
result.push_str(&format!("🎯 检测到的认证方式: {}\n", detected_method));
|
||||
|
||||
let refresh_url = provider.get_refresh_url();
|
||||
result.push_str(&format!("🌐 刷新端点: {}\n", refresh_url));
|
||||
|
||||
result.push_str("\n🚀 尝试刷新 token...\n");
|
||||
match provider.refresh_token().await {
|
||||
Ok(token) => {
|
||||
result.push_str(&format!("✅ Token 刷新成功! Token 长度: {}\n", token.len()));
|
||||
result.push_str(&format!("🎫 Token 前缀: {}...\n", &token[..std::cmp::min(50, token.len())]));
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ Token 刷新失败: {}\n", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ KiroProvider 加载失败: {}\n", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ JSON 格式无效: {}\n", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
result.push_str(&format!("❌ 无法读取凭证文件: {}\n", e));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! 路由相关 Tauri 命令
|
||||
|
||||
use crate::commands::provider_pool_cmd::ProviderPoolServiceState;
|
||||
use crate::config;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::route_model::{RouteInfo, RouteListResponse};
|
||||
|
||||
/// 获取所有可用的路由端点
|
||||
#[tauri::command]
|
||||
pub async fn get_available_routes(
|
||||
db: tauri::State<'_, DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
) -> Result<RouteListResponse, String> {
|
||||
// 获取配置中的服务器地址
|
||||
let config = config::load_config().unwrap_or_default();
|
||||
let base_url = format!("http://{}:{}", config.server.host, config.server.port);
|
||||
|
||||
let routes = pool_service
|
||||
.0
|
||||
.get_available_routes(db.inner(), &base_url)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 添加默认路由
|
||||
let mut all_routes = vec![RouteInfo {
|
||||
selector: "default".to_string(),
|
||||
provider_type: "kiro".to_string(),
|
||||
credential_count: 1,
|
||||
endpoints: vec![
|
||||
crate::models::route_model::RouteEndpoint {
|
||||
path: "/v1/messages".to_string(),
|
||||
protocol: "claude".to_string(),
|
||||
url: format!("{}/v1/messages", base_url),
|
||||
},
|
||||
crate::models::route_model::RouteEndpoint {
|
||||
path: "/v1/chat/completions".to_string(),
|
||||
protocol: "openai".to_string(),
|
||||
url: format!("{}/v1/chat/completions", base_url),
|
||||
},
|
||||
],
|
||||
tags: vec!["默认".to_string()],
|
||||
enabled: true,
|
||||
}];
|
||||
all_routes.extend(routes);
|
||||
|
||||
Ok(RouteListResponse {
|
||||
base_url,
|
||||
default_provider: "kiro".to_string(),
|
||||
routes: all_routes,
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取指定路由的 curl 示例
|
||||
#[tauri::command]
|
||||
pub async fn get_route_curl_examples(
|
||||
selector: String,
|
||||
db: tauri::State<'_, DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
) -> Result<Vec<crate::models::route_model::CurlExample>, String> {
|
||||
let config = config::load_config().unwrap_or_default();
|
||||
let base_url = format!("http://{}:{}", config.server.host, config.server.port);
|
||||
|
||||
let routes = pool_service
|
||||
.0
|
||||
.get_available_routes(db.inner(), &base_url)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 查找匹配的路由
|
||||
let route = routes
|
||||
.iter()
|
||||
.find(|r| r.selector == selector)
|
||||
.or_else(|| {
|
||||
// 如果是默认路由
|
||||
if selector == "default" {
|
||||
None // 返回 None 让下面的代码生成默认示例
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let api_key = &config.server.api_key;
|
||||
|
||||
match route {
|
||||
Some(r) => Ok(r.generate_curl_examples(api_key)),
|
||||
None => {
|
||||
// 生成默认路由的示例
|
||||
let mut default_route = RouteInfo::new("default".to_string(), "kiro".to_string());
|
||||
default_route.add_endpoint(&base_url, "claude");
|
||||
default_route.add_endpoint(&base_url, "openai");
|
||||
Ok(default_route.generate_curl_examples(api_key))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod mcp;
|
||||
pub mod prompts;
|
||||
pub mod provider_pool;
|
||||
pub mod providers;
|
||||
pub mod skills;
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Provider Pool 数据访问对象
|
||||
//!
|
||||
//! 提供凭证池的 CRUD 操作。
|
||||
|
||||
use crate::models::provider_pool_model::{
|
||||
CachedTokenInfo, CredentialData, PoolProviderType, ProviderCredential, ProviderPools,
|
||||
};
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
pub struct ProviderPoolDao;
|
||||
|
||||
impl ProviderPoolDao {
|
||||
/// 获取所有凭证
|
||||
pub fn get_all(conn: &Connection) -> Result<Vec<ProviderCredential>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uuid, provider_type, credential_data, name, is_healthy, is_disabled,
|
||||
check_health, check_model_name, not_supported_models, usage_count, error_count,
|
||||
last_used, last_error_time, last_error_message, last_health_check_time,
|
||||
last_health_check_model, created_at, updated_at
|
||||
FROM provider_pool_credentials
|
||||
ORDER BY provider_type, created_at ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([], |row| Self::row_to_credential(row))?;
|
||||
|
||||
let mut credentials = Vec::new();
|
||||
for row in rows {
|
||||
if let Ok(cred) = row {
|
||||
credentials.push(cred);
|
||||
}
|
||||
}
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// 获取指定类型的凭证
|
||||
pub fn get_by_type(
|
||||
conn: &Connection,
|
||||
provider_type: &PoolProviderType,
|
||||
) -> Result<Vec<ProviderCredential>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uuid, provider_type, credential_data, name, is_healthy, is_disabled,
|
||||
check_health, check_model_name, not_supported_models, usage_count, error_count,
|
||||
last_used, last_error_time, last_error_message, last_health_check_time,
|
||||
last_health_check_model, created_at, updated_at
|
||||
FROM provider_pool_credentials
|
||||
WHERE provider_type = ?1
|
||||
ORDER BY created_at ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map([provider_type.to_string()], |row| Self::row_to_credential(row))?;
|
||||
|
||||
let mut credentials = Vec::new();
|
||||
for row in rows {
|
||||
if let Ok(cred) = row {
|
||||
credentials.push(cred);
|
||||
}
|
||||
}
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// 获取指定 UUID 的凭证
|
||||
pub fn get_by_uuid(
|
||||
conn: &Connection,
|
||||
uuid: &str,
|
||||
) -> Result<Option<ProviderCredential>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uuid, provider_type, credential_data, name, is_healthy, is_disabled,
|
||||
check_health, check_model_name, not_supported_models, usage_count, error_count,
|
||||
last_used, last_error_time, last_error_message, last_health_check_time,
|
||||
last_health_check_model, created_at, updated_at
|
||||
FROM provider_pool_credentials
|
||||
WHERE uuid = ?1",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([uuid])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::row_to_credential(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据名称获取凭证
|
||||
pub fn get_by_name(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
) -> Result<Option<ProviderCredential>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uuid, provider_type, credential_data, name, is_healthy, is_disabled,
|
||||
check_health, check_model_name, not_supported_models, usage_count, error_count,
|
||||
last_used, last_error_time, last_error_message, last_health_check_time,
|
||||
last_health_check_model, created_at, updated_at
|
||||
FROM provider_pool_credentials
|
||||
WHERE name = ?1",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([name])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(Self::row_to_credential(row)?))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有凭证按类型分组
|
||||
pub fn get_grouped(conn: &Connection) -> Result<ProviderPools, rusqlite::Error> {
|
||||
let all = Self::get_all(conn)?;
|
||||
let mut grouped: ProviderPools = std::collections::HashMap::new();
|
||||
|
||||
for cred in all {
|
||||
grouped
|
||||
.entry(cred.provider_type)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(cred);
|
||||
}
|
||||
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
/// 插入新凭证
|
||||
pub fn insert(conn: &Connection, cred: &ProviderCredential) -> Result<(), rusqlite::Error> {
|
||||
let credential_json =
|
||||
serde_json::to_string(&cred.credential).unwrap_or_else(|_| "{}".to_string());
|
||||
let not_supported_models_json =
|
||||
serde_json::to_string(&cred.not_supported_models).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO provider_pool_credentials
|
||||
(uuid, provider_type, credential_data, name, is_healthy, is_disabled,
|
||||
check_health, check_model_name, not_supported_models, usage_count, error_count,
|
||||
last_used, last_error_time, last_error_message, last_health_check_time,
|
||||
last_health_check_model, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
params![
|
||||
cred.uuid,
|
||||
cred.provider_type.to_string(),
|
||||
credential_json,
|
||||
cred.name,
|
||||
cred.is_healthy,
|
||||
cred.is_disabled,
|
||||
cred.check_health,
|
||||
cred.check_model_name,
|
||||
not_supported_models_json,
|
||||
cred.usage_count,
|
||||
cred.error_count,
|
||||
cred.last_used.map(|t| t.timestamp()),
|
||||
cred.last_error_time.map(|t| t.timestamp()),
|
||||
cred.last_error_message,
|
||||
cred.last_health_check_time.map(|t| t.timestamp()),
|
||||
cred.last_health_check_model,
|
||||
cred.created_at.timestamp(),
|
||||
cred.updated_at.timestamp(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新凭证
|
||||
pub fn update(conn: &Connection, cred: &ProviderCredential) -> Result<(), rusqlite::Error> {
|
||||
let credential_json =
|
||||
serde_json::to_string(&cred.credential).unwrap_or_else(|_| "{}".to_string());
|
||||
let not_supported_models_json =
|
||||
serde_json::to_string(&cred.not_supported_models).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
provider_type = ?2, credential_data = ?3, name = ?4, is_healthy = ?5,
|
||||
is_disabled = ?6, check_health = ?7, check_model_name = ?8,
|
||||
not_supported_models = ?9, usage_count = ?10, error_count = ?11,
|
||||
last_used = ?12, last_error_time = ?13, last_error_message = ?14,
|
||||
last_health_check_time = ?15, last_health_check_model = ?16, updated_at = ?17
|
||||
WHERE uuid = ?1",
|
||||
params![
|
||||
cred.uuid,
|
||||
cred.provider_type.to_string(),
|
||||
credential_json,
|
||||
cred.name,
|
||||
cred.is_healthy,
|
||||
cred.is_disabled,
|
||||
cred.check_health,
|
||||
cred.check_model_name,
|
||||
not_supported_models_json,
|
||||
cred.usage_count,
|
||||
cred.error_count,
|
||||
cred.last_used.map(|t| t.timestamp()),
|
||||
cred.last_error_time.map(|t| t.timestamp()),
|
||||
cred.last_error_message,
|
||||
cred.last_health_check_time.map(|t| t.timestamp()),
|
||||
cred.last_health_check_model,
|
||||
cred.updated_at.timestamp(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除凭证
|
||||
pub fn delete(conn: &Connection, uuid: &str) -> Result<bool, rusqlite::Error> {
|
||||
let affected = conn.execute(
|
||||
"DELETE FROM provider_pool_credentials WHERE uuid = ?1",
|
||||
[uuid],
|
||||
)?;
|
||||
Ok(affected > 0)
|
||||
}
|
||||
|
||||
/// 更新健康状态
|
||||
pub fn update_health_status(
|
||||
conn: &Connection,
|
||||
uuid: &str,
|
||||
is_healthy: bool,
|
||||
error_count: u32,
|
||||
last_error_time: Option<DateTime<Utc>>,
|
||||
last_error_message: Option<&str>,
|
||||
last_health_check_time: Option<DateTime<Utc>>,
|
||||
last_health_check_model: Option<&str>,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
is_healthy = ?2, error_count = ?3, last_error_time = ?4,
|
||||
last_error_message = ?5, last_health_check_time = ?6,
|
||||
last_health_check_model = ?7, updated_at = ?8
|
||||
WHERE uuid = ?1",
|
||||
params![
|
||||
uuid,
|
||||
is_healthy,
|
||||
error_count,
|
||||
last_error_time.map(|t| t.timestamp()),
|
||||
last_error_message,
|
||||
last_health_check_time.map(|t| t.timestamp()),
|
||||
last_health_check_model,
|
||||
Utc::now().timestamp(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 更新使用统计
|
||||
pub fn update_usage(
|
||||
conn: &Connection,
|
||||
uuid: &str,
|
||||
usage_count: u64,
|
||||
last_used: DateTime<Utc>,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
usage_count = ?2, last_used = ?3, updated_at = ?4
|
||||
WHERE uuid = ?1",
|
||||
params![uuid, usage_count, last_used.timestamp(), Utc::now().timestamp()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重置凭证计数器
|
||||
pub fn reset_counters(conn: &Connection, uuid: &str) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
usage_count = 0, error_count = 0, is_healthy = 1,
|
||||
last_error_time = NULL, last_error_message = NULL, updated_at = ?2
|
||||
WHERE uuid = ?1",
|
||||
params![uuid, Utc::now().timestamp()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重置指定类型的所有凭证健康状态
|
||||
pub fn reset_health_by_type(
|
||||
conn: &Connection,
|
||||
provider_type: &PoolProviderType,
|
||||
) -> Result<usize, rusqlite::Error> {
|
||||
let affected = conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
is_healthy = 1, error_count = 0, last_error_time = NULL,
|
||||
last_error_message = NULL, updated_at = ?2
|
||||
WHERE provider_type = ?1",
|
||||
params![provider_type.to_string(), Utc::now().timestamp()],
|
||||
)?;
|
||||
Ok(affected)
|
||||
}
|
||||
|
||||
/// 从数据库行转换为 ProviderCredential
|
||||
fn row_to_credential(row: &rusqlite::Row) -> Result<ProviderCredential, rusqlite::Error> {
|
||||
let uuid: String = row.get(0)?;
|
||||
let provider_type_str: String = row.get(1)?;
|
||||
let credential_json: String = row.get(2)?;
|
||||
let name: Option<String> = row.get(3)?;
|
||||
let is_healthy: bool = row.get(4)?;
|
||||
let is_disabled: bool = row.get(5)?;
|
||||
let check_health: bool = row.get(6)?;
|
||||
let check_model_name: Option<String> = row.get(7)?;
|
||||
let not_supported_models_json: Option<String> = row.get(8)?;
|
||||
let usage_count: u64 = row.get::<_, i64>(9)? as u64;
|
||||
let error_count: u32 = row.get::<_, i32>(10)? as u32;
|
||||
let last_used_ts: Option<i64> = row.get(11)?;
|
||||
let last_error_time_ts: Option<i64> = row.get(12)?;
|
||||
let last_error_message: Option<String> = row.get(13)?;
|
||||
let last_health_check_time_ts: Option<i64> = row.get(14)?;
|
||||
let last_health_check_model: Option<String> = row.get(15)?;
|
||||
let created_at_ts: i64 = row.get(16)?;
|
||||
let updated_at_ts: i64 = row.get(17)?;
|
||||
|
||||
let provider_type: PoolProviderType = provider_type_str
|
||||
.parse()
|
||||
.unwrap_or(PoolProviderType::Kiro);
|
||||
|
||||
let credential: CredentialData = serde_json::from_str(&credential_json).map_err(|e| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
2,
|
||||
rusqlite::types::Type::Text,
|
||||
Box::new(e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let not_supported_models: Vec<String> = not_supported_models_json
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ProviderCredential {
|
||||
uuid,
|
||||
provider_type,
|
||||
credential,
|
||||
name,
|
||||
is_healthy,
|
||||
is_disabled,
|
||||
check_health,
|
||||
check_model_name,
|
||||
not_supported_models,
|
||||
usage_count,
|
||||
error_count,
|
||||
last_used: last_used_ts.and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
|
||||
last_error_time: last_error_time_ts.and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
|
||||
last_error_message,
|
||||
last_health_check_time: last_health_check_time_ts
|
||||
.and_then(|ts| Utc.timestamp_opt(ts, 0).single()),
|
||||
last_health_check_model,
|
||||
created_at: Utc.timestamp_opt(created_at_ts, 0).single().unwrap_or_default(),
|
||||
updated_at: Utc.timestamp_opt(updated_at_ts, 0).single().unwrap_or_default(),
|
||||
cached_token: None, // 从 get_token_cache 单独获取
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Token 缓存操作 ====================
|
||||
|
||||
/// 获取凭证的 Token 缓存信息
|
||||
pub fn get_token_cache(
|
||||
conn: &Connection,
|
||||
uuid: &str,
|
||||
) -> Result<Option<CachedTokenInfo>, rusqlite::Error> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT cached_access_token, cached_refresh_token, token_expiry_time,
|
||||
last_refresh_time, refresh_error_count, last_refresh_error
|
||||
FROM provider_pool_credentials
|
||||
WHERE uuid = ?1",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([uuid])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let access_token: Option<String> = row.get(0)?;
|
||||
let refresh_token: Option<String> = row.get(1)?;
|
||||
let expiry_time_str: Option<String> = row.get(2)?;
|
||||
let last_refresh_str: Option<String> = row.get(3)?;
|
||||
let refresh_error_count: i32 = row.get::<_, Option<i32>>(4)?.unwrap_or(0);
|
||||
let last_refresh_error: Option<String> = row.get(5)?;
|
||||
|
||||
// 如果没有缓存的 token,返回 None
|
||||
if access_token.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let expiry_time = expiry_time_str
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
let last_refresh = last_refresh_str
|
||||
.and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc));
|
||||
|
||||
Ok(Some(CachedTokenInfo {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expiry_time,
|
||||
last_refresh,
|
||||
refresh_error_count: refresh_error_count as u32,
|
||||
last_refresh_error,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新凭证的 Token 缓存
|
||||
pub fn update_token_cache(
|
||||
conn: &Connection,
|
||||
uuid: &str,
|
||||
token_info: &CachedTokenInfo,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
cached_access_token = ?2,
|
||||
cached_refresh_token = ?3,
|
||||
token_expiry_time = ?4,
|
||||
last_refresh_time = ?5,
|
||||
refresh_error_count = ?6,
|
||||
last_refresh_error = ?7,
|
||||
updated_at = ?8
|
||||
WHERE uuid = ?1",
|
||||
params![
|
||||
uuid,
|
||||
token_info.access_token,
|
||||
token_info.refresh_token,
|
||||
token_info.expiry_time.map(|t| t.to_rfc3339()),
|
||||
token_info.last_refresh.map(|t| t.to_rfc3339()),
|
||||
token_info.refresh_error_count as i32,
|
||||
token_info.last_refresh_error,
|
||||
Utc::now().timestamp(),
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清除凭证的 Token 缓存
|
||||
pub fn clear_token_cache(conn: &Connection, uuid: &str) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
cached_access_token = NULL,
|
||||
cached_refresh_token = NULL,
|
||||
token_expiry_time = NULL,
|
||||
last_refresh_time = NULL,
|
||||
refresh_error_count = 0,
|
||||
last_refresh_error = NULL,
|
||||
updated_at = ?2
|
||||
WHERE uuid = ?1",
|
||||
params![uuid, Utc::now().timestamp()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录 Token 刷新错误
|
||||
pub fn record_token_refresh_error(
|
||||
conn: &Connection,
|
||||
uuid: &str,
|
||||
error_message: &str,
|
||||
) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
refresh_error_count = COALESCE(refresh_error_count, 0) + 1,
|
||||
last_refresh_error = ?2,
|
||||
updated_at = ?3
|
||||
WHERE uuid = ?1",
|
||||
params![uuid, error_message, Utc::now().timestamp()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 重置 Token 刷新错误计数
|
||||
pub fn reset_token_refresh_errors(conn: &Connection, uuid: &str) -> Result<(), rusqlite::Error> {
|
||||
conn.execute(
|
||||
"UPDATE provider_pool_credentials SET
|
||||
refresh_error_count = 0,
|
||||
last_refresh_error = NULL,
|
||||
updated_at = ?2
|
||||
WHERE uuid = ?1",
|
||||
params![uuid, Utc::now().timestamp()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -94,5 +94,62 @@ pub fn create_tables(conn: &Connection) -> Result<(), rusqlite::Error> {
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Provider Pool 凭证表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS provider_pool_credentials (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
provider_type TEXT NOT NULL,
|
||||
credential_data TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_healthy INTEGER DEFAULT 1,
|
||||
is_disabled INTEGER DEFAULT 0,
|
||||
check_health INTEGER DEFAULT 1,
|
||||
check_model_name TEXT,
|
||||
not_supported_models TEXT,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
last_used INTEGER,
|
||||
last_error_time INTEGER,
|
||||
last_error_message TEXT,
|
||||
last_health_check_time INTEGER,
|
||||
last_health_check_model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建 provider_type 索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_provider_pool_type ON provider_pool_credentials(provider_type)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// Migration: 添加 Token 缓存字段
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE provider_pool_credentials ADD COLUMN cached_access_token TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE provider_pool_credentials ADD COLUMN cached_refresh_token TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE provider_pool_credentials ADD COLUMN token_expiry_time TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE provider_pool_credentials ADD COLUMN last_refresh_time TEXT",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE provider_pool_credentials ADD COLUMN refresh_error_count INTEGER DEFAULT 0",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE provider_pool_credentials ADD COLUMN last_refresh_error TEXT",
|
||||
[],
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+58
-4
@@ -12,8 +12,14 @@ use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use commands::provider_pool_cmd::ProviderPoolServiceState;
|
||||
use commands::skill_cmd::SkillServiceState;
|
||||
use services::provider_pool_service::ProviderPoolService;
|
||||
use services::skill_service::SkillService;
|
||||
use services::token_cache_service::TokenCacheService;
|
||||
|
||||
/// TokenCacheService 状态封装
|
||||
pub struct TokenCacheServiceState(pub Arc<TokenCacheService>);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -125,12 +131,20 @@ pub type LogState = Arc<RwLock<logger::LogStore>>;
|
||||
async fn start_server(
|
||||
state: tauri::State<'_, AppState>,
|
||||
logs: tauri::State<'_, LogState>,
|
||||
db: tauri::State<'_, database::DbConnection>,
|
||||
pool_service: tauri::State<'_, ProviderPoolServiceState>,
|
||||
token_cache: tauri::State<'_, TokenCacheServiceState>,
|
||||
) -> Result<String, String> {
|
||||
let mut s = state.write().await;
|
||||
logs.write().await.add("info", "Starting server...");
|
||||
s.start(logs.inner().clone())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
s.start(
|
||||
logs.inner().clone(),
|
||||
pool_service.0.clone(),
|
||||
token_cache.0.clone(),
|
||||
Some(db.inner().clone()),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
@@ -1269,6 +1283,14 @@ pub fn run() {
|
||||
let skill_service = SkillService::new().expect("Failed to initialize SkillService");
|
||||
let skill_service_state = SkillServiceState(Arc::new(skill_service));
|
||||
|
||||
// Initialize ProviderPoolService
|
||||
let provider_pool_service = ProviderPoolService::new();
|
||||
let provider_pool_service_state = ProviderPoolServiceState(Arc::new(provider_pool_service));
|
||||
|
||||
// Initialize TokenCacheService
|
||||
let token_cache_service = TokenCacheService::new();
|
||||
let token_cache_service_state = TokenCacheServiceState(Arc::new(token_cache_service));
|
||||
|
||||
// Initialize default skill repos
|
||||
{
|
||||
let conn = db.lock().expect("Failed to lock database");
|
||||
@@ -1279,9 +1301,13 @@ pub fn run() {
|
||||
// Clone for setup hook
|
||||
let state_clone = state.clone();
|
||||
let logs_clone = logs.clone();
|
||||
let db_clone = db.clone();
|
||||
let pool_service_clone = provider_pool_service_state.0.clone();
|
||||
let token_cache_clone = token_cache_service_state.0.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
|
||||
Some(vec!["--minimized"]),
|
||||
@@ -1290,10 +1316,15 @@ pub fn run() {
|
||||
.manage(logs)
|
||||
.manage(db)
|
||||
.manage(skill_service_state)
|
||||
.manage(provider_pool_service_state)
|
||||
.manage(token_cache_service_state)
|
||||
.setup(move |_app| {
|
||||
// 自动启动服务器
|
||||
let state = state_clone.clone();
|
||||
let logs = logs_clone.clone();
|
||||
let db = db_clone.clone();
|
||||
let pool_service = pool_service_clone.clone();
|
||||
let token_cache = token_cache_clone.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 先加载凭证
|
||||
{
|
||||
@@ -1312,7 +1343,7 @@ pub fn run() {
|
||||
logs.write()
|
||||
.await
|
||||
.add("info", "[启动] 正在自动启动服务器...");
|
||||
match s.start(logs.clone()).await {
|
||||
match s.start(logs.clone(), pool_service, token_cache, Some(db)).await {
|
||||
Ok(_) => {
|
||||
let host = s.config.server.host.clone();
|
||||
let port = s.config.server.port;
|
||||
@@ -1425,6 +1456,29 @@ pub fn run() {
|
||||
commands::skill_cmd::get_skill_repos,
|
||||
commands::skill_cmd::add_skill_repo,
|
||||
commands::skill_cmd::remove_skill_repo,
|
||||
// Provider Pool commands
|
||||
commands::provider_pool_cmd::get_provider_pool_overview,
|
||||
commands::provider_pool_cmd::get_provider_pool_credentials,
|
||||
commands::provider_pool_cmd::add_provider_pool_credential,
|
||||
commands::provider_pool_cmd::update_provider_pool_credential,
|
||||
commands::provider_pool_cmd::delete_provider_pool_credential,
|
||||
commands::provider_pool_cmd::toggle_provider_pool_credential,
|
||||
commands::provider_pool_cmd::reset_provider_pool_credential,
|
||||
commands::provider_pool_cmd::reset_provider_pool_health,
|
||||
commands::provider_pool_cmd::check_provider_pool_credential_health,
|
||||
commands::provider_pool_cmd::check_provider_pool_type_health,
|
||||
commands::provider_pool_cmd::add_kiro_oauth_credential,
|
||||
commands::provider_pool_cmd::add_gemini_oauth_credential,
|
||||
commands::provider_pool_cmd::add_qwen_oauth_credential,
|
||||
commands::provider_pool_cmd::add_openai_key_credential,
|
||||
commands::provider_pool_cmd::add_claude_key_credential,
|
||||
commands::provider_pool_cmd::refresh_pool_credential_token,
|
||||
commands::provider_pool_cmd::get_pool_credential_oauth_status,
|
||||
commands::provider_pool_cmd::debug_kiro_credentials,
|
||||
commands::provider_pool_cmd::test_user_credentials,
|
||||
// Route commands
|
||||
commands::route_cmd::get_available_routes,
|
||||
commands::route_cmd::get_route_curl_examples,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -5,6 +5,8 @@ pub mod mcp_model;
|
||||
pub mod openai;
|
||||
pub mod prompt_model;
|
||||
pub mod provider_model;
|
||||
pub mod provider_pool_model;
|
||||
pub mod route_model;
|
||||
pub mod skill_model;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
@@ -17,4 +19,5 @@ pub use mcp_model::McpServer;
|
||||
pub use openai::*;
|
||||
pub use prompt_model::Prompt;
|
||||
pub use provider_model::Provider;
|
||||
pub use provider_pool_model::*;
|
||||
pub use skill_model::{Skill, SkillMetadata, SkillRepo, SkillState, SkillStates};
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
//! Provider Pool 数据模型
|
||||
//!
|
||||
//! 支持多凭证池管理,包括健康检测、负载均衡、故障转移等功能。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Provider 类型枚举
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PoolProviderType {
|
||||
Kiro,
|
||||
Gemini,
|
||||
Qwen,
|
||||
#[serde(rename = "openai")]
|
||||
OpenAI,
|
||||
Claude,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PoolProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PoolProviderType::Kiro => write!(f, "kiro"),
|
||||
PoolProviderType::Gemini => write!(f, "gemini"),
|
||||
PoolProviderType::Qwen => write!(f, "qwen"),
|
||||
PoolProviderType::OpenAI => write!(f, "openai"),
|
||||
PoolProviderType::Claude => write!(f, "claude"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for PoolProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"kiro" => Ok(PoolProviderType::Kiro),
|
||||
"gemini" => Ok(PoolProviderType::Gemini),
|
||||
"qwen" => Ok(PoolProviderType::Qwen),
|
||||
"openai" => Ok(PoolProviderType::OpenAI),
|
||||
"claude" => Ok(PoolProviderType::Claude),
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 凭证数据,根据 Provider 类型不同而不同
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum CredentialData {
|
||||
/// Kiro OAuth 凭证(文件路径)
|
||||
KiroOAuth {
|
||||
creds_file_path: String,
|
||||
},
|
||||
/// Gemini OAuth 凭证(文件路径)
|
||||
GeminiOAuth {
|
||||
creds_file_path: String,
|
||||
project_id: Option<String>,
|
||||
},
|
||||
/// Qwen OAuth 凭证(文件路径)
|
||||
QwenOAuth {
|
||||
creds_file_path: String,
|
||||
},
|
||||
/// OpenAI API Key 凭证
|
||||
OpenAIKey {
|
||||
api_key: String,
|
||||
base_url: Option<String>,
|
||||
},
|
||||
/// Claude API Key 凭证
|
||||
ClaudeKey {
|
||||
api_key: String,
|
||||
base_url: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CredentialData {
|
||||
/// 获取凭证的显示名称(隐藏敏感信息)
|
||||
pub fn display_name(&self) -> String {
|
||||
match self {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
format!("Kiro OAuth: {}", mask_path(creds_file_path))
|
||||
}
|
||||
CredentialData::GeminiOAuth {
|
||||
creds_file_path, ..
|
||||
} => {
|
||||
format!("Gemini OAuth: {}", mask_path(creds_file_path))
|
||||
}
|
||||
CredentialData::QwenOAuth { creds_file_path } => {
|
||||
format!("Qwen OAuth: {}", mask_path(creds_file_path))
|
||||
}
|
||||
CredentialData::OpenAIKey { api_key, .. } => {
|
||||
format!("OpenAI: {}", mask_key(api_key))
|
||||
}
|
||||
CredentialData::ClaudeKey { api_key, .. } => {
|
||||
format!("Claude: {}", mask_key(api_key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 Provider 类型
|
||||
pub fn provider_type(&self) -> PoolProviderType {
|
||||
match self {
|
||||
CredentialData::KiroOAuth { .. } => PoolProviderType::Kiro,
|
||||
CredentialData::GeminiOAuth { .. } => PoolProviderType::Gemini,
|
||||
CredentialData::QwenOAuth { .. } => PoolProviderType::Qwen,
|
||||
CredentialData::OpenAIKey { .. } => PoolProviderType::OpenAI,
|
||||
CredentialData::ClaudeKey { .. } => PoolProviderType::Claude,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单个凭证
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderCredential {
|
||||
/// 唯一标识符
|
||||
pub uuid: String,
|
||||
/// Provider 类型
|
||||
pub provider_type: PoolProviderType,
|
||||
/// 凭证数据
|
||||
pub credential: CredentialData,
|
||||
/// 备注/名称
|
||||
pub name: Option<String>,
|
||||
/// 是否健康
|
||||
#[serde(default = "default_true")]
|
||||
pub is_healthy: bool,
|
||||
/// 是否禁用(手动禁用)
|
||||
#[serde(default)]
|
||||
pub is_disabled: bool,
|
||||
/// 是否启用自动健康检查
|
||||
#[serde(default = "default_true")]
|
||||
pub check_health: bool,
|
||||
/// 自定义健康检查模型
|
||||
pub check_model_name: Option<String>,
|
||||
/// 不支持的模型列表(黑名单)
|
||||
#[serde(default)]
|
||||
pub not_supported_models: Vec<String>,
|
||||
/// 使用次数
|
||||
#[serde(default)]
|
||||
pub usage_count: u64,
|
||||
/// 错误次数
|
||||
#[serde(default)]
|
||||
pub error_count: u32,
|
||||
/// 最后使用时间
|
||||
pub last_used: Option<DateTime<Utc>>,
|
||||
/// 最后错误时间
|
||||
pub last_error_time: Option<DateTime<Utc>>,
|
||||
/// 最后错误消息
|
||||
pub last_error_message: Option<String>,
|
||||
/// 最后健康检查时间
|
||||
pub last_health_check_time: Option<DateTime<Utc>>,
|
||||
/// 最后健康检查使用的模型
|
||||
pub last_health_check_model: Option<String>,
|
||||
/// 创建时间
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// 更新时间
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Token 缓存信息
|
||||
#[serde(default)]
|
||||
pub cached_token: Option<CachedTokenInfo>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl ProviderCredential {
|
||||
/// 创建新凭证
|
||||
pub fn new(provider_type: PoolProviderType, credential: CredentialData) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
uuid: Uuid::new_v4().to_string(),
|
||||
provider_type,
|
||||
credential,
|
||||
name: None,
|
||||
is_healthy: true,
|
||||
is_disabled: false,
|
||||
check_health: true,
|
||||
check_model_name: None,
|
||||
not_supported_models: Vec::new(),
|
||||
usage_count: 0,
|
||||
error_count: 0,
|
||||
last_used: None,
|
||||
last_error_time: None,
|
||||
last_error_message: None,
|
||||
last_health_check_time: None,
|
||||
last_health_check_model: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
cached_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否可用(健康且未禁用)
|
||||
pub fn is_available(&self) -> bool {
|
||||
self.is_healthy && !self.is_disabled
|
||||
}
|
||||
|
||||
/// 是否支持指定模型
|
||||
pub fn supports_model(&self, model: &str) -> bool {
|
||||
!self.not_supported_models.contains(&model.to_string())
|
||||
}
|
||||
|
||||
/// 标记为健康
|
||||
pub fn mark_healthy(&mut self, check_model: Option<String>) {
|
||||
self.is_healthy = true;
|
||||
self.error_count = 0;
|
||||
self.last_health_check_time = Some(Utc::now());
|
||||
self.last_health_check_model = check_model;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 标记为不健康
|
||||
pub fn mark_unhealthy(&mut self, error_message: Option<String>) {
|
||||
self.error_count += 1;
|
||||
self.last_error_time = Some(Utc::now());
|
||||
self.last_error_message = error_message;
|
||||
self.updated_at = Utc::now();
|
||||
// 错误次数达到阈值则标记为不健康
|
||||
if self.error_count >= 3 {
|
||||
self.is_healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录使用
|
||||
pub fn record_usage(&mut self) {
|
||||
self.usage_count += 1;
|
||||
self.last_used = Some(Utc::now());
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// 重置计数器
|
||||
pub fn reset_counters(&mut self) {
|
||||
self.usage_count = 0;
|
||||
self.error_count = 0;
|
||||
self.is_healthy = true;
|
||||
self.last_error_time = None;
|
||||
self.last_error_message = None;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 凭证池统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PoolStats {
|
||||
/// 总凭证数
|
||||
pub total_count: usize,
|
||||
/// 健康凭证数
|
||||
pub healthy_count: usize,
|
||||
/// 禁用凭证数
|
||||
pub disabled_count: usize,
|
||||
/// 总使用次数
|
||||
pub total_usage: u64,
|
||||
/// 总错误次数
|
||||
pub total_errors: u64,
|
||||
/// 最后更新时间
|
||||
pub last_update: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl PoolStats {
|
||||
pub fn from_credentials(credentials: &[ProviderCredential]) -> Self {
|
||||
Self {
|
||||
total_count: credentials.len(),
|
||||
healthy_count: credentials.iter().filter(|c| c.is_healthy).count(),
|
||||
disabled_count: credentials.iter().filter(|c| c.is_disabled).count(),
|
||||
total_usage: credentials.iter().map(|c| c.usage_count).sum(),
|
||||
total_errors: credentials.iter().map(|c| c.error_count as u64).sum(),
|
||||
last_update: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康检查结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheckResult {
|
||||
pub uuid: String,
|
||||
pub success: bool,
|
||||
pub model: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// OAuth 凭证状态
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthStatus {
|
||||
/// 是否有 access_token
|
||||
pub has_access_token: bool,
|
||||
/// 是否有 refresh_token
|
||||
pub has_refresh_token: bool,
|
||||
/// token 是否有效
|
||||
pub is_token_valid: bool,
|
||||
/// 过期信息
|
||||
pub expiry_info: Option<String>,
|
||||
/// 凭证文件路径
|
||||
pub creds_path: String,
|
||||
}
|
||||
|
||||
/// Token 缓存状态(用于前端展示)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TokenCacheStatus {
|
||||
/// 是否有缓存的 token
|
||||
pub has_cached_token: bool,
|
||||
/// Token 是否有效
|
||||
pub is_valid: bool,
|
||||
/// Token 是否即将过期(5分钟内)
|
||||
pub is_expiring_soon: bool,
|
||||
/// 过期时间
|
||||
pub expiry_time: Option<String>,
|
||||
/// 最后刷新时间
|
||||
pub last_refresh: Option<String>,
|
||||
/// 连续刷新失败次数
|
||||
pub refresh_error_count: u32,
|
||||
/// 最后刷新错误信息
|
||||
pub last_refresh_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Token 缓存信息
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CachedTokenInfo {
|
||||
/// 缓存的 access_token
|
||||
pub access_token: Option<String>,
|
||||
/// 缓存的 refresh_token(刷新后可能变化)
|
||||
pub refresh_token: Option<String>,
|
||||
/// Token 过期时间
|
||||
pub expiry_time: Option<DateTime<Utc>>,
|
||||
/// 最后刷新时间
|
||||
pub last_refresh: Option<DateTime<Utc>>,
|
||||
/// 连续刷新失败次数
|
||||
#[serde(default)]
|
||||
pub refresh_error_count: u32,
|
||||
/// 最后刷新错误信息
|
||||
pub last_refresh_error: Option<String>,
|
||||
}
|
||||
|
||||
impl CachedTokenInfo {
|
||||
/// 检查 token 是否有效(存在且未过期)
|
||||
pub fn is_valid(&self) -> bool {
|
||||
if self.access_token.is_none() {
|
||||
return false;
|
||||
}
|
||||
match &self.expiry_time {
|
||||
Some(expiry) => *expiry > Utc::now(),
|
||||
None => true, // 没有过期时间,假设有效
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 token 是否即将过期(5分钟内)
|
||||
pub fn is_expiring_soon(&self) -> bool {
|
||||
match &self.expiry_time {
|
||||
Some(expiry) => {
|
||||
let threshold = Utc::now() + chrono::Duration::minutes(5);
|
||||
*expiry <= threshold
|
||||
}
|
||||
None => false, // 没有过期时间,假设不会过期
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 token 是否需要刷新(无效或即将过期)
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
!self.is_valid() || self.is_expiring_soon()
|
||||
}
|
||||
}
|
||||
|
||||
/// 默认健康检查模型
|
||||
pub fn get_default_check_model(provider_type: PoolProviderType) -> &'static str {
|
||||
match provider_type {
|
||||
PoolProviderType::Kiro => "claude-haiku-4-5",
|
||||
PoolProviderType::Gemini => "gemini-2.5-flash",
|
||||
PoolProviderType::Qwen => "qwen3-coder-flash",
|
||||
PoolProviderType::OpenAI => "gpt-3.5-turbo",
|
||||
PoolProviderType::Claude => "claude-3-5-haiku-latest",
|
||||
}
|
||||
}
|
||||
|
||||
/// 凭证池前端展示数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CredentialDisplay {
|
||||
pub uuid: String,
|
||||
pub provider_type: String,
|
||||
pub credential_type: String,
|
||||
pub name: Option<String>,
|
||||
pub display_credential: String,
|
||||
pub is_healthy: bool,
|
||||
pub is_disabled: bool,
|
||||
pub usage_count: u64,
|
||||
pub error_count: u32,
|
||||
pub last_used: Option<String>,
|
||||
pub last_error_message: Option<String>,
|
||||
pub last_health_check_time: Option<String>,
|
||||
pub last_health_check_model: Option<String>,
|
||||
pub oauth_status: Option<OAuthStatus>,
|
||||
pub token_cache_status: Option<TokenCacheStatus>,
|
||||
}
|
||||
|
||||
/// 获取凭证类型字符串
|
||||
fn get_credential_type(cred: &CredentialData) -> String {
|
||||
match cred {
|
||||
CredentialData::KiroOAuth { .. } => "kiro_oauth".to_string(),
|
||||
CredentialData::GeminiOAuth { .. } => "gemini_oauth".to_string(),
|
||||
CredentialData::QwenOAuth { .. } => "qwen_oauth".to_string(),
|
||||
CredentialData::OpenAIKey { .. } => "openai_key".to_string(),
|
||||
CredentialData::ClaudeKey { .. } => "claude_key".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 OAuth 凭证的文件路径
|
||||
pub fn get_oauth_creds_path(cred: &CredentialData) -> Option<String> {
|
||||
match cred {
|
||||
CredentialData::KiroOAuth { creds_file_path } => Some(creds_file_path.clone()),
|
||||
CredentialData::GeminiOAuth { creds_file_path, .. } => Some(creds_file_path.clone()),
|
||||
CredentialData::QwenOAuth { creds_file_path } => Some(creds_file_path.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ProviderCredential> for CredentialDisplay {
|
||||
fn from(cred: &ProviderCredential) -> Self {
|
||||
// 构建 token 缓存状态
|
||||
let token_cache_status = cred.cached_token.as_ref().map(|cache| TokenCacheStatus {
|
||||
has_cached_token: cache.access_token.is_some(),
|
||||
is_valid: cache.is_valid(),
|
||||
is_expiring_soon: cache.is_expiring_soon(),
|
||||
expiry_time: cache.expiry_time.map(|t| t.to_rfc3339()),
|
||||
last_refresh: cache.last_refresh.map(|t| t.to_rfc3339()),
|
||||
refresh_error_count: cache.refresh_error_count,
|
||||
last_refresh_error: cache.last_refresh_error.clone(),
|
||||
});
|
||||
|
||||
Self {
|
||||
uuid: cred.uuid.clone(),
|
||||
provider_type: cred.provider_type.to_string(),
|
||||
credential_type: get_credential_type(&cred.credential),
|
||||
name: cred.name.clone(),
|
||||
display_credential: cred.credential.display_name(),
|
||||
is_healthy: cred.is_healthy,
|
||||
is_disabled: cred.is_disabled,
|
||||
usage_count: cred.usage_count,
|
||||
error_count: cred.error_count,
|
||||
last_used: cred.last_used.map(|t| t.to_rfc3339()),
|
||||
last_error_message: cred.last_error_message.clone(),
|
||||
last_health_check_time: cred.last_health_check_time.map(|t| t.to_rfc3339()),
|
||||
last_health_check_model: cred.last_health_check_model.clone(),
|
||||
oauth_status: None, // 需要单独调用获取
|
||||
token_cache_status,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 池概览(按类型分组的统计)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderPoolOverview {
|
||||
pub provider_type: String,
|
||||
pub stats: PoolStats,
|
||||
pub credentials: Vec<CredentialDisplay>,
|
||||
}
|
||||
|
||||
// 辅助函数:隐藏路径中的用户名
|
||||
fn mask_path(path: &str) -> String {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let home_str = home.to_string_lossy();
|
||||
path.replace(&*home_str, "~")
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助函数:隐藏 API Key
|
||||
fn mask_key(key: &str) -> String {
|
||||
if key.len() <= 12 {
|
||||
"****".to_string()
|
||||
} else {
|
||||
format!("{}...{}", &key[..6], &key[key.len() - 4..])
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加凭证的请求结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddCredentialRequest {
|
||||
pub provider_type: String,
|
||||
pub credential: CredentialData,
|
||||
pub name: Option<String>,
|
||||
pub check_health: Option<bool>,
|
||||
pub check_model_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新凭证的请求结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateCredentialRequest {
|
||||
pub name: Option<String>,
|
||||
pub is_disabled: Option<bool>,
|
||||
pub check_health: Option<bool>,
|
||||
pub check_model_name: Option<String>,
|
||||
pub not_supported_models: Option<Vec<String>>,
|
||||
/// 新的凭证文件路径(仅适用于OAuth凭证,用于重新上传文件)
|
||||
pub new_creds_file_path: Option<String>,
|
||||
/// OAuth相关:新的project_id(仅适用于Gemini)
|
||||
pub new_project_id: Option<String>,
|
||||
}
|
||||
|
||||
pub type ProviderPools = HashMap<PoolProviderType, Vec<ProviderCredential>>;
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 路由模型
|
||||
//!
|
||||
//! 用于多供应商路由功能的数据结构定义。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 单个路由信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteInfo {
|
||||
/// 路由选择器 (provider 类型或凭证名称)
|
||||
pub selector: String,
|
||||
/// Provider 类型
|
||||
pub provider_type: String,
|
||||
/// 关联的凭证数量
|
||||
pub credential_count: usize,
|
||||
/// 可用的端点列表
|
||||
pub endpoints: Vec<RouteEndpoint>,
|
||||
/// 标签 (如 "突破限制", "官方API/三方")
|
||||
pub tags: Vec<String>,
|
||||
/// 是否启用
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 路由端点
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteEndpoint {
|
||||
/// 端点路径
|
||||
pub path: String,
|
||||
/// 协议类型
|
||||
pub protocol: String, // "openai" 或 "claude"
|
||||
/// 完整 URL
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// 路由列表响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RouteListResponse {
|
||||
/// 服务器基础 URL
|
||||
pub base_url: String,
|
||||
/// 默认 Provider
|
||||
pub default_provider: String,
|
||||
/// 所有可用路由
|
||||
pub routes: Vec<RouteInfo>,
|
||||
}
|
||||
|
||||
/// curl 示例
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurlExample {
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// curl 命令
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
impl RouteInfo {
|
||||
/// 创建新的路由信息
|
||||
pub fn new(selector: String, provider_type: String) -> Self {
|
||||
Self {
|
||||
selector,
|
||||
provider_type,
|
||||
credential_count: 0,
|
||||
endpoints: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加端点
|
||||
pub fn add_endpoint(&mut self, base_url: &str, protocol: &str) {
|
||||
let path = match protocol {
|
||||
"claude" => format!("/{}/v1/messages", self.selector),
|
||||
"openai" => format!("/{}/v1/chat/completions", self.selector),
|
||||
_ => return,
|
||||
};
|
||||
let url = format!("{}{}", base_url, path);
|
||||
self.endpoints.push(RouteEndpoint {
|
||||
path,
|
||||
protocol: protocol.to_string(),
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
/// 生成 curl 示例
|
||||
pub fn generate_curl_examples(&self, api_key: &str) -> Vec<CurlExample> {
|
||||
let mut examples = Vec::new();
|
||||
|
||||
for endpoint in &self.endpoints {
|
||||
let (model, body) = match endpoint.protocol.as_str() {
|
||||
"claude" => {
|
||||
let model = match self.provider_type.as_str() {
|
||||
"kiro" | "claude" => "claude-sonnet-4-5",
|
||||
"gemini" => "gemini-2.5-flash",
|
||||
"qwen" => "qwen3-coder-plus",
|
||||
"openai" => "gpt-4",
|
||||
_ => "claude-sonnet-4-5",
|
||||
};
|
||||
(model, format!(r#"{{
|
||||
"model": "{}",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{{"role": "user", "content": "Hello!"}}]
|
||||
}}"#, model))
|
||||
}
|
||||
"openai" => {
|
||||
let model = match self.provider_type.as_str() {
|
||||
"kiro" | "claude" => "claude-sonnet-4-5",
|
||||
"gemini" => "gemini-2.5-flash",
|
||||
"qwen" => "qwen3-coder-plus",
|
||||
"openai" => "gpt-4",
|
||||
_ => "claude-sonnet-4-5",
|
||||
};
|
||||
(model, format!(r#"{{
|
||||
"model": "{}",
|
||||
"messages": [{{"role": "user", "content": "Hello!"}}]
|
||||
}}"#, model))
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let command = format!(
|
||||
r#"curl {} \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer {}" \
|
||||
-d '{}'"#,
|
||||
endpoint.url, api_key, body
|
||||
);
|
||||
|
||||
examples.push(CurlExample {
|
||||
description: format!("{} 协议", endpoint.protocol.to_uppercase()),
|
||||
command,
|
||||
});
|
||||
}
|
||||
|
||||
examples
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Claude Custom Provider (自定义 Claude API)
|
||||
use crate::models::anthropic::AnthropicMessagesRequest;
|
||||
use crate::models::openai::{ChatCompletionRequest, ContentPart, MessageContent};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
@@ -29,6 +31,18 @@ impl ClaudeCustomProvider {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 使用 API key 和 base_url 创建 Provider
|
||||
pub fn with_config(api_key: String, base_url: Option<String>) -> Self {
|
||||
Self {
|
||||
config: ClaudeCustomConfig {
|
||||
api_key: Some(api_key),
|
||||
base_url,
|
||||
enabled: true,
|
||||
},
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_base_url(&self) -> String {
|
||||
self.config
|
||||
.base_url
|
||||
@@ -40,6 +54,148 @@ impl ClaudeCustomProvider {
|
||||
self.config.api_key.is_some() && self.config.enabled
|
||||
}
|
||||
|
||||
/// 调用 Anthropic API(原生格式)
|
||||
pub async fn call_api(
|
||||
&self,
|
||||
request: &AnthropicMessagesRequest,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let api_key = self
|
||||
.config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.ok_or("Claude API key not configured")?;
|
||||
|
||||
let base_url = self.get_base_url();
|
||||
let url = format!("{base_url}/v1/messages");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// 调用 OpenAI 格式的 API(内部转换为 Anthropic 格式)
|
||||
pub async fn call_openai_api(
|
||||
&self,
|
||||
request: &ChatCompletionRequest,
|
||||
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
|
||||
// 手动转换 OpenAI 请求为 Anthropic 格式
|
||||
let mut anthropic_messages = Vec::new();
|
||||
let mut system_content = None;
|
||||
|
||||
for msg in &request.messages {
|
||||
let role = &msg.role;
|
||||
|
||||
// 提取消息内容
|
||||
let content = match &msg.content {
|
||||
Some(MessageContent::Text(text)) => text.clone(),
|
||||
Some(MessageContent::Parts(parts)) => {
|
||||
// 合并所有文本部分
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let ContentPart::Text { text } = p {
|
||||
Some(text.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
if role == "system" {
|
||||
system_content = Some(content);
|
||||
} else {
|
||||
let anthropic_role = if role == "assistant" {
|
||||
"assistant"
|
||||
} else {
|
||||
"user"
|
||||
};
|
||||
anthropic_messages.push(serde_json::json!({
|
||||
"role": anthropic_role,
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
let mut anthropic_body = serde_json::json!({
|
||||
"model": request.model,
|
||||
"max_tokens": request.max_tokens.unwrap_or(4096),
|
||||
"messages": anthropic_messages
|
||||
});
|
||||
|
||||
if let Some(sys) = system_content {
|
||||
anthropic_body["system"] = serde_json::json!(sys);
|
||||
}
|
||||
|
||||
let api_key = self
|
||||
.config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.ok_or("Claude API key not configured")?;
|
||||
|
||||
let base_url = self.get_base_url();
|
||||
let url = format!("{base_url}/v1/messages");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&anthropic_body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Claude API error: {status} - {body}").into());
|
||||
}
|
||||
|
||||
let anthropic_resp: serde_json::Value = resp.json().await?;
|
||||
|
||||
// 转换回 OpenAI 格式
|
||||
let content = anthropic_resp["content"]
|
||||
.as_array()
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|block| block["text"].as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": format!("chatcmpl-{}", uuid::Uuid::new_v4()),
|
||||
"object": "chat.completion",
|
||||
"created": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
"model": request.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": content
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": anthropic_resp["usage"]["input_tokens"].as_u64().unwrap_or(0),
|
||||
"completion_tokens": anthropic_resp["usage"]["output_tokens"].as_u64().unwrap_or(0),
|
||||
"total_tokens": 0
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn messages(
|
||||
&self,
|
||||
request: &serde_json::Value,
|
||||
|
||||
@@ -157,6 +157,16 @@ impl GeminiProvider {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_credentials_from_path(
|
||||
&mut self,
|
||||
path: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let content = tokio::fs::read_to_string(path).await?;
|
||||
let creds: GeminiCredentials = serde_json::from_str(&content)?;
|
||||
self.credentials = creds;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let path = Self::default_creds_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
+344
-21
@@ -47,6 +47,7 @@ pub struct KiroCredentials {
|
||||
pub expires_at: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub auth_method: Option<String>,
|
||||
pub client_id_hash: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for KiroCredentials {
|
||||
@@ -60,6 +61,7 @@ impl Default for KiroCredentials {
|
||||
expires_at: None,
|
||||
region: Some("us-east-1".to_string()),
|
||||
auth_method: Some("social".to_string()),
|
||||
client_id_hash: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +69,8 @@ impl Default for KiroCredentials {
|
||||
pub struct KiroProvider {
|
||||
pub credentials: KiroCredentials,
|
||||
pub client: Client,
|
||||
/// 当前加载的凭证文件路径
|
||||
pub creds_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for KiroProvider {
|
||||
@@ -74,6 +78,7 @@ impl Default for KiroProvider {
|
||||
Self {
|
||||
credentials: KiroCredentials::default(),
|
||||
client: Client::new(),
|
||||
creds_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +117,33 @@ impl KiroProvider {
|
||||
merge_credentials(&mut merged, &creds);
|
||||
}
|
||||
|
||||
// 如果有 clientIdHash,尝试加载对应的 client_id 和 client_secret
|
||||
if let Some(hash) = &merged.client_id_hash {
|
||||
let hash_file_path = dir.join(format!("{}.json", hash));
|
||||
tracing::info!("[KIRO] 检查 clientIdHash 文件: {}", hash_file_path.display());
|
||||
if tokio::fs::try_exists(&hash_file_path).await.unwrap_or(false) {
|
||||
if let Ok(content) = tokio::fs::read_to_string(&hash_file_path).await {
|
||||
if let Ok(creds) = serde_json::from_str::<KiroCredentials>(&content) {
|
||||
tracing::info!(
|
||||
"[KIRO] Hash file {:?}: has_client_id={}, has_client_secret={}",
|
||||
hash_file_path.file_name(),
|
||||
creds.client_id.is_some(),
|
||||
creds.client_secret.is_some()
|
||||
);
|
||||
merge_credentials(&mut merged, &creds);
|
||||
} else {
|
||||
tracing::error!("[KIRO] 无法解析 clientIdHash 文件: {}", hash_file_path.display());
|
||||
}
|
||||
} else {
|
||||
tracing::error!("[KIRO] 无法读取 clientIdHash 文件: {}", hash_file_path.display());
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("[KIRO] clientIdHash {} 指向的文件不存在: {}", hash, hash_file_path.display());
|
||||
}
|
||||
} else {
|
||||
tracing::info!("[KIRO] 没有 clientIdHash 字段");
|
||||
}
|
||||
|
||||
// 读取目录中其他 JSON 文件
|
||||
if tokio::fs::try_exists(dir).await.unwrap_or(false) {
|
||||
let mut entries = tokio::fs::read_dir(dir).await?;
|
||||
@@ -144,6 +176,152 @@ impl KiroProvider {
|
||||
);
|
||||
|
||||
self.credentials = merged;
|
||||
self.creds_path = Some(path);
|
||||
|
||||
// 加载完成后,智能检测并更新认证方式(如果需要)
|
||||
let detected_auth_method = self.detect_auth_method();
|
||||
if self.credentials.auth_method.as_deref().unwrap_or("social") != detected_auth_method {
|
||||
tracing::info!("[KIRO] 加载后检测到需要调整认证方式为: {}", detected_auth_method);
|
||||
self.set_auth_method(&detected_auth_method);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从指定路径加载凭证(包括 clientIdHash 文件和同目录的其他 JSON 文件)
|
||||
pub async fn load_credentials_from_path(
|
||||
&mut self,
|
||||
path: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let dir = path.parent().ok_or("Invalid path: no parent directory")?;
|
||||
|
||||
let mut merged = KiroCredentials::default();
|
||||
|
||||
// 读取主凭证文件
|
||||
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
|
||||
let content = tokio::fs::read_to_string(&path).await?;
|
||||
let creds: KiroCredentials = serde_json::from_str(&content)?;
|
||||
tracing::info!(
|
||||
"[KIRO] Main file loaded from {:?}: has_access={}, has_refresh={}, has_client_id={}, auth_method={:?}, clientIdHash={:?}",
|
||||
path,
|
||||
creds.access_token.is_some(),
|
||||
creds.refresh_token.is_some(),
|
||||
creds.client_id.is_some(),
|
||||
creds.auth_method,
|
||||
creds.client_id_hash
|
||||
);
|
||||
merge_credentials(&mut merged, &creds);
|
||||
}
|
||||
|
||||
// 如果有 clientIdHash,尝试从 ~/.aws/sso/cache/ 目录加载对应的 client_id 和 client_secret
|
||||
if let Some(hash) = &merged.client_id_hash {
|
||||
// clientIdHash 文件总是在 ~/.aws/sso/cache/ 目录中
|
||||
let aws_sso_cache_dir = dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".aws")
|
||||
.join("sso")
|
||||
.join("cache");
|
||||
let hash_file_path = aws_sso_cache_dir.join(format!("{}.json", hash));
|
||||
|
||||
tracing::debug!(
|
||||
"[KIRO] 检查 clientIdHash 文件: {}",
|
||||
hash_file_path.display()
|
||||
);
|
||||
|
||||
if tokio::fs::try_exists(&hash_file_path).await.unwrap_or(false) {
|
||||
if let Ok(content) = tokio::fs::read_to_string(&hash_file_path).await {
|
||||
// 使用 serde_json::Value 来更灵活地解析,因为 hash 文件可能包含额外字段
|
||||
if let Ok(json_value) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
// 直接提取 clientId 和 clientSecret
|
||||
let client_id = json_value
|
||||
.get("clientId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let client_secret = json_value
|
||||
.get("clientSecret")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
tracing::debug!(
|
||||
"[KIRO] Hash file {:?}: has_client_id={}, has_client_secret={}",
|
||||
hash_file_path.file_name(),
|
||||
client_id.is_some(),
|
||||
client_secret.is_some()
|
||||
);
|
||||
|
||||
if client_id.is_some() {
|
||||
merged.client_id = client_id;
|
||||
}
|
||||
if client_secret.is_some() {
|
||||
merged.client_secret = client_secret;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[KIRO] 无法解析 clientIdHash 文件 JSON: {}",
|
||||
hash_file_path.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[KIRO] 无法读取 clientIdHash 文件: {}",
|
||||
hash_file_path.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[KIRO] clientIdHash {} 指向的文件不存在: {}",
|
||||
hash,
|
||||
hash_file_path.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("[KIRO] 没有 clientIdHash 字段,尝试扫描同目录文件");
|
||||
}
|
||||
|
||||
// 如果还没有 client_id/client_secret,读取目录中其他 JSON 文件
|
||||
if merged.client_id.is_none() || merged.client_secret.is_none() {
|
||||
if tokio::fs::try_exists(dir).await.unwrap_or(false) {
|
||||
let mut entries = tokio::fs::read_dir(dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let file_path = entry.path();
|
||||
if file_path.extension().map(|e| e == "json").unwrap_or(false) && file_path != path
|
||||
{
|
||||
if let Ok(content) = tokio::fs::read_to_string(&file_path).await {
|
||||
if let Ok(creds) = serde_json::from_str::<KiroCredentials>(&content) {
|
||||
tracing::info!(
|
||||
"[KIRO] Extra file {:?}: has_client_id={}, has_client_secret={}",
|
||||
file_path.file_name(),
|
||||
creds.client_id.is_some(),
|
||||
creds.client_secret.is_some()
|
||||
);
|
||||
merge_credentials(&mut merged, &creds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[KIRO] Final merged from path: has_access={}, has_refresh={}, has_client_id={}, has_client_secret={}, auth_method={:?}",
|
||||
merged.access_token.is_some(),
|
||||
merged.refresh_token.is_some(),
|
||||
merged.client_id.is_some(),
|
||||
merged.client_secret.is_some(),
|
||||
merged.auth_method
|
||||
);
|
||||
|
||||
self.credentials = merged;
|
||||
self.creds_path = Some(path);
|
||||
|
||||
// 加载完成后,智能检测并更新认证方式(如果需要)
|
||||
let detected_auth_method = self.detect_auth_method();
|
||||
if self.credentials.auth_method.as_deref().unwrap_or("social") != detected_auth_method {
|
||||
tracing::info!("[KIRO] 从路径加载后检测到需要调整认证方式为: {}", detected_auth_method);
|
||||
self.set_auth_method(&detected_auth_method);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -168,50 +346,171 @@ impl KiroProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建健康检查使用的端点,与实际API调用保持一致
|
||||
pub fn get_health_check_url(&self) -> String {
|
||||
// 重用基础URL逻辑,确保健康检查与实际API调用使用相同端点
|
||||
self.get_base_url()
|
||||
}
|
||||
|
||||
/// 从凭证文件中提取 region 信息的静态方法,供健康检查服务使用
|
||||
pub fn extract_region_from_creds(creds_content: &str) -> Result<String, String> {
|
||||
let creds: serde_json::Value = serde_json::from_str(creds_content)
|
||||
.map_err(|e| format!("解析凭证失败: {}", e))?;
|
||||
|
||||
let region = creds["region"]
|
||||
.as_str()
|
||||
.unwrap_or("us-east-1")
|
||||
.to_string();
|
||||
|
||||
Ok(region)
|
||||
}
|
||||
|
||||
/// 构建健康检查端点的静态方法,供外部服务使用
|
||||
pub fn build_health_check_url(region: &str) -> String {
|
||||
format!("https://codewhisperer.{region}.amazonaws.com/generateAssistantResponse")
|
||||
}
|
||||
|
||||
/// 检查 Token 是否已过期(基于时间戳)
|
||||
pub fn is_token_expired(&self) -> bool {
|
||||
if let Some(expires_str) = &self.credentials.expires_at {
|
||||
if let Ok(expires_timestamp) = expires_str.parse::<i64>() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
// 提前5分钟判断为过期,避免边界情况
|
||||
return now >= (expires_timestamp - 300);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有过期时间信息,保守地认为可能需要刷新
|
||||
true
|
||||
}
|
||||
|
||||
/// 验证 refresh_token 的基本有效性
|
||||
pub fn validate_refresh_token(&self) -> Result<(), String> {
|
||||
let refresh_token = self.credentials.refresh_token.as_ref()
|
||||
.ok_or("缺少 refresh_token。\n💡 解决方案:\n1. 重新添加 OAuth 凭证\n2. 确保凭证文件包含完整的认证信息")?;
|
||||
|
||||
// 基本格式验证
|
||||
if refresh_token.trim().is_empty() {
|
||||
return Err("refresh_token 为空。\n💡 解决方案:\n1. 检查凭证文件是否损坏\n2. 重新生成 OAuth 凭证".to_string());
|
||||
}
|
||||
|
||||
// 检查是否看起来像有效的 token(简单的长度和格式检查)
|
||||
if refresh_token.len() < 10 {
|
||||
return Err("refresh_token 格式异常(长度过短)。\n💡 解决方案:\n1. 凭证文件可能已损坏\n2. 重新获取 OAuth 凭证".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检测最佳的认证方式
|
||||
/// 优先使用 IdC(如果有完整配置),否则回退到 social ��证
|
||||
pub fn detect_auth_method(&self) -> String {
|
||||
// 检查当前设置的认证方式
|
||||
let current_auth = self.credentials.auth_method.as_deref().unwrap_or("social");
|
||||
|
||||
// 如果当前是 IdC 方式,检查是否有完整的 IdC 配置
|
||||
if current_auth.to_lowercase() == "idc" {
|
||||
if self.credentials.client_id.is_some() && self.credentials.client_secret.is_some() {
|
||||
// IdC 配置完整,继续使用 IdC
|
||||
tracing::debug!("[KIRO] IdC 配置完整,使用 IdC 认证");
|
||||
"idc".to_string()
|
||||
} else {
|
||||
// IdC 配置不完整,降级到 social
|
||||
tracing::warn!("[KIRO] IdC 配置不完整(缺少 client_id 或 client_secret),自动降级到 social 认证");
|
||||
"social".to_string()
|
||||
}
|
||||
} else {
|
||||
// 默认或已设置为 social
|
||||
tracing::debug!("[KIRO] 使用 social 认证");
|
||||
"social".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新认证方式到凭证中(仅在内存中,需要调用 save_credentials 持久化)
|
||||
pub fn set_auth_method(&mut self, method: &str) {
|
||||
let old_method = self.credentials.auth_method.as_deref().unwrap_or("social");
|
||||
if old_method != method {
|
||||
tracing::info!("[KIRO] 认证方式从 {} 切换到 {}", old_method, method);
|
||||
self.credentials.auth_method = Some(method.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_token(&mut self) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
// 首先验证 refresh_token 的有效性
|
||||
self.validate_refresh_token()?;
|
||||
|
||||
tracing::info!("[KIRO] 开始 Token 刷新流程");
|
||||
tracing::info!("[KIRO] 当前凭证状态: has_client_id={}, has_client_secret={}, auth_method={:?}",
|
||||
self.credentials.client_id.is_some(),
|
||||
self.credentials.client_secret.is_some(),
|
||||
self.credentials.auth_method
|
||||
);
|
||||
|
||||
// 先克隆必要的值,避免借用冲突
|
||||
let refresh_token = self
|
||||
.credentials
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.ok_or("No refresh token")?;
|
||||
.ok_or("No refresh token")?
|
||||
.clone();
|
||||
|
||||
let auth_method = self
|
||||
.credentials
|
||||
.auth_method
|
||||
.as_deref()
|
||||
.unwrap_or("social")
|
||||
.to_lowercase();
|
||||
// 使用智能检测的认证方式,而不是直接使用配置中的方式
|
||||
let detected_auth_method = self.detect_auth_method();
|
||||
tracing::info!("[KIRO] 检测到的认证方式: {}", detected_auth_method);
|
||||
|
||||
// 如果检测到的方式与配置中的不同,更新配置
|
||||
let current_auth = self.credentials.auth_method.as_deref().unwrap_or("social");
|
||||
if current_auth != detected_auth_method {
|
||||
tracing::info!("[KIRO] 认证方式从 {} 切换到 {}", current_auth, detected_auth_method);
|
||||
self.set_auth_method(&detected_auth_method);
|
||||
}
|
||||
|
||||
let auth_method = detected_auth_method.to_lowercase();
|
||||
let refresh_url = self.get_refresh_url();
|
||||
|
||||
tracing::debug!("[KIRO] refresh_token: auth_method={}, refresh_url={}", auth_method, refresh_url);
|
||||
tracing::debug!("[KIRO] has_client_id={}, has_client_secret={}",
|
||||
self.credentials.client_id.is_some(),
|
||||
self.credentials.client_secret.is_some()
|
||||
);
|
||||
|
||||
let resp = if auth_method == "idc" {
|
||||
// AWS OIDC endpoint requires form-urlencoded format
|
||||
// IdC 认证使用 JSON 格式(参考 AIClient-2-API 实现)
|
||||
let client_id = self
|
||||
.credentials
|
||||
.client_id
|
||||
.as_ref()
|
||||
.ok_or("No client_id for IdC refresh")?;
|
||||
.ok_or("IdC 认证配置错误:缺少 client_id。建议删除后重新添加 OAuth 凭证")?;
|
||||
let client_secret = self
|
||||
.credentials
|
||||
.client_secret
|
||||
.as_ref()
|
||||
.ok_or("No client_secret for IdC refresh")?;
|
||||
.ok_or("IdC 认证配置错误:缺少 client_secret。建议删除后重新添加 OAuth 凭证")?;
|
||||
|
||||
let form_body = format!(
|
||||
"grant_type=refresh_token&client_id={}&client_secret={}&refresh_token={}",
|
||||
urlencoding::encode(client_id),
|
||||
urlencoding::encode(client_secret),
|
||||
urlencoding::encode(refresh_token)
|
||||
);
|
||||
// 使用 JSON 格式发送请求(与 AIClient-2-API 保持一致)
|
||||
let body = serde_json::json!({
|
||||
"refreshToken": &refresh_token,
|
||||
"clientId": client_id,
|
||||
"clientSecret": client_secret,
|
||||
"grantType": "refresh_token"
|
||||
});
|
||||
|
||||
tracing::debug!("[KIRO] IdC 刷新请求体已构建");
|
||||
|
||||
self.client
|
||||
.post(&refresh_url)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.body(form_body)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
} else {
|
||||
let body = serde_json::json!({ "refreshToken": refresh_token });
|
||||
// Social 认证使用简单的 JSON 格式
|
||||
let body = serde_json::json!({ "refreshToken": &refresh_token });
|
||||
self.client
|
||||
.post(&refresh_url)
|
||||
.header("Content-Type", "application/json")
|
||||
@@ -221,10 +520,30 @@ impl KiroProvider {
|
||||
.await?
|
||||
};
|
||||
|
||||
tracing::info!("[KIRO] Token 刷新响应状态: {}", resp.status());
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Refresh failed: {status} {body_text}").into());
|
||||
|
||||
tracing::warn!("[KIRO] Token 刷新失败: {} - {}", status, body_text);
|
||||
|
||||
// 根据具体的HTTP状态码提供更友好的错误信息
|
||||
let error_msg = match status.as_u16() {
|
||||
401 => {
|
||||
if body_text.contains("Bad credentials") || body_text.contains("invalid") {
|
||||
format!("OAuth 凭证已过期或无效,需要重新认证。\n💡 解决方案:\n1. 删除当前 OAuth 凭证\n2. 重新添加 OAuth 凭证\n3. 确保使用最新的凭证文件\n\n技术详情:{} {}", status, body_text)
|
||||
} else {
|
||||
format!("认证失败,Token 可能已过期。\n💡 解决方案:\n1. 检查 AWS 账户状态\n2. 重新生成 OAuth 凭证\n3. 确保凭证文件格式正确\n\n技术详情:{} {}", status, body_text)
|
||||
}
|
||||
}
|
||||
403 => format!("权限不足,无法刷新 Token。\n💡 解决方案:\n1. 检查 AWS 账户权限\n2. 确保 OAuth 应用配置正确\n3. 联系管理员检查权限设置\n\n技术详情:{} {}", status, body_text),
|
||||
429 => format!("请求过于频繁,已被限流。\n💡 解决方案:\n1. 等待 5-10 分钟后重试\n2. 减少 Token 刷新频率\n3. 检查是否有其他程序在同时使用\n\n技术详情:{} {}", status, body_text),
|
||||
500..=599 => format!("服务器错误,AWS OAuth 服务暂时不可用。\n💡 解决方案:\n1. 稍后重试(通常几分钟后恢复)\n2. 检查 AWS 服务状态页面\n3. 如持续失败,联系 AWS 支持\n\n技术详情:{} {}", status, body_text),
|
||||
_ => format!("Token 刷新失败。\n💡 解决方案:\n1. 检查网络连接\n2. 确认凭证文件完整性\n3. 尝试重新添加凭证\n\n技术详情:{} {}", status, body_text)
|
||||
};
|
||||
|
||||
return Err(error_msg.into());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
@@ -255,7 +574,8 @@ impl KiroProvider {
|
||||
}
|
||||
|
||||
pub async fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let path = Self::default_creds_path();
|
||||
// 使用加载时的路径或默认路径
|
||||
let path = self.creds_path.clone().unwrap_or_else(Self::default_creds_path);
|
||||
|
||||
// 读取现有文件内容
|
||||
let mut existing: serde_json::Value = if tokio::fs::try_exists(&path).await.unwrap_or(false)
|
||||
@@ -411,4 +731,7 @@ fn merge_credentials(target: &mut KiroCredentials, source: &KiroCredentials) {
|
||||
if source.auth_method.is_some() {
|
||||
target.auth_method = source.auth_method.clone();
|
||||
}
|
||||
if source.client_id_hash.is_some() {
|
||||
target.client_id_hash = source.client_id_hash.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! OpenAI Custom Provider (自定义 OpenAI 兼容 API)
|
||||
use crate::models::openai::ChatCompletionRequest;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::error::Error;
|
||||
@@ -29,6 +30,18 @@ impl OpenAICustomProvider {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 使用 API key 和 base_url 创建 Provider
|
||||
pub fn with_config(api_key: String, base_url: Option<String>) -> Self {
|
||||
Self {
|
||||
config: OpenAICustomConfig {
|
||||
api_key: Some(api_key),
|
||||
base_url,
|
||||
enabled: true,
|
||||
},
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_base_url(&self) -> String {
|
||||
self.config
|
||||
.base_url
|
||||
@@ -40,6 +53,32 @@ impl OpenAICustomProvider {
|
||||
self.config.api_key.is_some() && self.config.enabled
|
||||
}
|
||||
|
||||
/// 调用 OpenAI API(使用类型化请求)
|
||||
pub async fn call_api(
|
||||
&self,
|
||||
request: &ChatCompletionRequest,
|
||||
) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||
let api_key = self
|
||||
.config
|
||||
.api_key
|
||||
.as_ref()
|
||||
.ok_or("OpenAI API key not configured")?;
|
||||
|
||||
let base_url = self.get_base_url();
|
||||
let url = format!("{base_url}/chat/completions");
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub async fn chat_completions(
|
||||
&self,
|
||||
request: &serde_json::Value,
|
||||
|
||||
@@ -70,6 +70,16 @@ impl QwenProvider {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_credentials_from_path(
|
||||
&mut self,
|
||||
path: &str,
|
||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let content = tokio::fs::read_to_string(path).await?;
|
||||
let creds: QwenCredentials = serde_json::from_str(&content)?;
|
||||
self.credentials = creds;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn save_credentials(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let path = Self::default_creds_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
+797
-2
@@ -1,17 +1,21 @@
|
||||
//! HTTP API 服务器
|
||||
use crate::config::Config;
|
||||
use crate::converter::anthropic_to_openai::convert_anthropic_to_openai;
|
||||
use crate::database::DbConnection;
|
||||
use crate::logger::LogStore;
|
||||
use crate::models::anthropic::*;
|
||||
use crate::models::openai::*;
|
||||
use crate::models::route_model::{RouteInfo, RouteListResponse};
|
||||
use crate::providers::claude_custom::ClaudeCustomProvider;
|
||||
use crate::providers::gemini::GeminiProvider;
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
use crate::providers::openai_custom::OpenAICustomProvider;
|
||||
use crate::providers::qwen::QwenProvider;
|
||||
use crate::services::provider_pool_service::ProviderPoolService;
|
||||
use crate::services::token_cache_service::TokenCacheService;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
extract::{Path, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
@@ -89,6 +93,9 @@ impl ServerState {
|
||||
pub async fn start(
|
||||
&mut self,
|
||||
logs: Arc<RwLock<LogStore>>,
|
||||
pool_service: Arc<ProviderPoolService>,
|
||||
token_cache: Arc<TokenCacheService>,
|
||||
db: Option<DbConnection>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if self.running {
|
||||
return Ok(());
|
||||
@@ -106,7 +113,7 @@ impl ServerState {
|
||||
let kiro = self.kiro_provider.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_server(&host, port, &api_key, kiro, logs, rx).await {
|
||||
if let Err(e) = run_server(&host, port, &api_key, kiro, logs, rx, pool_service, token_cache, db).await {
|
||||
tracing::error!("Server error: {}", e);
|
||||
}
|
||||
});
|
||||
@@ -130,6 +137,7 @@ impl Clone for KiroProvider {
|
||||
Self {
|
||||
credentials: self.credentials.clone(),
|
||||
client: reqwest::Client::new(),
|
||||
creds_path: self.creds_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,11 +146,15 @@ impl Clone for KiroProvider {
|
||||
#[allow(dead_code)]
|
||||
struct AppState {
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
kiro: Arc<RwLock<KiroProvider>>,
|
||||
logs: Arc<RwLock<LogStore>>,
|
||||
kiro_refresh_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
gemini_refresh_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
qwen_refresh_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
pool_service: Arc<ProviderPoolService>,
|
||||
token_cache: Arc<TokenCacheService>,
|
||||
db: Option<DbConnection>,
|
||||
}
|
||||
|
||||
async fn run_server(
|
||||
@@ -152,22 +164,34 @@ async fn run_server(
|
||||
kiro: KiroProvider,
|
||||
logs: Arc<RwLock<LogStore>>,
|
||||
shutdown: oneshot::Receiver<()>,
|
||||
pool_service: Arc<ProviderPoolService>,
|
||||
token_cache: Arc<TokenCacheService>,
|
||||
db: Option<DbConnection>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_url = format!("http://{}:{}", host, port);
|
||||
let state = AppState {
|
||||
api_key: api_key.to_string(),
|
||||
base_url,
|
||||
kiro: Arc::new(RwLock::new(kiro)),
|
||||
logs,
|
||||
kiro_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
gemini_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
qwen_refresh_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
pool_service,
|
||||
token_cache,
|
||||
db,
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/v1/models", get(models))
|
||||
.route("/v1/routes", get(list_routes))
|
||||
.route("/v1/chat/completions", post(chat_completions))
|
||||
.route("/v1/messages", post(anthropic_messages))
|
||||
.route("/v1/messages/count_tokens", post(count_tokens))
|
||||
// 多供应商路由
|
||||
.route("/{selector}/v1/messages", post(anthropic_messages_with_selector))
|
||||
.route("/{selector}/v1/chat/completions", post(chat_completions_with_selector))
|
||||
.with_state(state);
|
||||
|
||||
let addr: std::net::SocketAddr = format!("{host}:{port}").parse()?;
|
||||
@@ -1310,3 +1334,774 @@ fn parse_bracket_tool_calls(result: &mut CWParsedResponse) {
|
||||
result.content = result.content.trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有可用路由
|
||||
async fn list_routes(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let routes = match &state.db {
|
||||
Some(db) => state
|
||||
.pool_service
|
||||
.get_available_routes(db, &state.base_url)
|
||||
.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
// 添加默认路由
|
||||
let mut all_routes = vec![RouteInfo {
|
||||
selector: "default".to_string(),
|
||||
provider_type: "kiro".to_string(),
|
||||
credential_count: 1,
|
||||
endpoints: vec![
|
||||
crate::models::route_model::RouteEndpoint {
|
||||
path: "/v1/messages".to_string(),
|
||||
protocol: "claude".to_string(),
|
||||
url: format!("{}/v1/messages", state.base_url),
|
||||
},
|
||||
crate::models::route_model::RouteEndpoint {
|
||||
path: "/v1/chat/completions".to_string(),
|
||||
protocol: "openai".to_string(),
|
||||
url: format!("{}/v1/chat/completions", state.base_url),
|
||||
},
|
||||
],
|
||||
tags: vec!["默认".to_string()],
|
||||
enabled: true,
|
||||
}];
|
||||
all_routes.extend(routes);
|
||||
|
||||
let response = RouteListResponse {
|
||||
base_url: state.base_url.clone(),
|
||||
default_provider: "kiro".to_string(),
|
||||
routes: all_routes,
|
||||
};
|
||||
|
||||
Json(response)
|
||||
}
|
||||
|
||||
/// 带选择器的 Anthropic messages 处理
|
||||
async fn anthropic_messages_with_selector(
|
||||
State(state): State<AppState>,
|
||||
Path(selector): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<AnthropicMessagesRequest>,
|
||||
) -> Response {
|
||||
if let Err(e) = verify_api_key(&headers, &state.api_key).await {
|
||||
state
|
||||
.logs
|
||||
.write()
|
||||
.await
|
||||
.add("warn", &format!("Unauthorized request to /{}/v1/messages", selector));
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[REQ] POST /{}/v1/messages model={} stream={}",
|
||||
selector, request.model, request.stream
|
||||
),
|
||||
);
|
||||
|
||||
// 尝试解析凭证
|
||||
let credential = match &state.db {
|
||||
Some(db) => {
|
||||
// 首先尝试按名称查找
|
||||
if let Ok(Some(cred)) = state.pool_service.get_by_name(db, &selector) {
|
||||
Some(cred)
|
||||
}
|
||||
// 然后尝试按 UUID 查找
|
||||
else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &selector) {
|
||||
Some(cred)
|
||||
}
|
||||
// 最后尝试按 provider 类型轮询
|
||||
else if let Ok(Some(cred)) = state
|
||||
.pool_service
|
||||
.select_credential(db, &selector, Some(&request.model))
|
||||
{
|
||||
Some(cred)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match credential {
|
||||
Some(cred) => {
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[ROUTE] Using credential: type={} name={:?} uuid={}",
|
||||
cred.provider_type,
|
||||
cred.name,
|
||||
&cred.uuid[..8]
|
||||
),
|
||||
);
|
||||
|
||||
// 根据凭证类型调用相应的 Provider
|
||||
call_provider_anthropic(&state, &cred, &request).await
|
||||
}
|
||||
None => {
|
||||
// 回退到默认 Kiro provider
|
||||
state.logs.write().await.add(
|
||||
"warn",
|
||||
&format!(
|
||||
"[ROUTE] Credential not found for selector '{}', falling back to default",
|
||||
selector
|
||||
),
|
||||
);
|
||||
// 调用原有的 Kiro 处理逻辑
|
||||
anthropic_messages_internal(&state, &request).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 带选择器的 OpenAI chat completions 处理
|
||||
async fn chat_completions_with_selector(
|
||||
State(state): State<AppState>,
|
||||
Path(selector): Path<String>,
|
||||
headers: HeaderMap,
|
||||
Json(request): Json<ChatCompletionRequest>,
|
||||
) -> Response {
|
||||
if let Err(e) = verify_api_key(&headers, &state.api_key).await {
|
||||
state
|
||||
.logs
|
||||
.write()
|
||||
.await
|
||||
.add("warn", &format!("Unauthorized request to /{}/v1/chat/completions", selector));
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[REQ] POST /{}/v1/chat/completions model={} stream={}",
|
||||
selector, request.model, request.stream
|
||||
),
|
||||
);
|
||||
|
||||
// 尝试解析凭证
|
||||
let credential = match &state.db {
|
||||
Some(db) => {
|
||||
if let Ok(Some(cred)) = state.pool_service.get_by_name(db, &selector) {
|
||||
Some(cred)
|
||||
} else if let Ok(Some(cred)) = state.pool_service.get_by_uuid(db, &selector) {
|
||||
Some(cred)
|
||||
} else if let Ok(Some(cred)) = state
|
||||
.pool_service
|
||||
.select_credential(db, &selector, Some(&request.model))
|
||||
{
|
||||
Some(cred)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
match credential {
|
||||
Some(cred) => {
|
||||
state.logs.write().await.add(
|
||||
"info",
|
||||
&format!(
|
||||
"[ROUTE] Using credential: type={} name={:?} uuid={}",
|
||||
cred.provider_type,
|
||||
cred.name,
|
||||
&cred.uuid[..8]
|
||||
),
|
||||
);
|
||||
|
||||
call_provider_openai(&state, &cred, &request).await
|
||||
}
|
||||
None => {
|
||||
state.logs.write().await.add(
|
||||
"warn",
|
||||
&format!(
|
||||
"[ROUTE] Credential not found for selector '{}', falling back to default",
|
||||
selector
|
||||
),
|
||||
);
|
||||
chat_completions_internal(&state, &request).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部 Anthropic messages 处理 (使用默认 Kiro)
|
||||
async fn anthropic_messages_internal(
|
||||
state: &AppState,
|
||||
request: &AnthropicMessagesRequest,
|
||||
) -> Response {
|
||||
// 检查 token
|
||||
{
|
||||
let _guard = state.kiro_refresh_lock.lock().await;
|
||||
let mut kiro = state.kiro.write().await;
|
||||
let needs_refresh =
|
||||
kiro.credentials.access_token.is_none() || kiro.is_token_expiring_soon();
|
||||
if needs_refresh {
|
||||
if let Err(e) = kiro.refresh_token().await {
|
||||
state
|
||||
.logs
|
||||
.write()
|
||||
.await
|
||||
.add("error", &format!("[AUTH] Token refresh failed: {e}"));
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": {"message": format!("Token refresh failed: {e}")}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let openai_request = convert_anthropic_to_openai(request);
|
||||
let kiro = state.kiro.read().await;
|
||||
|
||||
match kiro.call_api(&openai_request).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
match resp.bytes().await {
|
||||
Ok(bytes) => {
|
||||
let body = String::from_utf8_lossy(&bytes).to_string();
|
||||
let parsed = parse_cw_response(&body);
|
||||
if request.stream {
|
||||
build_anthropic_stream_response(&request.model, &parsed)
|
||||
} else {
|
||||
build_anthropic_response(&request.model, &parsed)
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
(
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Json(serde_json::json!({"error": {"message": format!("Upstream error: {}", body)}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 内部 OpenAI chat completions 处理 (使用默认 Kiro)
|
||||
async fn chat_completions_internal(state: &AppState, request: &ChatCompletionRequest) -> Response {
|
||||
{
|
||||
let _guard = state.kiro_refresh_lock.lock().await;
|
||||
let mut kiro = state.kiro.write().await;
|
||||
let needs_refresh =
|
||||
kiro.credentials.access_token.is_none() || kiro.is_token_expiring_soon();
|
||||
if needs_refresh {
|
||||
if let Err(e) = kiro.refresh_token().await {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": {"message": format!("Token refresh failed: {e}")}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let kiro = state.kiro.read().await;
|
||||
match kiro.call_api(request).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
match resp.text().await {
|
||||
Ok(body) => {
|
||||
let parsed = parse_cw_response(&body);
|
||||
let has_tool_calls = !parsed.tool_calls.is_empty();
|
||||
|
||||
let message = if has_tool_calls {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": if parsed.content.is_empty() { serde_json::Value::Null } else { serde_json::json!(parsed.content) },
|
||||
"tool_calls": parsed.tool_calls.iter().map(|tc| {
|
||||
serde_json::json!({
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments
|
||||
}
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": parsed.content
|
||||
})
|
||||
};
|
||||
|
||||
let response = serde_json::json!({
|
||||
"id": format!("chatcmpl-{}", uuid::Uuid::new_v4()),
|
||||
"object": "chat.completion",
|
||||
"created": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
"model": request.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": if has_tool_calls { "tool_calls" } else { "stop" }
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
});
|
||||
Json(response).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
(
|
||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Json(serde_json::json!({"error": {"message": format!("Upstream error: {}", body)}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
use crate::models::provider_pool_model::{CredentialData, ProviderCredential};
|
||||
|
||||
/// 根据凭证调用 Provider (Anthropic 格式)
|
||||
async fn call_provider_anthropic(
|
||||
state: &AppState,
|
||||
credential: &ProviderCredential,
|
||||
request: &AnthropicMessagesRequest,
|
||||
) -> Response {
|
||||
match &credential.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
// 使用 TokenCacheService 获取有效 token
|
||||
let db = match &state.db {
|
||||
Some(db) => db,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": "Database not available"}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 获取缓存的 token
|
||||
let token = match state.token_cache.get_valid_token(db, &credential.uuid).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!("[POOL] Token cache miss, loading from source: {}", e);
|
||||
// 回退到从源文件加载
|
||||
let mut kiro = KiroProvider::new();
|
||||
if let Err(e) = kiro.load_credentials_from_path(creds_file_path).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": format!("Failed to load Kiro credentials: {}", e)}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = kiro.refresh_token().await {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": {"message": format!("Token refresh failed: {}", e)}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
kiro.credentials.access_token.unwrap_or_default()
|
||||
}
|
||||
};
|
||||
|
||||
// 使用获取到的 token 创建 KiroProvider
|
||||
let mut kiro = KiroProvider::new();
|
||||
kiro.credentials.access_token = Some(token);
|
||||
// 从源文件加载其他配置(region, profile_arn 等)
|
||||
let _ = kiro.load_credentials_from_path(creds_file_path).await;
|
||||
|
||||
let openai_request = convert_anthropic_to_openai(request);
|
||||
let resp = match kiro.call_api(&openai_request).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
match resp.bytes().await {
|
||||
Ok(bytes) => {
|
||||
let body = String::from_utf8_lossy(&bytes).to_string();
|
||||
let parsed = parse_cw_response(&body);
|
||||
if request.stream {
|
||||
build_anthropic_stream_response(&request.model, &parsed)
|
||||
} else {
|
||||
build_anthropic_response(&request.model, &parsed)
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else if status.as_u16() == 401 || status.as_u16() == 403 {
|
||||
// Token 过期,强制刷新并重试
|
||||
tracing::info!("[POOL] Got {}, forcing token refresh for {}", status, &credential.uuid[..8]);
|
||||
|
||||
let new_token = match state.token_cache.refresh_and_cache(db, &credential.uuid, true).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": {"message": format!("Token refresh failed: {}", e)}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 使用新 token 重试
|
||||
kiro.credentials.access_token = Some(new_token);
|
||||
match kiro.call_api(&openai_request).await {
|
||||
Ok(retry_resp) => {
|
||||
if retry_resp.status().is_success() {
|
||||
match retry_resp.bytes().await {
|
||||
Ok(bytes) => {
|
||||
let body = String::from_utf8_lossy(&bytes).to_string();
|
||||
let parsed = parse_cw_response(&body);
|
||||
if request.stream {
|
||||
build_anthropic_stream_response(&request.model, &parsed)
|
||||
} else {
|
||||
build_anthropic_response(&request.model, &parsed)
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else {
|
||||
let body = retry_resp.text().await.unwrap_or_default();
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": format!("Retry failed: {}", body)}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": body}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
CredentialData::GeminiOAuth { .. } => {
|
||||
// Gemini OAuth 路由暂不支持
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(serde_json::json!({"error": {"message": "Gemini OAuth routing not yet implemented. Use /v1/messages with Gemini models instead."}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
CredentialData::QwenOAuth { .. } => {
|
||||
// Qwen OAuth 路由暂不支持
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(serde_json::json!({"error": {"message": "Qwen OAuth routing not yet implemented. Use /v1/messages with Qwen models instead."}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
CredentialData::OpenAIKey { api_key, base_url } => {
|
||||
let openai = OpenAICustomProvider::with_config(api_key.clone(), base_url.clone());
|
||||
let openai_request = convert_anthropic_to_openai(request);
|
||||
match openai.call_api(&openai_request).await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
match resp.text().await {
|
||||
Ok(body) => {
|
||||
if let Ok(openai_resp) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
let content = openai_resp["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.unwrap_or("");
|
||||
let parsed = CWParsedResponse {
|
||||
content: content.to_string(),
|
||||
tool_calls: Vec::new(),
|
||||
usage_credits: 0.0,
|
||||
context_usage_percentage: 0.0,
|
||||
};
|
||||
if request.stream {
|
||||
build_anthropic_stream_response(&request.model, &parsed)
|
||||
} else {
|
||||
build_anthropic_response(&request.model, &parsed)
|
||||
}
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": "Failed to parse OpenAI response"}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": body}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
CredentialData::ClaudeKey { api_key, base_url } => {
|
||||
let claude = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone());
|
||||
match claude.call_api(request).await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
match resp.text().await {
|
||||
Ok(body) => {
|
||||
if status.is_success() {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(body))
|
||||
.unwrap_or_else(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": "Failed to build response"}})),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
} else {
|
||||
(
|
||||
StatusCode::from_u16(status.as_u16())
|
||||
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
Json(serde_json::json!({"error": {"message": body}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据凭证调用 Provider (OpenAI 格式)
|
||||
async fn call_provider_openai(
|
||||
_state: &AppState,
|
||||
credential: &ProviderCredential,
|
||||
request: &ChatCompletionRequest,
|
||||
) -> Response {
|
||||
match &credential.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
let mut kiro = KiroProvider::new();
|
||||
if let Err(e) = kiro.load_credentials_from_path(creds_file_path).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": format!("Failed to load Kiro credentials: {}", e)}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = kiro.refresh_token().await {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": {"message": format!("Token refresh failed: {}", e)}})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match kiro.call_api(request).await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
match resp.text().await {
|
||||
Ok(body) => {
|
||||
let parsed = parse_cw_response(&body);
|
||||
let has_tool_calls = !parsed.tool_calls.is_empty();
|
||||
|
||||
let message = if has_tool_calls {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": if parsed.content.is_empty() { serde_json::Value::Null } else { serde_json::json!(parsed.content) },
|
||||
"tool_calls": parsed.tool_calls.iter().map(|tc| {
|
||||
serde_json::json!({
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments
|
||||
}
|
||||
})
|
||||
}).collect::<Vec<_>>()
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": parsed.content
|
||||
})
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"id": format!("chatcmpl-{}", uuid::Uuid::new_v4()),
|
||||
"object": "chat.completion",
|
||||
"created": std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
"model": request.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": if has_tool_calls { "tool_calls" } else { "stop" }
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": body}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
CredentialData::GeminiOAuth { .. } => {
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(serde_json::json!({"error": {"message": "Gemini OAuth routing not yet implemented."}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
CredentialData::QwenOAuth { .. } => {
|
||||
(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(serde_json::json!({"error": {"message": "Qwen OAuth routing not yet implemented."}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
CredentialData::OpenAIKey { api_key, base_url } => {
|
||||
let openai = OpenAICustomProvider::with_config(api_key.clone(), base_url.clone());
|
||||
match openai.call_api(request).await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
match resp.text().await {
|
||||
Ok(body) => {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
Json(json).into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": "Invalid JSON response"}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": body}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
CredentialData::ClaudeKey { api_key, base_url } => {
|
||||
let claude = ClaudeCustomProvider::with_config(api_key.clone(), base_url.clone());
|
||||
match claude.call_openai_api(request).await {
|
||||
Ok(resp) => Json(resp).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": {"message": e.to_string()}})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@ pub mod mcp_service;
|
||||
pub mod mcp_sync;
|
||||
pub mod prompt_service;
|
||||
pub mod prompt_sync;
|
||||
pub mod provider_pool_service;
|
||||
pub mod skill_service;
|
||||
pub mod switch;
|
||||
pub mod token_cache_service;
|
||||
|
||||
@@ -0,0 +1,852 @@
|
||||
//! Provider Pool 管理服务
|
||||
//!
|
||||
//! 提供凭证池的选择、健康检测、负载均衡等功能。
|
||||
|
||||
use crate::database::dao::provider_pool::ProviderPoolDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::provider_pool_model::{
|
||||
get_default_check_model, get_oauth_creds_path, CredentialData, CredentialDisplay,
|
||||
HealthCheckResult, OAuthStatus, PoolProviderType, PoolStats, ProviderCredential,
|
||||
ProviderPoolOverview,
|
||||
};
|
||||
use crate::models::route_model::RouteInfo;
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
use chrono::Utc;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
/// 凭证池管理服务
|
||||
pub struct ProviderPoolService {
|
||||
/// HTTP 客户端(用于健康检测)
|
||||
client: Client,
|
||||
/// 轮询索引(按 provider_type 和可选的 model 分组)
|
||||
round_robin_index: std::sync::RwLock<HashMap<String, AtomicUsize>>,
|
||||
/// 最大错误次数(超过后标记为不健康)
|
||||
max_error_count: u32,
|
||||
/// 健康检查超时时间
|
||||
health_check_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for ProviderPoolService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderPoolService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_default(),
|
||||
round_robin_index: std::sync::RwLock::new(HashMap::new()),
|
||||
max_error_count: 3,
|
||||
health_check_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有凭证概览
|
||||
pub fn get_overview(&self, db: &DbConnection) -> Result<Vec<ProviderPoolOverview>, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let grouped = ProviderPoolDao::get_grouped(&conn).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut overview = Vec::new();
|
||||
for (provider_type, mut credentials) in grouped {
|
||||
// 为每个凭证加载 token 缓存
|
||||
for cred in &mut credentials {
|
||||
cred.cached_token = ProviderPoolDao::get_token_cache(&conn, &cred.uuid)
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
|
||||
let stats = PoolStats::from_credentials(&credentials);
|
||||
let displays: Vec<CredentialDisplay> = credentials.iter().map(|c| c.into()).collect();
|
||||
|
||||
overview.push(ProviderPoolOverview {
|
||||
provider_type: provider_type.to_string(),
|
||||
stats,
|
||||
credentials: displays,
|
||||
});
|
||||
}
|
||||
|
||||
// 按 provider_type 排序
|
||||
overview.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
|
||||
Ok(overview)
|
||||
}
|
||||
|
||||
/// 获取指定类型的凭证列表
|
||||
pub fn get_by_type(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
provider_type: &str,
|
||||
) -> Result<Vec<CredentialDisplay>, String> {
|
||||
let pt: PoolProviderType = provider_type.parse().map_err(|e: String| e)?;
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let mut credentials = ProviderPoolDao::get_by_type(&conn, &pt).map_err(|e| e.to_string())?;
|
||||
|
||||
// 为每个凭证加载 token 缓存
|
||||
for cred in &mut credentials {
|
||||
cred.cached_token = ProviderPoolDao::get_token_cache(&conn, &cred.uuid)
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
|
||||
Ok(credentials.iter().map(|c| c.into()).collect())
|
||||
}
|
||||
|
||||
/// 添加凭证
|
||||
pub fn add_credential(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
provider_type: &str,
|
||||
credential: CredentialData,
|
||||
name: Option<String>,
|
||||
check_health: Option<bool>,
|
||||
check_model_name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
let pt: PoolProviderType = provider_type.parse().map_err(|e: String| e)?;
|
||||
|
||||
let mut cred = ProviderCredential::new(pt, credential);
|
||||
cred.name = name;
|
||||
cred.check_health = check_health.unwrap_or(true);
|
||||
cred.check_model_name = check_model_name;
|
||||
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::insert(&conn, &cred).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
/// 更新凭证
|
||||
pub fn update_credential(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
name: Option<String>,
|
||||
is_disabled: Option<bool>,
|
||||
check_health: Option<bool>,
|
||||
check_model_name: Option<String>,
|
||||
not_supported_models: Option<Vec<String>>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let mut cred = ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?;
|
||||
|
||||
if let Some(n) = name {
|
||||
cred.name = Some(n);
|
||||
}
|
||||
if let Some(d) = is_disabled {
|
||||
cred.is_disabled = d;
|
||||
}
|
||||
if let Some(c) = check_health {
|
||||
cred.check_health = c;
|
||||
}
|
||||
if let Some(m) = check_model_name {
|
||||
cred.check_model_name = Some(m);
|
||||
}
|
||||
if let Some(models) = not_supported_models {
|
||||
cred.not_supported_models = models;
|
||||
}
|
||||
cred.updated_at = Utc::now();
|
||||
|
||||
ProviderPoolDao::update(&conn, &cred).map_err(|e| e.to_string())?;
|
||||
Ok(cred)
|
||||
}
|
||||
|
||||
/// 删除凭证
|
||||
pub fn delete_credential(&self, db: &DbConnection, uuid: &str) -> Result<bool, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::delete(&conn, uuid).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 选择一个可用的凭证(轮询负载均衡)
|
||||
pub fn select_credential(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
provider_type: &str,
|
||||
model: Option<&str>,
|
||||
) -> Result<Option<ProviderCredential>, String> {
|
||||
let pt: PoolProviderType = provider_type.parse().map_err(|e: String| e)?;
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let credentials = ProviderPoolDao::get_by_type(&conn, &pt).map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
|
||||
// 过滤可用的凭证
|
||||
let mut available: Vec<_> = credentials
|
||||
.into_iter()
|
||||
.filter(|c| c.is_available())
|
||||
.collect();
|
||||
|
||||
// 如果指定了模型,进一步过滤支持该模型的凭证
|
||||
if let Some(m) = model {
|
||||
available.retain(|c| c.supports_model(m));
|
||||
}
|
||||
|
||||
if available.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// 轮询选择
|
||||
let index_key = match model {
|
||||
Some(m) => format!("{}:{}", provider_type, m),
|
||||
None => provider_type.to_string(),
|
||||
};
|
||||
|
||||
let index = {
|
||||
let indices = self.round_robin_index.read().unwrap();
|
||||
indices
|
||||
.get(&index_key)
|
||||
.map(|i| i.load(Ordering::SeqCst))
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let selected_index = index % available.len();
|
||||
let selected = available.remove(selected_index);
|
||||
|
||||
// 更新轮询索引
|
||||
{
|
||||
let mut indices = self.round_robin_index.write().unwrap();
|
||||
let counter = indices
|
||||
.entry(index_key)
|
||||
.or_insert_with(|| AtomicUsize::new(0));
|
||||
counter.store((index + 1) % usize::MAX, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
Ok(Some(selected))
|
||||
}
|
||||
|
||||
/// 记录凭证使用
|
||||
pub fn record_usage(&self, db: &DbConnection, uuid: &str) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let cred = ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?;
|
||||
|
||||
ProviderPoolDao::update_usage(&conn, uuid, cred.usage_count + 1, Utc::now())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 标记凭证为健康
|
||||
pub fn mark_healthy(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
check_model: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::update_health_status(
|
||||
&conn,
|
||||
uuid,
|
||||
true,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
Some(Utc::now()),
|
||||
check_model,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 标记凭证为不健康
|
||||
pub fn mark_unhealthy(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
error_message: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let cred = ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?;
|
||||
|
||||
let new_error_count = cred.error_count + 1;
|
||||
let is_healthy = new_error_count < self.max_error_count;
|
||||
|
||||
ProviderPoolDao::update_health_status(
|
||||
&conn,
|
||||
uuid,
|
||||
is_healthy,
|
||||
new_error_count,
|
||||
Some(Utc::now()),
|
||||
error_message,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 重置凭证计数器
|
||||
pub fn reset_counters(&self, db: &DbConnection, uuid: &str) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::reset_counters(&conn, uuid).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 重置指定类型的所有凭证健康状态
|
||||
pub fn reset_health_by_type(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
provider_type: &str,
|
||||
) -> Result<usize, String> {
|
||||
let pt: PoolProviderType = provider_type.parse().map_err(|e: String| e)?;
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::reset_health_by_type(&conn, &pt).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 执行单个凭证的健康检查
|
||||
pub async fn check_credential_health(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<HealthCheckResult, String> {
|
||||
let cred = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?
|
||||
};
|
||||
|
||||
let check_model = cred
|
||||
.check_model_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| get_default_check_model(cred.provider_type).to_string());
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let result = self
|
||||
.perform_health_check(&cred.credential, &check_model)
|
||||
.await;
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
self.mark_healthy(db, uuid, Some(&check_model))?;
|
||||
Ok(HealthCheckResult {
|
||||
uuid: uuid.to_string(),
|
||||
success: true,
|
||||
model: Some(check_model),
|
||||
message: Some("Health check passed".to_string()),
|
||||
duration_ms,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
self.mark_unhealthy(db, uuid, Some(&e))?;
|
||||
Ok(HealthCheckResult {
|
||||
uuid: uuid.to_string(),
|
||||
success: false,
|
||||
model: Some(check_model),
|
||||
message: Some(e),
|
||||
duration_ms,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行指定类型的所有凭证健康检查
|
||||
pub async fn check_type_health(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
provider_type: &str,
|
||||
) -> Result<Vec<HealthCheckResult>, String> {
|
||||
let pt: PoolProviderType = provider_type.parse().map_err(|e: String| e)?;
|
||||
let credentials = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_type(&conn, &pt).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
for cred in credentials {
|
||||
if cred.is_disabled || !cred.check_health {
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = self.check_credential_health(db, &cred.uuid).await?;
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 执行实际的健康检查请求
|
||||
async fn perform_health_check(
|
||||
&self,
|
||||
credential: &CredentialData,
|
||||
model: &str,
|
||||
) -> Result<(), String> {
|
||||
// 根据凭证类型构建测试请求
|
||||
match credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
self.check_kiro_health(creds_file_path, model).await
|
||||
}
|
||||
CredentialData::GeminiOAuth {
|
||||
creds_file_path,
|
||||
project_id,
|
||||
} => {
|
||||
self.check_gemini_health(creds_file_path, project_id.as_deref(), model)
|
||||
.await
|
||||
}
|
||||
CredentialData::QwenOAuth { creds_file_path } => {
|
||||
self.check_qwen_health(creds_file_path, model).await
|
||||
}
|
||||
CredentialData::OpenAIKey { api_key, base_url } => {
|
||||
self.check_openai_health(api_key, base_url.as_deref(), model)
|
||||
.await
|
||||
}
|
||||
CredentialData::ClaudeKey { api_key, base_url } => {
|
||||
self.check_claude_health(api_key, base_url.as_deref(), model)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 将技术错误转换为用户友好的错误信息
|
||||
fn format_user_friendly_error(&self, error: &str, provider_type: &str) -> String {
|
||||
if error.contains("No client_id") {
|
||||
format!("OAuth 配置不完整:缺少必要的认证参数。\n💡 解决方案:\n1. 检查 {} OAuth 凭证配置是否完整\n2. 如问题持续,建议删除后重新添加此凭证\n3. 或者切换到其他可用的凭证", provider_type)
|
||||
} else if error.contains("请求失败") || error.contains("error sending request") {
|
||||
format!("网络连接失败,无法访问 {} 服务。\n💡 解决方案:\n1. 检查网络连接是否正常\n2. 确认防火墙或代理设置\n3. 稍后重试,如问题持续请联系网络管理员", provider_type)
|
||||
} else if error.contains("HTTP 401") || error.contains("HTTP 403") {
|
||||
format!("{} 认证失败,凭证可能已过期或无效。\n💡 解决方案:\n1. 点击\"刷新\"按钮尝试更新 Token\n2. 如刷新失败,请删除后重新添加此凭证\n3. 检查账户权限是否正常", provider_type)
|
||||
} else if error.contains("HTTP 429") {
|
||||
format!("{} 请求频率过高,已被限流。\n💡 解决方案:\n1. 稍等几分钟后再次尝试\n2. 考虑添加更多凭证分散负载", provider_type)
|
||||
} else if error.contains("HTTP 500") || error.contains("HTTP 502") || error.contains("HTTP 503") {
|
||||
format!("{} 服务暂时不可用。\n💡 解决方案:\n1. 这通常是服务提供方的临时问题\n2. 请稍后重试\n3. 如问题持续,可尝试其他凭证", provider_type)
|
||||
} else if error.contains("读取凭证文件失败") || error.contains("解析凭证失败") {
|
||||
format!("凭证文件损坏或不可读。\n💡 解决方案:\n1. 凭证文件可能已损坏\n2. 建议删除此凭证后重新添加\n3. 确保文件权限正确且格式为有效的 JSON")
|
||||
} else {
|
||||
// 对于其他未识别的错误,提供通用建议
|
||||
format!("操作失败:{}\n💡 建议:\n1. 检查网络连接和凭证状态\n2. 尝试刷新 Token 或重新添加凭证\n3. 如问题持续,请联系技术支持", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Kiro OAuth 健康检查
|
||||
async fn check_kiro_health(&self, creds_path: &str, model: &str) -> Result<(), String> {
|
||||
tracing::debug!("[KIRO HEALTH] 开始健康检查,凭证路径: {}", creds_path);
|
||||
|
||||
// 使用 KiroProvider 加载凭证(包括 clientIdHash 文件)
|
||||
let mut provider = KiroProvider::new();
|
||||
provider.load_credentials_from_path(creds_path).await
|
||||
.map_err(|e| self.format_user_friendly_error(&format!("加载凭证失败: {}", e), "Kiro"))?;
|
||||
|
||||
let access_token = provider.credentials.access_token.as_ref()
|
||||
.ok_or_else(|| "凭证中缺少 access_token".to_string())?;
|
||||
|
||||
let health_check_url = provider.get_health_check_url();
|
||||
|
||||
// 获取 modelId 映射
|
||||
let model_id = match model {
|
||||
"claude-opus-4-5" | "claude-opus-4-5-20251101" => "claude-opus-4.5",
|
||||
"claude-haiku-4-5" => "claude-haiku-4.5",
|
||||
"claude-sonnet-4-5" | "claude-sonnet-4-5-20250929" => "CLAUDE_SONNET_4_5_20250929_V1_0",
|
||||
"claude-sonnet-4-20250514" => "CLAUDE_SONNET_4_20250514_V1_0",
|
||||
"claude-3-7-sonnet-20250219" => "CLAUDE_3_7_SONNET_20250219_V1_0",
|
||||
_ => "claude-haiku-4.5", // 默认使用 haiku
|
||||
};
|
||||
|
||||
tracing::debug!("[KIRO HEALTH] 健康检查 URL: {}", health_check_url);
|
||||
tracing::debug!("[KIRO HEALTH] 使用模型: {} -> {}", model, model_id);
|
||||
|
||||
// 构建与实际 API 调用相同格式的测试请求(参考 AIClient-2-API 实现)
|
||||
let conversation_id = uuid::Uuid::new_v4().to_string();
|
||||
let mut request_body = serde_json::json!({
|
||||
"conversationState": {
|
||||
"chatTriggerType": "MANUAL",
|
||||
"conversationId": conversation_id,
|
||||
"currentMessage": {
|
||||
"userInputMessage": {
|
||||
"content": "Say OK",
|
||||
"modelId": model_id,
|
||||
"origin": "AI_EDITOR"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果是 social 认证方式,需要添加 profileArn
|
||||
if provider.credentials.auth_method.as_deref() == Some("social") {
|
||||
if let Some(profile_arn) = &provider.credentials.profile_arn {
|
||||
request_body["profileArn"] = serde_json::json!(profile_arn);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!("[KIRO HEALTH] 请求体已构建");
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&health_check_url)
|
||||
.bearer_auth(access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.header("x-amz-user-agent", "aws-sdk-js/1.0.7 KiroIDE-0.1.25")
|
||||
.header("user-agent", "aws-sdk-js/1.0.7 ua/2.1 os/macos#14.0 lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.7 m/E KiroIDE-0.1.25")
|
||||
.header("amz-sdk-invocation-id", uuid::Uuid::new_v4().to_string())
|
||||
.header("amz-sdk-request", "attempt=1; max=1")
|
||||
.header("x-amzn-kiro-agent-mode", "vibe")
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| self.format_user_friendly_error(&format!("请求失败: {}", e), "Kiro"))?;
|
||||
|
||||
let status = response.status();
|
||||
tracing::info!("[KIRO HEALTH] 响应状态: {}", status);
|
||||
|
||||
if status.is_success() {
|
||||
tracing::info!("[KIRO HEALTH] 健康检查成功");
|
||||
Ok(())
|
||||
} else {
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("[KIRO HEALTH] 健康检查失败: {} - {}", status, body_text);
|
||||
let error_msg = format!("HTTP {}: {}", status, body_text);
|
||||
Err(self.format_user_friendly_error(&error_msg, "Kiro"))
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini OAuth 健康检查
|
||||
async fn check_gemini_health(
|
||||
&self,
|
||||
creds_path: &str,
|
||||
_project_id: Option<&str>,
|
||||
model: &str,
|
||||
) -> Result<(), String> {
|
||||
let creds_content =
|
||||
std::fs::read_to_string(creds_path).map_err(|e| format!("读取凭证文件失败: {}", e))?;
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&creds_content).map_err(|e| format!("解析凭证失败: {}", e))?;
|
||||
|
||||
let access_token = creds["access_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "凭证中缺少 access_token".to_string())?;
|
||||
|
||||
let url = format!(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent",
|
||||
model
|
||||
);
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"contents": [{
|
||||
"parts": [{"text": "Say OK"}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 10
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(access_token)
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("HTTP {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen OAuth 健康检查
|
||||
async fn check_qwen_health(&self, creds_path: &str, model: &str) -> Result<(), String> {
|
||||
let creds_content =
|
||||
std::fs::read_to_string(creds_path).map_err(|e| format!("读取凭证文件失败: {}", e))?;
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&creds_content).map_err(|e| format!("解析凭证失败: {}", e))?;
|
||||
|
||||
let access_token = creds["access_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| "凭证中缺少 access_token".to_string())?;
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "Say OK"}],
|
||||
"max_tokens": 10
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("https://chat.qwen.ai/api/v1/chat/completions")
|
||||
.bearer_auth(access_token)
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("HTTP {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI API 健康检查
|
||||
async fn check_openai_health(
|
||||
&self,
|
||||
api_key: &str,
|
||||
base_url: Option<&str>,
|
||||
model: &str,
|
||||
) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"{}/chat/completions",
|
||||
base_url.unwrap_or("https://api.openai.com/v1")
|
||||
);
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "Say OK"}],
|
||||
"max_tokens": 10
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(api_key)
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("HTTP {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
// Claude API 健康检查
|
||||
async fn check_claude_health(
|
||||
&self,
|
||||
api_key: &str,
|
||||
base_url: Option<&str>,
|
||||
model: &str,
|
||||
) -> Result<(), String> {
|
||||
let url = format!(
|
||||
"{}/messages",
|
||||
base_url.unwrap_or("https://api.anthropic.com/v1")
|
||||
);
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "Say OK"}],
|
||||
"max_tokens": 10
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("请求失败: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("HTTP {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据名称获取凭证
|
||||
pub fn get_by_name(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
name: &str,
|
||||
) -> Result<Option<ProviderCredential>, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_name(&conn, name).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 根据 UUID 获取凭证
|
||||
pub fn get_by_uuid(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<Option<ProviderCredential>, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取所有可用的路由端点
|
||||
pub fn get_available_routes(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<RouteInfo>, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let grouped = ProviderPoolDao::get_grouped(&conn).map_err(|e| e.to_string())?;
|
||||
drop(conn);
|
||||
|
||||
let mut routes = Vec::new();
|
||||
|
||||
// 为每种 Provider 类型创建路由
|
||||
for (provider_type, credentials) in &grouped {
|
||||
let available: Vec<_> = credentials.iter().filter(|c| c.is_available()).collect();
|
||||
if available.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Provider 类型路由 (轮询)
|
||||
let mut route = RouteInfo::new(provider_type.to_string(), provider_type.to_string());
|
||||
route.credential_count = available.len();
|
||||
route.add_endpoint(base_url, "claude");
|
||||
route.add_endpoint(base_url, "openai");
|
||||
route.tags.push("轮询".to_string());
|
||||
routes.push(route);
|
||||
}
|
||||
|
||||
// 为每个命名凭证创建路由
|
||||
for (_provider_type, credentials) in &grouped {
|
||||
for cred in credentials {
|
||||
if let Some(name) = &cred.name {
|
||||
if cred.is_available() {
|
||||
let mut route =
|
||||
RouteInfo::new(name.clone(), cred.provider_type.to_string());
|
||||
route.credential_count = 1;
|
||||
route.enabled = !cred.is_disabled;
|
||||
route.add_endpoint(base_url, "claude");
|
||||
route.add_endpoint(base_url, "openai");
|
||||
route.tags.push("指定凭证".to_string());
|
||||
routes.push(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(routes)
|
||||
}
|
||||
|
||||
/// 获取 OAuth 凭证状态
|
||||
pub fn get_oauth_status(&self, creds_path: &str, provider_type: &str) -> Result<OAuthStatus, String> {
|
||||
let content = std::fs::read_to_string(creds_path)
|
||||
.map_err(|e| format!("读取凭证文件失败: {}", e))?;
|
||||
let creds: serde_json::Value = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("解析凭证文件失败: {}", e))?;
|
||||
|
||||
let has_access_token = creds.get("accessToken")
|
||||
.or_else(|| creds.get("access_token"))
|
||||
.map(|v| v.as_str().is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
let has_refresh_token = creds.get("refreshToken")
|
||||
.or_else(|| creds.get("refresh_token"))
|
||||
.map(|v| v.as_str().is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 检查 token 是否有效(根据 expiry_date 判断)
|
||||
let (is_token_valid, expiry_info) = match provider_type {
|
||||
"kiro" => {
|
||||
let expires_at = creds.get("expiresAt")
|
||||
.or_else(|| creds.get("expires_at"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
// Kiro 没有标准的过期时间字段,假设有 access_token 就有效
|
||||
(has_access_token, expires_at)
|
||||
}
|
||||
"gemini" | "qwen" => {
|
||||
let expiry = creds.get("expiry_date").and_then(|v| v.as_i64());
|
||||
if let Some(exp) = expiry {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let is_valid = exp > now;
|
||||
let expiry_str = chrono::DateTime::from_timestamp(exp, 0)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_else(|| exp.to_string());
|
||||
(is_valid, Some(expiry_str))
|
||||
} else {
|
||||
(has_access_token, None)
|
||||
}
|
||||
}
|
||||
_ => (has_access_token, None),
|
||||
};
|
||||
|
||||
Ok(OAuthStatus {
|
||||
has_access_token,
|
||||
has_refresh_token,
|
||||
is_token_valid,
|
||||
expiry_info,
|
||||
creds_path: creds_path.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 刷新 OAuth Token (Kiro)
|
||||
pub async fn refresh_kiro_token(&self, creds_path: &str) -> Result<String, String> {
|
||||
let mut provider = crate::providers::kiro::KiroProvider::new();
|
||||
provider.load_credentials_from_path(creds_path).await
|
||||
.map_err(|e| self.format_user_friendly_error(&format!("加载凭证失败: {}", e), "Kiro"))?;
|
||||
provider.refresh_token().await
|
||||
.map_err(|e| self.format_user_friendly_error(&format!("刷新 Token 失败: {}", e), "Kiro"))
|
||||
}
|
||||
|
||||
/// 刷新 OAuth Token (Gemini)
|
||||
pub async fn refresh_gemini_token(&self, creds_path: &str) -> Result<String, String> {
|
||||
let mut provider = crate::providers::gemini::GeminiProvider::new();
|
||||
provider.load_credentials_from_path(creds_path).await
|
||||
.map_err(|e| format!("加载凭证失败: {}", e))?;
|
||||
provider.refresh_token().await
|
||||
.map_err(|e| format!("刷新 Token 失败: {}", e))
|
||||
}
|
||||
|
||||
/// 刷新 OAuth Token (Qwen)
|
||||
pub async fn refresh_qwen_token(&self, creds_path: &str) -> Result<String, String> {
|
||||
let mut provider = crate::providers::qwen::QwenProvider::new();
|
||||
provider.load_credentials_from_path(creds_path).await
|
||||
.map_err(|e| format!("加载凭证失败: {}", e))?;
|
||||
provider.refresh_token().await
|
||||
.map_err(|e| format!("刷新 Token 失败: {}", e))
|
||||
}
|
||||
|
||||
/// 刷新凭证池中指定凭证的 OAuth Token
|
||||
pub async fn refresh_credential_token(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<String, String> {
|
||||
let cred = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?
|
||||
};
|
||||
|
||||
match &cred.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
self.refresh_kiro_token(creds_file_path).await
|
||||
}
|
||||
CredentialData::GeminiOAuth { creds_file_path, .. } => {
|
||||
self.refresh_gemini_token(creds_file_path).await
|
||||
}
|
||||
CredentialData::QwenOAuth { creds_file_path } => {
|
||||
self.refresh_qwen_token(creds_file_path).await
|
||||
}
|
||||
_ => Err("此凭证类型不支持 Token 刷新".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取凭证池中指定凭证的 OAuth 状态
|
||||
pub fn get_credential_oauth_status(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<OAuthStatus, String> {
|
||||
let cred = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?
|
||||
};
|
||||
|
||||
let creds_path = get_oauth_creds_path(&cred.credential)
|
||||
.ok_or_else(|| "此凭证类型不是 OAuth 凭证".to_string())?;
|
||||
|
||||
self.get_oauth_status(&creds_path, &cred.provider_type.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Token 缓存管理服务
|
||||
//!
|
||||
//! 负责管理凭证池中 OAuth Token 的生命周期:
|
||||
//! - 从源文件加载初始 Token
|
||||
//! - 缓存刷新后的 Token 到数据库
|
||||
//! - 按需刷新即将过期的 Token
|
||||
//! - 处理 401/403 错误时的强制刷新
|
||||
|
||||
use crate::database::dao::provider_pool::ProviderPoolDao;
|
||||
use crate::database::DbConnection;
|
||||
use crate::models::provider_pool_model::{
|
||||
CachedTokenInfo, CredentialData, PoolProviderType, ProviderCredential,
|
||||
};
|
||||
use crate::providers::gemini::GeminiProvider;
|
||||
use crate::providers::kiro::KiroProvider;
|
||||
use crate::providers::qwen::QwenProvider;
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Token 缓存服务
|
||||
pub struct TokenCacheService {
|
||||
/// 每凭证一把锁,防止并发刷新
|
||||
locks: DashMap<String, Arc<Mutex<()>>>,
|
||||
}
|
||||
|
||||
impl Default for TokenCacheService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenCacheService {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
locks: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取有效的 Token(核心方法)
|
||||
///
|
||||
/// 1. 检查数据库缓存是否有效
|
||||
/// 2. 如果缓存有效且未过期,直接返回
|
||||
/// 3. 如果缓存无效或即将过期,执行刷新
|
||||
pub async fn get_valid_token(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<String, String> {
|
||||
// 首先检查缓存
|
||||
let cached = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_token_cache(&conn, uuid).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
// 缓存有效且未即将过期,直接返回
|
||||
if let Some(ref cache) = cached {
|
||||
if cache.is_valid() && !cache.is_expiring_soon() {
|
||||
if let Some(token) = &cache.access_token {
|
||||
tracing::debug!(
|
||||
"[TOKEN_CACHE] Using cached token for {}, expires at {:?}",
|
||||
&uuid[..8],
|
||||
cache.expiry_time
|
||||
);
|
||||
return Ok(token.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 需要刷新(无缓存、已过期或即将过期)
|
||||
self.refresh_and_cache(db, uuid, false).await
|
||||
}
|
||||
|
||||
/// 刷新 Token 并缓存到数据库
|
||||
///
|
||||
/// - force: 是否强制刷新(忽略缓存状态)
|
||||
pub async fn refresh_and_cache(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
force: bool,
|
||||
) -> Result<String, String> {
|
||||
// 获取该凭证的锁
|
||||
let lock = self
|
||||
.locks
|
||||
.entry(uuid.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone();
|
||||
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
// 双重检查:可能其他线程已完成刷新
|
||||
if !force {
|
||||
let cached = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_token_cache(&conn, uuid).map_err(|e| e.to_string())?
|
||||
};
|
||||
|
||||
if let Some(cache) = cached {
|
||||
if cache.is_valid() && !cache.is_expiring_soon() {
|
||||
if let Some(token) = cache.access_token {
|
||||
tracing::debug!(
|
||||
"[TOKEN_CACHE] Double-check: another thread refreshed for {}",
|
||||
&uuid[..8]
|
||||
);
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取凭证信息
|
||||
let credential = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"[TOKEN_CACHE] Refreshing token for {} ({})",
|
||||
&uuid[..8],
|
||||
credential.provider_type
|
||||
);
|
||||
|
||||
// 执行刷新
|
||||
match self.do_refresh(&credential).await {
|
||||
Ok(token_info) => {
|
||||
// 缓存到数据库
|
||||
{
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::update_token_cache(&conn, uuid, &token_info)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let token = token_info
|
||||
.access_token
|
||||
.ok_or_else(|| "Refresh succeeded but no access_token".to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
"[TOKEN_CACHE] Token refreshed and cached for {}, expires at {:?}",
|
||||
&uuid[..8],
|
||||
token_info.expiry_time
|
||||
);
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
Err(e) => {
|
||||
// 记录刷新错误
|
||||
{
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
let _ = ProviderPoolDao::record_token_refresh_error(&conn, uuid, &e);
|
||||
}
|
||||
|
||||
tracing::error!("[TOKEN_CACHE] Token refresh failed for {}: {}", &uuid[..8], e);
|
||||
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行实际的 Token 刷新
|
||||
async fn do_refresh(&self, credential: &ProviderCredential) -> Result<CachedTokenInfo, String> {
|
||||
match &credential.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
self.refresh_kiro(creds_file_path).await
|
||||
}
|
||||
CredentialData::GeminiOAuth {
|
||||
creds_file_path, ..
|
||||
} => self.refresh_gemini(creds_file_path).await,
|
||||
CredentialData::QwenOAuth { creds_file_path } => {
|
||||
self.refresh_qwen(creds_file_path).await
|
||||
}
|
||||
CredentialData::OpenAIKey { api_key, .. } => {
|
||||
// API Key 不需要刷新,直接返回
|
||||
Ok(CachedTokenInfo {
|
||||
access_token: Some(api_key.clone()),
|
||||
refresh_token: None,
|
||||
expiry_time: None, // 永不过期
|
||||
last_refresh: Some(Utc::now()),
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
CredentialData::ClaudeKey { api_key, .. } => {
|
||||
// API Key 不需要刷新,直接返回
|
||||
Ok(CachedTokenInfo {
|
||||
access_token: Some(api_key.clone()),
|
||||
refresh_token: None,
|
||||
expiry_time: None, // 永不过期
|
||||
last_refresh: Some(Utc::now()),
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新 Kiro Token
|
||||
async fn refresh_kiro(&self, creds_path: &str) -> Result<CachedTokenInfo, String> {
|
||||
let mut provider = KiroProvider::new();
|
||||
provider
|
||||
.load_credentials_from_path(creds_path)
|
||||
.await
|
||||
.map_err(|e| format!("加载 Kiro 凭证失败: {}", e))?;
|
||||
|
||||
let token = provider
|
||||
.refresh_token()
|
||||
.await
|
||||
.map_err(|e| format!("刷新 Kiro Token 失败: {}", e))?;
|
||||
|
||||
// Kiro token 通常 1 小时过期,我们假设 50 分钟
|
||||
let expiry_time = Utc::now() + chrono::Duration::minutes(50);
|
||||
|
||||
Ok(CachedTokenInfo {
|
||||
access_token: Some(token),
|
||||
refresh_token: provider.credentials.refresh_token.clone(),
|
||||
expiry_time: Some(expiry_time),
|
||||
last_refresh: Some(Utc::now()),
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 刷新 Gemini Token
|
||||
async fn refresh_gemini(&self, creds_path: &str) -> Result<CachedTokenInfo, String> {
|
||||
let mut provider = GeminiProvider::new();
|
||||
provider
|
||||
.load_credentials_from_path(creds_path)
|
||||
.await
|
||||
.map_err(|e| format!("加载 Gemini 凭证失败: {}", e))?;
|
||||
|
||||
let token = provider
|
||||
.refresh_token()
|
||||
.await
|
||||
.map_err(|e| format!("刷新 Gemini Token 失败: {}", e))?;
|
||||
|
||||
// Gemini token 通常 1 小时过期
|
||||
let expiry_time = provider
|
||||
.credentials
|
||||
.expiry_date
|
||||
.map(|ts| chrono::DateTime::from_timestamp(ts, 0).unwrap_or_default())
|
||||
.unwrap_or_else(|| Utc::now() + chrono::Duration::minutes(50));
|
||||
|
||||
Ok(CachedTokenInfo {
|
||||
access_token: Some(token),
|
||||
refresh_token: provider.credentials.refresh_token.clone(),
|
||||
expiry_time: Some(expiry_time),
|
||||
last_refresh: Some(Utc::now()),
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 刷新 Qwen Token
|
||||
async fn refresh_qwen(&self, creds_path: &str) -> Result<CachedTokenInfo, String> {
|
||||
let mut provider = QwenProvider::new();
|
||||
provider
|
||||
.load_credentials_from_path(creds_path)
|
||||
.await
|
||||
.map_err(|e| format!("加载 Qwen 凭证失败: {}", e))?;
|
||||
|
||||
let token = provider
|
||||
.refresh_token()
|
||||
.await
|
||||
.map_err(|e| format!("刷新 Qwen Token 失败: {}", e))?;
|
||||
|
||||
// Qwen token 通常 1 小时过期
|
||||
let expiry_time = provider
|
||||
.credentials
|
||||
.expiry_date
|
||||
.map(|ts| chrono::DateTime::from_timestamp(ts, 0).unwrap_or_default())
|
||||
.unwrap_or_else(|| Utc::now() + chrono::Duration::minutes(50));
|
||||
|
||||
Ok(CachedTokenInfo {
|
||||
access_token: Some(token),
|
||||
refresh_token: provider.credentials.refresh_token.clone(),
|
||||
expiry_time: Some(expiry_time),
|
||||
last_refresh: Some(Utc::now()),
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从源文件加载初始 Token(首次使用时)
|
||||
pub async fn load_initial_token(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<String, String> {
|
||||
let credential = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?
|
||||
};
|
||||
|
||||
// 尝试从源文件读取 token
|
||||
let token_info = self.read_token_from_source(&credential).await?;
|
||||
|
||||
// 缓存到数据库
|
||||
{
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::update_token_cache(&conn, uuid, &token_info)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
token_info
|
||||
.access_token
|
||||
.ok_or_else(|| "源文件中没有 access_token".to_string())
|
||||
}
|
||||
|
||||
/// 从源文件读取 Token(不刷新)
|
||||
async fn read_token_from_source(
|
||||
&self,
|
||||
credential: &ProviderCredential,
|
||||
) -> Result<CachedTokenInfo, String> {
|
||||
match &credential.credential {
|
||||
CredentialData::KiroOAuth { creds_file_path } => {
|
||||
let content = tokio::fs::read_to_string(creds_file_path)
|
||||
.await
|
||||
.map_err(|e| format!("读取 Kiro 凭证文件失败: {}", e))?;
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析凭证失败: {}", e))?;
|
||||
|
||||
let access_token = creds["accessToken"]
|
||||
.as_str()
|
||||
.or_else(|| creds["access_token"].as_str())
|
||||
.map(|s| s.to_string());
|
||||
let refresh_token = creds["refreshToken"]
|
||||
.as_str()
|
||||
.or_else(|| creds["refresh_token"].as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(CachedTokenInfo {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expiry_time: None, // Kiro 源文件通常没有过期时间
|
||||
last_refresh: None,
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
CredentialData::GeminiOAuth {
|
||||
creds_file_path, ..
|
||||
} => {
|
||||
let content = tokio::fs::read_to_string(creds_file_path)
|
||||
.await
|
||||
.map_err(|e| format!("读取 Gemini 凭证文件失败: {}", e))?;
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析凭证失败: {}", e))?;
|
||||
|
||||
let access_token = creds["access_token"].as_str().map(|s| s.to_string());
|
||||
let refresh_token = creds["refresh_token"].as_str().map(|s| s.to_string());
|
||||
let expiry_time = creds["expiry_date"]
|
||||
.as_i64()
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
|
||||
|
||||
Ok(CachedTokenInfo {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expiry_time,
|
||||
last_refresh: None,
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
CredentialData::QwenOAuth { creds_file_path } => {
|
||||
let content = tokio::fs::read_to_string(creds_file_path)
|
||||
.await
|
||||
.map_err(|e| format!("读取 Qwen 凭证文件失败: {}", e))?;
|
||||
let creds: serde_json::Value =
|
||||
serde_json::from_str(&content).map_err(|e| format!("解析凭证失败: {}", e))?;
|
||||
|
||||
let access_token = creds["access_token"].as_str().map(|s| s.to_string());
|
||||
let refresh_token = creds["refresh_token"].as_str().map(|s| s.to_string());
|
||||
let expiry_time = creds["expiry_date"]
|
||||
.as_i64()
|
||||
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
|
||||
|
||||
Ok(CachedTokenInfo {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expiry_time,
|
||||
last_refresh: None,
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
})
|
||||
}
|
||||
CredentialData::OpenAIKey { api_key, .. } => Ok(CachedTokenInfo {
|
||||
access_token: Some(api_key.clone()),
|
||||
refresh_token: None,
|
||||
expiry_time: None,
|
||||
last_refresh: None,
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
}),
|
||||
CredentialData::ClaudeKey { api_key, .. } => Ok(CachedTokenInfo {
|
||||
access_token: Some(api_key.clone()),
|
||||
refresh_token: None,
|
||||
expiry_time: None,
|
||||
last_refresh: None,
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除凭证的 Token 缓存
|
||||
pub fn clear_cache(&self, db: &DbConnection, uuid: &str) -> Result<(), String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::clear_token_cache(&conn, uuid).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 检查凭证类型是否支持 Token 刷新
|
||||
pub fn supports_refresh(provider_type: PoolProviderType) -> bool {
|
||||
matches!(
|
||||
provider_type,
|
||||
PoolProviderType::Kiro | PoolProviderType::Gemini | PoolProviderType::Qwen
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取凭证的缓存状态
|
||||
pub fn get_cache_status(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
uuid: &str,
|
||||
) -> Result<Option<CachedTokenInfo>, String> {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_token_cache(&conn, uuid).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.6.0",
|
||||
"version": "0.6.1",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+5
-5
@@ -7,13 +7,12 @@ import { SwitchPage } from "./components/switch";
|
||||
import { ClientsPage } from "./components/clients";
|
||||
import { McpPage } from "./components/mcp";
|
||||
import { PromptsPage } from "./components/prompts";
|
||||
import { CredentialsPage } from "./components/credentials/CredentialsPage";
|
||||
import { ApiServerPage } from "./components/api-server/ApiServerPage";
|
||||
import { SkillsPage } from "./components/skills";
|
||||
import { ProviderPoolPage } from "./components/provider-pool";
|
||||
|
||||
type Page =
|
||||
| "dashboard"
|
||||
| "credentials"
|
||||
| "clients"
|
||||
| "api-server"
|
||||
| "providers"
|
||||
@@ -21,7 +20,8 @@ type Page =
|
||||
| "switch"
|
||||
| "mcp"
|
||||
| "prompts"
|
||||
| "skills";
|
||||
| "skills"
|
||||
| "provider-pool";
|
||||
|
||||
function App() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>("dashboard");
|
||||
@@ -30,8 +30,8 @@ function App() {
|
||||
switch (currentPage) {
|
||||
case "dashboard":
|
||||
return <Dashboard />;
|
||||
case "credentials":
|
||||
return <CredentialsPage />;
|
||||
case "provider-pool":
|
||||
return <ProviderPoolPage />;
|
||||
case "clients":
|
||||
return <ClientsPage />;
|
||||
case "api-server":
|
||||
|
||||
@@ -3,16 +3,15 @@ import {
|
||||
Settings,
|
||||
Plug,
|
||||
MessageSquare,
|
||||
Key,
|
||||
Monitor,
|
||||
Globe,
|
||||
Boxes,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Page =
|
||||
| "dashboard"
|
||||
| "credentials"
|
||||
| "clients"
|
||||
| "api-server"
|
||||
| "providers"
|
||||
@@ -20,7 +19,8 @@ type Page =
|
||||
| "switch"
|
||||
| "mcp"
|
||||
| "prompts"
|
||||
| "skills";
|
||||
| "skills"
|
||||
| "provider-pool";
|
||||
|
||||
interface SidebarProps {
|
||||
currentPage: Page;
|
||||
@@ -29,7 +29,7 @@ interface SidebarProps {
|
||||
|
||||
const navItems = [
|
||||
{ id: "dashboard" as Page, label: "仪表盘", icon: LayoutDashboard },
|
||||
{ id: "credentials" as Page, label: "凭证管理", icon: Key },
|
||||
{ id: "provider-pool" as Page, label: "凭证池", icon: Database },
|
||||
{ id: "clients" as Page, label: "AI Clients", icon: Monitor },
|
||||
{ id: "api-server" as Page, label: "API Server", icon: Globe },
|
||||
{ id: "mcp" as Page, label: "MCP", icon: Plug },
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { ModelsTab } from "./ModelsTab";
|
||||
import { LogsTab } from "./LogsTab";
|
||||
import { RoutesTab } from "./RoutesTab";
|
||||
import {
|
||||
startServer,
|
||||
stopServer,
|
||||
@@ -44,7 +45,7 @@ interface TestState {
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
type TabId = "server" | "openai" | "claude" | "models" | "logs";
|
||||
type TabId = "server" | "routes" | "openai" | "claude" | "models" | "logs";
|
||||
|
||||
export function ApiServerPage() {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
@@ -453,6 +454,7 @@ export function ApiServerPage() {
|
||||
<div className="flex gap-2 border-b overflow-x-auto">
|
||||
{[
|
||||
{ id: "server" as TabId, name: "服务器控制" },
|
||||
{ id: "routes" as TabId, name: "路由端点" },
|
||||
{ id: "openai" as TabId, name: "OpenAI 自定义" },
|
||||
{ id: "claude" as TabId, name: "Claude 自定义" },
|
||||
{ id: "models" as TabId, name: "可用模型" },
|
||||
@@ -691,6 +693,9 @@ export function ApiServerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Routes Tab */}
|
||||
{activeTab === "routes" && <RoutesTab />}
|
||||
|
||||
{/* OpenAI Custom Tab */}
|
||||
{activeTab === "openai" && (
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Copy, Check, RefreshCw, Globe, Server, Tag } from "lucide-react";
|
||||
import {
|
||||
routesApi,
|
||||
RouteInfo,
|
||||
RouteListResponse,
|
||||
CurlExample,
|
||||
} from "@/lib/api/routes";
|
||||
|
||||
export function RoutesTab() {
|
||||
const [routes, setRoutes] = useState<RouteListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
|
||||
const [curlExamples, setCurlExamples] = useState<
|
||||
Record<string, CurlExample[]>
|
||||
>({});
|
||||
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||
const [copiedCmd, setCopiedCmd] = useState<string | null>(null);
|
||||
|
||||
const fetchRoutes = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await routesApi.getAvailableRoutes();
|
||||
setRoutes(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoutes();
|
||||
}, []);
|
||||
|
||||
const fetchCurlExamples = async (selector: string) => {
|
||||
if (curlExamples[selector]) return;
|
||||
try {
|
||||
const examples = await routesApi.getCurlExamples(selector);
|
||||
setCurlExamples((prev) => ({ ...prev, [selector]: examples }));
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch curl examples:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpand = (selector: string) => {
|
||||
if (expandedRoute === selector) {
|
||||
setExpandedRoute(null);
|
||||
} else {
|
||||
setExpandedRoute(selector);
|
||||
fetchCurlExamples(selector);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, type: "url" | "cmd", id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (type === "url") {
|
||||
setCopiedUrl(id);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
} else {
|
||||
setCopiedCmd(id);
|
||||
setTimeout(() => setCopiedCmd(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderColor = (provider: string) => {
|
||||
switch (provider) {
|
||||
case "kiro":
|
||||
return "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400";
|
||||
case "gemini":
|
||||
return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400";
|
||||
case "qwen":
|
||||
return "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400";
|
||||
case "openai":
|
||||
return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400";
|
||||
case "claude":
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !routes) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">可用路由端点</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
通过不同的 URL 路径访问不同的 Provider
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRoutes}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700 dark:bg-red-950/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{routes && (
|
||||
<div className="space-y-4">
|
||||
{/* Base URL Info */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">服务器地址:</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono">
|
||||
{routes.base_url}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Routes List */}
|
||||
<div className="space-y-3">
|
||||
{routes.routes.map((route) => (
|
||||
<RouteCard
|
||||
key={route.selector}
|
||||
route={route}
|
||||
expanded={expandedRoute === route.selector}
|
||||
onExpand={() => handleExpand(route.selector)}
|
||||
curlExamples={curlExamples[route.selector]}
|
||||
copiedUrl={copiedUrl}
|
||||
copiedCmd={copiedCmd}
|
||||
onCopyUrl={(url, id) => copyToClipboard(url, "url", id)}
|
||||
onCopyCmd={(cmd, id) => copyToClipboard(cmd, "cmd", id)}
|
||||
getProviderColor={getProviderColor}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RouteCardProps {
|
||||
route: RouteInfo;
|
||||
expanded: boolean;
|
||||
onExpand: () => void;
|
||||
curlExamples?: CurlExample[];
|
||||
copiedUrl: string | null;
|
||||
copiedCmd: string | null;
|
||||
onCopyUrl: (url: string, id: string) => void;
|
||||
onCopyCmd: (cmd: string, id: string) => void;
|
||||
getProviderColor: (provider: string) => string;
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
route,
|
||||
expanded,
|
||||
onExpand,
|
||||
curlExamples,
|
||||
copiedUrl,
|
||||
copiedCmd,
|
||||
onCopyUrl,
|
||||
onCopyCmd,
|
||||
getProviderColor,
|
||||
}: RouteCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-muted/50"
|
||||
onClick={onExpand}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{route.selector}</span>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${getProviderColor(route.provider_type)}`}
|
||||
>
|
||||
{route.provider_type}
|
||||
</span>
|
||||
{route.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{route.credential_count} 个凭证
|
||||
{!route.enabled && (
|
||||
<span className="ml-2 text-red-500">(已禁用)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{expanded ? "收起" : "展开"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expanded && (
|
||||
<div className="border-t p-4 space-y-4">
|
||||
{/* Endpoints */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">端点地址</p>
|
||||
<div className="space-y-2">
|
||||
{route.endpoints.map((endpoint, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between rounded bg-muted p-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
endpoint.protocol === "claude"
|
||||
? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
}`}
|
||||
>
|
||||
{endpoint.protocol.toUpperCase()}
|
||||
</span>
|
||||
<code className="text-sm font-mono">{endpoint.url}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyUrl(endpoint.url, `${route.selector}-${idx}`);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-background"
|
||||
title="复制 URL"
|
||||
>
|
||||
{copiedUrl === `${route.selector}-${idx}` ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Curl Examples */}
|
||||
{curlExamples && curlExamples.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">curl 示例</p>
|
||||
<div className="space-y-3">
|
||||
{curlExamples.map((example, idx) => (
|
||||
<div key={idx} className="rounded border bg-background p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{example.description}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyCmd(
|
||||
example.command,
|
||||
`${route.selector}-cmd-${idx}`,
|
||||
);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-muted"
|
||||
title="复制命令"
|
||||
>
|
||||
{copiedCmd === `${route.selector}-cmd-${idx}` ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap bg-muted rounded p-2">
|
||||
{example.command}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,411 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
RefreshCw,
|
||||
FolderOpen,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
FileText,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
useOAuthCredentials,
|
||||
useAllOAuthCredentials,
|
||||
} from "@/hooks/useOAuthCredentials";
|
||||
import { OAuthProvider, EnvVariable } from "@/lib/api/credentials";
|
||||
|
||||
type TabId = "kiro" | "gemini" | "qwen";
|
||||
|
||||
const tabs: { id: TabId; name: string; description: string }[] = [
|
||||
{
|
||||
id: "kiro",
|
||||
name: "Kiro Claude",
|
||||
description: "通过 Kiro OAuth 访问 Claude Sonnet 4.5",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "Gemini CLI",
|
||||
description: "通过 Gemini CLI OAuth 访问 Gemini 模型",
|
||||
},
|
||||
{
|
||||
id: "qwen",
|
||||
name: "通义千问",
|
||||
description: "通过 Qwen OAuth 访问通义千问",
|
||||
},
|
||||
];
|
||||
|
||||
function CredentialPanel({ provider }: { provider: OAuthProvider }) {
|
||||
const {
|
||||
credentials,
|
||||
envVariables,
|
||||
loading,
|
||||
refreshing,
|
||||
error,
|
||||
reloadFromFile,
|
||||
refreshToken,
|
||||
} = useOAuthCredentials(provider);
|
||||
|
||||
const [showEnv, setShowEnv] = useState(false);
|
||||
const [showValues, setShowValues] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleReload = async () => {
|
||||
setMessage(null);
|
||||
try {
|
||||
await reloadFromFile();
|
||||
setMessage({ type: "success", text: "凭证加载成功!" });
|
||||
} catch (e) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: `加载失败: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setMessage(null);
|
||||
try {
|
||||
await refreshToken();
|
||||
setMessage({ type: "success", text: "Token 刷新成功!" });
|
||||
} catch (e) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: `刷新失败: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyValue = (key: string, value: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const copyAllEnv = (vars: EnvVariable[]) => {
|
||||
navigator.clipboard.writeText(
|
||||
vars.map((v) => `${v.key}=${v.value}`).join("\n"),
|
||||
);
|
||||
setCopied("all");
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const isLoading = loading || refreshing;
|
||||
|
||||
// Provider-specific extra info
|
||||
const renderExtraInfo = () => {
|
||||
if (!credentials?.extra) return null;
|
||||
const extra = credentials.extra as Record<string, unknown>;
|
||||
|
||||
if (provider === "kiro") {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-muted-foreground">区域:</span>
|
||||
<span className="ml-2">{(extra.region as string) || "未设置"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">认证方式:</span>
|
||||
<span className="ml-2">
|
||||
{(extra.auth_method as string) || "social"}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "qwen" && extra.resource_url) {
|
||||
return (
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">Resource URL:</span>
|
||||
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
|
||||
{extra.resource_url as string}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{message && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border p-3 text-sm ${
|
||||
message.type === "success"
|
||||
? "border-green-500 bg-green-50 text-green-700 dark:bg-green-950/30"
|
||||
: "border-red-500 bg-red-50 text-red-700 dark:bg-red-950/30"
|
||||
}`}
|
||||
>
|
||||
{message.type === "success" ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
)}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Key className="h-4 w-4" />
|
||||
凭证状态
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
credentials?.is_valid
|
||||
? "bg-green-500"
|
||||
: credentials?.loaded
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-400"
|
||||
} animate-pulse`}
|
||||
/>
|
||||
{credentials?.is_valid
|
||||
? "有效"
|
||||
: credentials?.loaded
|
||||
? "已加载"
|
||||
: "未加载"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">凭证路径:</span>
|
||||
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
|
||||
{credentials?.creds_path || "未知"}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Access Token:</span>
|
||||
<span
|
||||
className={`ml-2 ${credentials?.has_access_token ? "text-green-600" : "text-red-500"}`}
|
||||
>
|
||||
{credentials?.has_access_token ? "已加载" : "未加载"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Refresh Token:</span>
|
||||
<span
|
||||
className={`ml-2 ${credentials?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
|
||||
>
|
||||
{credentials?.has_refresh_token ? "已加载" : "未加载"}
|
||||
</span>
|
||||
</div>
|
||||
{renderExtraInfo()}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleReload}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
{loading ? "加载中..." : "读取凭证"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading || !credentials?.has_refresh_token}
|
||||
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新 Token
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowEnv(!showEnv)}
|
||||
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{showEnv ? "隐藏" : "查看"} .env 变量
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
{showEnv && (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-semibold">.env 环境变量</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowValues(!showValues)}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
{showValues ? (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3" />
|
||||
)}
|
||||
{showValues ? "隐藏值" : "显示值"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyAllEnv(envVariables)}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
{copied === "all" ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
复制全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{envVariables.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无环境变量,请先加载凭证
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 font-mono text-sm">
|
||||
{envVariables.map((v) => (
|
||||
<div
|
||||
key={v.key}
|
||||
className="flex items-center gap-2 rounded bg-muted p-2"
|
||||
>
|
||||
<span className="text-blue-600 dark:text-blue-400 shrink-0">
|
||||
{v.key}
|
||||
</span>
|
||||
<span>=</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">
|
||||
{showValues ? v.value : v.masked}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyValue(v.key, v.value)}
|
||||
className="rounded p-1 hover:bg-background shrink-0"
|
||||
>
|
||||
{copied === v.key ? (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialOverview() {
|
||||
const { credentials, reload } = useAllOAuthCredentials();
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{credentials.map((cred) => {
|
||||
const tab = tabs.find((t) => t.id === cred.provider);
|
||||
return (
|
||||
<div
|
||||
key={cred.provider}
|
||||
className="rounded-lg border bg-card p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">{tab?.name || cred.provider}</h4>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
cred.is_valid
|
||||
? "bg-green-500"
|
||||
: cred.loaded
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tab?.description || ""}
|
||||
</p>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Access Token</span>
|
||||
<span
|
||||
className={
|
||||
cred.has_access_token ? "text-green-600" : "text-red-500"
|
||||
}
|
||||
>
|
||||
{cred.has_access_token ? "已加载" : "未加载"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Refresh Token</span>
|
||||
<span
|
||||
className={
|
||||
cred.has_refresh_token ? "text-green-600" : "text-red-500"
|
||||
}
|
||||
>
|
||||
{cred.has_refresh_token ? "已加载" : "未加载"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CredentialsPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("kiro");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">OAuth 凭证管理</h2>
|
||||
<p className="text-muted-foreground">
|
||||
管理 Kiro/Gemini/Qwen 的 OAuth 凭证
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<CredentialOverview />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<CredentialPanel provider={activeTab} />
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
系统每 5 秒自动检查凭证文件变化,如有更新会自动重新加载并记录日志
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState } from "react";
|
||||
import { X, Key, FolderOpen } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { providerPoolApi, PoolProviderType } from "@/lib/api/providerPool";
|
||||
|
||||
interface AddCredentialModalProps {
|
||||
providerType: PoolProviderType;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// Default credential paths
|
||||
const defaultCredsPath: Record<string, string> = {
|
||||
kiro: "~/.aws/sso/cache/kiro-auth-token.json",
|
||||
gemini: "~/.gemini/oauth_creds.json",
|
||||
qwen: "~/.qwen/oauth_creds.json",
|
||||
};
|
||||
|
||||
export function AddCredentialModal({
|
||||
providerType,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AddCredentialModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// OAuth fields - initialize with default path
|
||||
const [credsFilePath, setCredsFilePath] = useState(
|
||||
defaultCredsPath[providerType] || "",
|
||||
);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
||||
// API Key fields
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
const isOAuth = ["kiro", "gemini", "qwen"].includes(providerType);
|
||||
|
||||
const providerLabels: Record<PoolProviderType, string> = {
|
||||
kiro: "Kiro (AWS)",
|
||||
gemini: "Gemini (Google)",
|
||||
qwen: "Qwen (阿里)",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude (Anthropic)",
|
||||
};
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (selected) {
|
||||
setCredsFilePath(selected as string);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to open file dialog:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
|
||||
if (isOAuth) {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (providerType) {
|
||||
case "kiro":
|
||||
await providerPoolApi.addKiroOAuth(credsFilePath, trimmedName);
|
||||
break;
|
||||
case "gemini":
|
||||
await providerPoolApi.addGeminiOAuth(
|
||||
credsFilePath,
|
||||
projectId.trim() || undefined,
|
||||
trimmedName,
|
||||
);
|
||||
break;
|
||||
case "qwen":
|
||||
await providerPoolApi.addQwenOAuth(credsFilePath, trimmedName);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!apiKey) {
|
||||
setError("请输入 API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (providerType) {
|
||||
case "openai":
|
||||
await providerPoolApi.addOpenAIKey(
|
||||
apiKey,
|
||||
baseUrl.trim() || undefined,
|
||||
trimmedName,
|
||||
);
|
||||
break;
|
||||
case "claude":
|
||||
await providerPoolApi.addClaudeKey(
|
||||
apiKey,
|
||||
baseUrl.trim() || undefined,
|
||||
trimmedName,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-background p-6 shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
添加 {providerLabels[providerType]} 凭证
|
||||
</h3>
|
||||
<button onClick={onClose} className="rounded-lg p-1 hover:bg-muted">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Name field */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
名称 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="给这个凭证起个名字..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOAuth ? (
|
||||
<>
|
||||
{/* Credential File */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
凭证文件路径 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={credsFilePath}
|
||||
onChange={(e) => setCredsFilePath(e.target.value)}
|
||||
placeholder="输入凭证文件的完整路径..."
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFile}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{providerType === "kiro" &&
|
||||
"默认路径: ~/.aws/sso/cache/kiro-auth-token.json"}
|
||||
{providerType === "gemini" &&
|
||||
"默认路径: ~/.gemini/oauth_creds.json"}
|
||||
{providerType === "qwen" &&
|
||||
"默认路径: ~/.qwen/oauth_creds.json"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gemini specific: Project ID */}
|
||||
{providerType === "gemini" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Project ID (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
placeholder="Google Cloud Project ID..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border bg-background pl-10 pr-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Base URL (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder={
|
||||
providerType === "openai"
|
||||
? "https://api.openai.com/v1"
|
||||
: "https://api.anthropic.com/v1"
|
||||
}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
留空使用默认 URL,或输入自定义代理地址
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
Heart,
|
||||
HeartOff,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Activity,
|
||||
Power,
|
||||
PowerOff,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Key,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Database,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import type { CredentialDisplay } from "@/lib/api/providerPool";
|
||||
|
||||
interface CredentialCardProps {
|
||||
credential: CredentialDisplay;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
onReset: () => void;
|
||||
onCheckHealth: () => void;
|
||||
onRefreshToken?: () => void;
|
||||
onEdit: () => void;
|
||||
deleting: boolean;
|
||||
checkingHealth: boolean;
|
||||
refreshingToken?: boolean;
|
||||
}
|
||||
|
||||
export function CredentialCard({
|
||||
credential,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onReset,
|
||||
onCheckHealth,
|
||||
onRefreshToken,
|
||||
onEdit,
|
||||
deleting,
|
||||
checkingHealth,
|
||||
refreshingToken,
|
||||
}: CredentialCardProps) {
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "从未";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const getCredentialTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
kiro_oauth: "OAuth",
|
||||
gemini_oauth: "OAuth",
|
||||
qwen_oauth: "OAuth",
|
||||
openai_key: "API Key",
|
||||
claude_key: "API Key",
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const isHealthy = credential.is_healthy && !credential.is_disabled;
|
||||
const hasError = credential.error_count > 0;
|
||||
const isOAuth = credential.credential_type.includes("oauth");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-5 transition-all hover:shadow-md ${
|
||||
credential.is_disabled
|
||||
? "border-gray-200 bg-gray-50/80 opacity-60 dark:border-gray-700 dark:bg-gray-900/60"
|
||||
: isHealthy
|
||||
? "border-green-200 bg-gradient-to-br from-green-50/80 to-green-100/40 dark:border-green-800 dark:bg-gradient-to-br dark:from-green-950/40 dark:to-green-900/20 shadow-green-500/5"
|
||||
: "border-red-200 bg-gradient-to-br from-red-50/80 to-red-100/40 dark:border-red-800 dark:bg-gradient-to-br dark:from-red-950/40 dark:to-red-900/20 shadow-red-500/5"
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-semibold text-base truncate">
|
||||
{credential.name || `凭证 #${credential.uuid.slice(0, 12)}`}
|
||||
</h4>
|
||||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-medium whitespace-nowrap">
|
||||
{getCredentialTypeLabel(credential.credential_type)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{credential.uuid.slice(0, 24)}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`rounded-full p-2 ${
|
||||
credential.is_disabled
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: isHealthy
|
||||
? "bg-green-100 dark:bg-green-900/30"
|
||||
: "bg-red-100 dark:bg-red-900/30"
|
||||
}`}
|
||||
>
|
||||
{credential.is_disabled ? (
|
||||
<PowerOff className="h-4 w-4 text-gray-400" />
|
||||
) : isHealthy ? (
|
||||
<Heart className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<HeartOff className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="bg-white/50 dark:bg-black/20 rounded-lg p-3 mb-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-full bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<Activity className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">使用次数</div>
|
||||
<div className="font-semibold text-sm">
|
||||
{credential.usage_count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`rounded-full p-1.5 ${
|
||||
hasError
|
||||
? "bg-yellow-100 dark:bg-yellow-900/30"
|
||||
: "bg-green-100 dark:bg-green-900/30"
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle
|
||||
className={`h-3 w-3 ${
|
||||
hasError
|
||||
? "text-yellow-600 dark:text-yellow-400"
|
||||
: "text-green-600 dark:text-green-400"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">错误次数</div>
|
||||
<div className="font-semibold text-sm">
|
||||
{credential.error_count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-2 pt-2 border-t border-border/50">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
最后使用: {formatDate(credential.last_used)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health Check Info */}
|
||||
{credential.last_health_check_time && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
检查: {formatDate(credential.last_health_check_time)}
|
||||
{credential.last_health_check_model &&
|
||||
` (${credential.last_health_check_model})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OAuth Status */}
|
||||
{isOAuth && credential.oauth_status && (
|
||||
<div className="mb-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-950/30 p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="rounded-full bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<Key className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm">OAuth 状态</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Access Token
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{credential.oauth_status.has_access_token ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{credential.oauth_status.has_access_token ? "有效" : "缺失"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">Token 状态</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{credential.oauth_status.is_token_valid ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{credential.oauth_status.is_token_valid ? "有效" : "需刷新"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{credential.oauth_status.expiry_info && (
|
||||
<div className="pt-2 border-t border-blue-200/50 dark:border-blue-800/50">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
过期时间: {credential.oauth_status.expiry_info}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Cache Status */}
|
||||
{isOAuth && credential.token_cache_status && (
|
||||
<div className="mb-3 rounded-lg border border-purple-200 dark:border-purple-800 bg-purple-50/50 dark:bg-purple-950/30 p-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="rounded-full bg-purple-100 dark:bg-purple-900/30 p-1.5">
|
||||
<Database className="h-3 w-3 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm">Token 缓存</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">缓存状态</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{credential.token_cache_status.has_cached_token ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{credential.token_cache_status.has_cached_token
|
||||
? "已缓存"
|
||||
: "未缓存"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">有效性</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{credential.token_cache_status.is_valid ? (
|
||||
credential.token_cache_status.is_expiring_soon ? (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{credential.token_cache_status.is_valid
|
||||
? credential.token_cache_status.is_expiring_soon
|
||||
? "即将过期"
|
||||
: "有效"
|
||||
: "已过期"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{(credential.token_cache_status.last_refresh ||
|
||||
credential.token_cache_status.expiry_time) && (
|
||||
<div className="pt-2 border-t border-purple-200/50 dark:border-purple-800/50 space-y-1">
|
||||
{credential.token_cache_status.last_refresh && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
最后刷新:{" "}
|
||||
{formatDate(credential.token_cache_status.last_refresh)}
|
||||
</div>
|
||||
)}
|
||||
{credential.token_cache_status.expiry_time && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
过期时间:{" "}
|
||||
{formatDate(credential.token_cache_status.expiry_time)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{credential.token_cache_status.refresh_error_count > 0 && (
|
||||
<div className="pt-2 border-t border-red-200/50 dark:border-red-800/50">
|
||||
<div className="flex items-center gap-2 text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">
|
||||
刷新失败 {credential.token_cache_status.refresh_error_count}{" "}
|
||||
次
|
||||
</span>
|
||||
</div>
|
||||
{credential.token_cache_status.last_refresh_error && (
|
||||
<div className="mt-1 text-xs text-red-600 dark:text-red-400 truncate">
|
||||
{credential.token_cache_status.last_refresh_error.slice(
|
||||
0,
|
||||
60,
|
||||
)}
|
||||
{credential.token_cache_status.last_refresh_error.length >
|
||||
60 && "..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{credential.last_error_message && (
|
||||
<div className="mt-2 rounded bg-red-100 p-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
{credential.last_error_message.slice(0, 100)}
|
||||
{credential.last_error_message.length > 100 && "..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-border/30">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex items-center gap-1 rounded-lg px-3 py-2 text-xs font-medium transition-colors ${
|
||||
credential.is_disabled
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-800/40"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
title={credential.is_disabled ? "启用凭证" : "禁用凭证"}
|
||||
>
|
||||
{credential.is_disabled ? (
|
||||
<>
|
||||
<Power className="h-3 w-3" />
|
||||
启用
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PowerOff className="h-3 w-3" />
|
||||
禁用
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex items-center gap-1 rounded-lg bg-blue-100 px-3 py-2 text-xs font-medium text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-800/40 transition-colors"
|
||||
title="编辑凭证配置"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
编辑
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onCheckHealth}
|
||||
disabled={checkingHealth}
|
||||
className="flex items-center gap-1 rounded-lg bg-emerald-100 px-3 py-2 text-xs font-medium text-emerald-700 hover:bg-emerald-200 disabled:opacity-50 dark:bg-emerald-900/30 dark:text-emerald-400 dark:hover:bg-emerald-800/40 transition-colors"
|
||||
title="执行健康检测"
|
||||
>
|
||||
<Activity
|
||||
className={`h-3 w-3 ${checkingHealth ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
检测
|
||||
</button>
|
||||
|
||||
{isOAuth && onRefreshToken && (
|
||||
<button
|
||||
onClick={onRefreshToken}
|
||||
disabled={refreshingToken}
|
||||
className="flex items-center gap-1 rounded-lg bg-purple-100 px-3 py-2 text-xs font-medium text-purple-700 hover:bg-purple-200 disabled:opacity-50 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-800/40 transition-colors"
|
||||
title="刷新 OAuth Token"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3 w-3 ${refreshingToken ? "animate-spin" : ""}`}
|
||||
/>
|
||||
刷新
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex items-center gap-1 rounded-lg bg-orange-100 px-3 py-2 text-xs font-medium text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-800/40 transition-colors"
|
||||
title="重置统计计数器"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
重置
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={deleting}
|
||||
className="flex items-center gap-1 rounded-lg bg-red-100 px-3 py-2 text-xs font-medium text-red-700 hover:bg-red-200 disabled:opacity-50 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-800/40 transition-colors"
|
||||
title="删除凭证"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Eye, EyeOff, Settings, FolderOpen, Upload, CheckCircle } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
CredentialDisplay,
|
||||
UpdateCredentialRequest,
|
||||
} from "@/lib/api/providerPool";
|
||||
|
||||
interface EditCredentialModalProps {
|
||||
credential: CredentialDisplay | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEdit: (uuid: string, request: UpdateCredentialRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export function EditCredentialModal({
|
||||
credential,
|
||||
isOpen,
|
||||
onClose,
|
||||
onEdit,
|
||||
}: EditCredentialModalProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [checkHealth, setCheckHealth] = useState(true);
|
||||
const [checkModelName, setCheckModelName] = useState("");
|
||||
const [notSupportedModelsText, setNotSupportedModelsText] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCredentialDetails, setShowCredentialDetails] = useState(false);
|
||||
|
||||
// 重新上传文件相关状态
|
||||
const [newCredFilePath, setNewCredFilePath] = useState("");
|
||||
const [newProjectId, setNewProjectId] = useState("");
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (credential) {
|
||||
setName(credential.name || "");
|
||||
setCheckHealth(credential.check_health);
|
||||
setCheckModelName(credential.check_model_name || "");
|
||||
setNotSupportedModelsText(
|
||||
(credential.not_supported_models || []).join(", "),
|
||||
);
|
||||
setNewCredFilePath("");
|
||||
setNewProjectId("");
|
||||
setError(null);
|
||||
}
|
||||
}, [credential]);
|
||||
|
||||
if (!isOpen || !credential) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOAuth = credential.credential_type.includes("oauth");
|
||||
|
||||
const handleSelectNewFile = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (selected) {
|
||||
setNewCredFilePath(selected as string);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to open file dialog:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const getMaskedCredentialInfo = () => {
|
||||
if (isOAuth) {
|
||||
// OAuth 凭证显示文件路径(部分遮罩)
|
||||
const path = credential.display_credential;
|
||||
const parts = path.split("/");
|
||||
if (parts.length > 1) {
|
||||
const fileName = parts[parts.length - 1];
|
||||
const dirPath = parts.slice(0, -1).join("/");
|
||||
return `${dirPath}/***${fileName.slice(-8)}`;
|
||||
}
|
||||
return `***${path.slice(-12)}`;
|
||||
} else {
|
||||
// API Key 显示遮罩
|
||||
return credential.display_credential;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 解析不支持的模型列表
|
||||
const parsedNotSupportedModels = notSupportedModelsText
|
||||
.split(",")
|
||||
.map((model) => model.trim())
|
||||
.filter((model) => model.length > 0);
|
||||
|
||||
const updateRequest: UpdateCredentialRequest = {
|
||||
name: name.trim() || undefined,
|
||||
check_health: checkHealth,
|
||||
check_model_name: checkModelName.trim() || undefined,
|
||||
not_supported_models:
|
||||
parsedNotSupportedModels.length > 0
|
||||
? parsedNotSupportedModels
|
||||
: undefined,
|
||||
new_creds_file_path: newCredFilePath.trim() || undefined,
|
||||
new_project_id: newProjectId.trim() || undefined,
|
||||
};
|
||||
|
||||
await onEdit(credential.uuid, updateRequest);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl h-[80vh] rounded-lg bg-background shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4 px-6 pt-6">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
编辑凭证
|
||||
</h3>
|
||||
<button onClick={onClose} className="rounded-lg p-1 hover:bg-muted">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
{/* 凭证信息(只读) */}
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium">凭证信息</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCredentialDetails(!showCredentialDetails)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showCredentialDetails ? (
|
||||
<>
|
||||
<EyeOff className="h-3 w-3" />
|
||||
隐藏
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-3 w-3" />
|
||||
显示
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">类型:</span>
|
||||
<span className="font-mono">{credential.credential_type}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">UUID:</span>
|
||||
<span className="font-mono">
|
||||
{credential.uuid.slice(0, 24)}...
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{isOAuth ? "文件路径:" : "API Key:"}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{showCredentialDetails
|
||||
? credential.display_credential
|
||||
: getMaskedCredentialInfo()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
🔒 敏感信息(API Key、文件路径)无法修改,如需更改请删除后重新添加
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 可编辑字段 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="给这个凭证起个名字..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 健康检查设置 */}
|
||||
<div>
|
||||
<label className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkHealth}
|
||||
onChange={(e) => setCheckHealth(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
启用自动健康检查
|
||||
</label>
|
||||
{checkHealth && (
|
||||
<div className="ml-6">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
检查模型(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={checkModelName}
|
||||
onChange={(e) => setCheckModelName(e.target.value)}
|
||||
placeholder="留空使用默认模型..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 不支持的模型列表 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
不支持的模型
|
||||
</label>
|
||||
<textarea
|
||||
value={notSupportedModelsText}
|
||||
onChange={(e) => setNotSupportedModelsText(e.target.value)}
|
||||
placeholder="用逗号分隔多个模型,例如: model-1, model-2"
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
这些模型将不会路由到此凭证
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* OAuth 文件重新上传 */}
|
||||
{isOAuth && (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-950/30 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="rounded-full bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||
<Upload className="h-3 w-3 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm">重新上传凭证文件</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
选择新的凭证文件来替换当前文件。新文件将被复制到应用存储目录。
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
新凭证文件
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCredFilePath}
|
||||
onChange={(e) => setNewCredFilePath(e.target.value)}
|
||||
placeholder="选择新的凭证文件..."
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectNewFile}
|
||||
className="flex items-center gap-1 rounded-lg bg-blue-100 px-3 py-2 text-xs font-medium text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-800/40 transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
选择文件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{credential.credential_type === "gemini_oauth" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
项目ID(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectId}
|
||||
onChange={(e) => setNewProjectId(e.target.value)}
|
||||
placeholder="留空保持当前项目ID..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{newCredFilePath && (
|
||||
<div className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
文件已选择,保存后将替换当前凭证文件
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计信息(只读) */}
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<label className="mb-2 block text-sm font-medium">使用统计</label>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">使用次数:</span>
|
||||
<span className="font-mono">{credential.usage_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">错误次数:</span>
|
||||
<span className="font-mono">{credential.error_count}</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-muted-foreground">最后使用:</span>
|
||||
<span className="text-xs">
|
||||
{credential.last_used || "从未"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t px-6 py-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border px-4 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "保存中..." : "保存更改"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { AlertTriangle, X, RotateCcw, Trash2, Settings, CheckCircle2 } from "lucide-react";
|
||||
|
||||
export interface ErrorInfo {
|
||||
id: string;
|
||||
message: string;
|
||||
type: "delete" | "toggle" | "reset" | "health_check" | "refresh_token" | "general" | "success";
|
||||
uuid?: string; // 相关凭证的UUID(如果有的话)
|
||||
}
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
errors: ErrorInfo[];
|
||||
onDismiss: (id: string) => void;
|
||||
onRetry?: (error: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
const ErrorTypeConfig = {
|
||||
delete: {
|
||||
icon: Trash2,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-50 dark:bg-red-950/30",
|
||||
borderColor: "border-red-200 dark:border-red-800"
|
||||
},
|
||||
toggle: {
|
||||
icon: Settings,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/30",
|
||||
borderColor: "border-blue-200 dark:border-blue-800"
|
||||
},
|
||||
reset: {
|
||||
icon: RotateCcw,
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-50 dark:bg-orange-950/30",
|
||||
borderColor: "border-orange-200 dark:border-orange-800"
|
||||
},
|
||||
health_check: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-yellow-600 dark:text-yellow-400",
|
||||
bgColor: "bg-yellow-50 dark:bg-yellow-950/30",
|
||||
borderColor: "border-yellow-200 dark:border-yellow-800"
|
||||
},
|
||||
refresh_token: {
|
||||
icon: RotateCcw,
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-950/30",
|
||||
borderColor: "border-purple-200 dark:border-purple-800"
|
||||
},
|
||||
general: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-gray-600 dark:text-gray-400",
|
||||
bgColor: "bg-gray-50 dark:bg-gray-950/30",
|
||||
borderColor: "border-gray-200 dark:border-gray-800"
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-50 dark:bg-green-950/30",
|
||||
borderColor: "border-green-200 dark:border-green-800"
|
||||
}
|
||||
};
|
||||
|
||||
function ErrorItem({ error, onDismiss, onRetry }: {
|
||||
error: ErrorInfo;
|
||||
onDismiss: (id: string) => void;
|
||||
onRetry?: (error: ErrorInfo) => void;
|
||||
}) {
|
||||
const config = ErrorTypeConfig[error.type];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${config.bgColor} ${config.borderColor}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<IconComponent className={`h-5 w-5 mt-0.5 ${config.color}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-line">
|
||||
{error.message}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={() => onRetry(error)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded bg-white dark:bg-gray-800 border hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDismiss(error.id)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded bg-white dark:bg-gray-800 border hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorDisplay({ errors, onDismiss, onRetry }: ErrorDisplayProps) {
|
||||
// 自动关闭通知
|
||||
useEffect(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
errors.forEach(error => {
|
||||
// 成功消息 3 秒后自动关闭,其他类型 15 秒后自动关闭
|
||||
if (error.type === "success") {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(error.id);
|
||||
}, 3000); // 3秒后自动关闭
|
||||
timers.push(timer);
|
||||
} else if (error.type === "general" || error.message.includes("💡")) {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(error.id);
|
||||
}, 15000); // 15秒后自动关闭
|
||||
timers.push(timer);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
timers.forEach(timer => clearTimeout(timer));
|
||||
};
|
||||
}, [errors, onDismiss]);
|
||||
|
||||
if (errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 w-96 max-w-full">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{errors.map(error => (
|
||||
<ErrorItem
|
||||
key={error.id}
|
||||
error={error}
|
||||
onDismiss={onDismiss}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook for managing errors and success messages
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useErrorDisplay() {
|
||||
const [errors, setErrors] = useState<ErrorInfo[]>([]);
|
||||
|
||||
const showError = (message: string, type: ErrorInfo["type"] = "general", uuid?: string) => {
|
||||
// 检查是否已经存在相同的错误消息(基于 message, type, uuid 的组合)
|
||||
setErrors(prev => {
|
||||
const isDuplicate = prev.some(existing =>
|
||||
existing.message === message &&
|
||||
existing.type === type &&
|
||||
existing.uuid === uuid
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
return prev; // 如果重复,不添加新的错误
|
||||
}
|
||||
|
||||
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
const error: ErrorInfo = { id, message, type, uuid };
|
||||
return [...prev, error];
|
||||
});
|
||||
};
|
||||
|
||||
const showSuccess = (message: string, uuid?: string) => {
|
||||
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9);
|
||||
const info: ErrorInfo = { id, message, type: "success", uuid };
|
||||
setErrors(prev => [...prev, info]);
|
||||
};
|
||||
|
||||
const dismissError = (id: string) => {
|
||||
setErrors(prev => prev.filter(error => error.id !== id));
|
||||
};
|
||||
|
||||
const clearErrors = () => {
|
||||
setErrors([]);
|
||||
};
|
||||
|
||||
return {
|
||||
errors,
|
||||
showError,
|
||||
showSuccess,
|
||||
dismissError,
|
||||
clearErrors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useState, forwardRef, useImperativeHandle } from "react";
|
||||
import {
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Heart,
|
||||
HeartOff,
|
||||
RotateCcw,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { useProviderPool } from "@/hooks/useProviderPool";
|
||||
import { CredentialCard } from "./CredentialCard";
|
||||
import { AddCredentialModal } from "./AddCredentialModal";
|
||||
import { EditCredentialModal } from "./EditCredentialModal";
|
||||
import { ErrorDisplay, useErrorDisplay } from "./ErrorDisplay";
|
||||
import type {
|
||||
PoolProviderType,
|
||||
CredentialDisplay,
|
||||
UpdateCredentialRequest,
|
||||
} from "@/lib/api/providerPool";
|
||||
|
||||
export interface ProviderPoolPageRef {
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
// All provider types
|
||||
const allProviderTypes: PoolProviderType[] = [
|
||||
"kiro",
|
||||
"gemini",
|
||||
"qwen",
|
||||
"openai",
|
||||
"claude",
|
||||
];
|
||||
|
||||
const providerLabels: Record<PoolProviderType, string> = {
|
||||
kiro: "Kiro (AWS)",
|
||||
gemini: "Gemini (Google)",
|
||||
qwen: "Qwen (阿里)",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude (Anthropic)",
|
||||
};
|
||||
|
||||
export const ProviderPoolPage = forwardRef<ProviderPoolPageRef>(
|
||||
(_props, ref) => {
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingCredential, setEditingCredential] =
|
||||
useState<CredentialDisplay | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<PoolProviderType>("kiro");
|
||||
const [deletingCredentials, setDeletingCredentials] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const { errors, showError, showSuccess, dismissError } = useErrorDisplay();
|
||||
|
||||
const {
|
||||
overview,
|
||||
loading,
|
||||
error,
|
||||
checkingHealth,
|
||||
refreshingToken,
|
||||
refresh,
|
||||
deleteCredential,
|
||||
toggleCredential,
|
||||
resetCredential,
|
||||
resetHealth,
|
||||
checkCredentialHealth,
|
||||
checkTypeHealth,
|
||||
refreshCredentialToken,
|
||||
updateCredential,
|
||||
} = useProviderPool();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh,
|
||||
}));
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (!confirm("确定要删除这个凭证吗?")) return;
|
||||
setDeletingCredentials((prev) => new Set(prev).add(uuid));
|
||||
try {
|
||||
await deleteCredential(uuid);
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "delete", uuid);
|
||||
} finally {
|
||||
setDeletingCredentials((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(uuid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (credential: CredentialDisplay) => {
|
||||
try {
|
||||
await toggleCredential(credential.uuid, !credential.is_disabled);
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "toggle", credential.uuid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async (uuid: string) => {
|
||||
try {
|
||||
await resetCredential(uuid);
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "reset", uuid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckHealth = async (uuid: string) => {
|
||||
try {
|
||||
const result = await checkCredentialHealth(uuid);
|
||||
if (result.success) {
|
||||
showSuccess("健康检查通过!", uuid);
|
||||
} else {
|
||||
showError(result.message || "健康检查未通过", "health_check", uuid);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "health_check", uuid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckTypeHealth = async (providerType: PoolProviderType) => {
|
||||
try {
|
||||
await checkTypeHealth(providerType);
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "health_check");
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetTypeHealth = async (providerType: PoolProviderType) => {
|
||||
try {
|
||||
await resetHealth(providerType);
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "reset");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshToken = async (uuid: string) => {
|
||||
try {
|
||||
await refreshCredentialToken(uuid);
|
||||
showSuccess("Token 刷新成功!", uuid);
|
||||
} catch (e) {
|
||||
showError(e instanceof Error ? e.message : String(e), "refresh_token", uuid);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (credential: CredentialDisplay) => {
|
||||
setEditingCredential(credential);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (
|
||||
uuid: string,
|
||||
request: UpdateCredentialRequest,
|
||||
) => {
|
||||
try {
|
||||
await updateCredential(uuid, request);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`编辑失败: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditModalOpen(false);
|
||||
setEditingCredential(null);
|
||||
};
|
||||
|
||||
const openAddModal = () => {
|
||||
setAddModalOpen(true);
|
||||
};
|
||||
|
||||
const getProviderOverview = (providerType: PoolProviderType) => {
|
||||
return overview.find((p) => p.provider_type === providerType);
|
||||
};
|
||||
|
||||
const getCredentialCount = (providerType: PoolProviderType) => {
|
||||
const pool = getProviderOverview(providerType);
|
||||
return pool?.credentials?.length || 0;
|
||||
};
|
||||
|
||||
// Current tab data
|
||||
const currentPool = getProviderOverview(activeTab);
|
||||
const currentStats = currentPool?.stats;
|
||||
const currentCredentials = currentPool?.credentials || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">凭证池</h2>
|
||||
<p className="text-muted-foreground">
|
||||
管理多个凭证,支持负载均衡和健康检测
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-4 text-red-700 dark:bg-red-950/30">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b overflow-x-auto">
|
||||
{allProviderTypes.map((providerType) => {
|
||||
const count = getCredentialCount(providerType);
|
||||
return (
|
||||
<button
|
||||
key={providerType}
|
||||
onClick={() => setActiveTab(providerType)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px whitespace-nowrap flex items-center gap-2 ${
|
||||
activeTab === providerType
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{providerLabels[providerType]}
|
||||
{count > 0 && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-xs">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Stats and Actions Bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
{currentStats && currentStats.total > 0 && (
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4 text-green-500" />
|
||||
健康: {currentStats.healthy}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HeartOff className="h-4 w-4 text-red-500" />
|
||||
不健康: {currentStats.unhealthy}
|
||||
</span>
|
||||
<span>总计: {currentStats.total}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentCredentials.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleCheckTypeHealth(activeTab)}
|
||||
disabled={checkingHealth === activeTab}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Activity
|
||||
className={`h-4 w-4 ${checkingHealth === activeTab ? "animate-pulse" : ""}`}
|
||||
/>
|
||||
检测全部
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResetTypeHealth(activeTab)}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置状态
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加凭证
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials List */}
|
||||
{currentCredentials.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12 text-muted-foreground">
|
||||
<p className="text-lg">暂无 {providerLabels[activeTab]} 凭证</p>
|
||||
<p className="mt-1 text-sm">点击上方"添加凭证"按钮添加</p>
|
||||
<button
|
||||
onClick={openAddModal}
|
||||
className="mt-4 flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加第一个凭证
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{currentCredentials.map((credential) => (
|
||||
<CredentialCard
|
||||
key={credential.uuid}
|
||||
credential={credential}
|
||||
onToggle={() => handleToggle(credential)}
|
||||
onDelete={() => handleDelete(credential.uuid)}
|
||||
onReset={() => handleReset(credential.uuid)}
|
||||
onCheckHealth={() => handleCheckHealth(credential.uuid)}
|
||||
onRefreshToken={() => handleRefreshToken(credential.uuid)}
|
||||
onEdit={() => handleEdit(credential)}
|
||||
deleting={deletingCredentials.has(credential.uuid)}
|
||||
checkingHealth={checkingHealth === credential.uuid}
|
||||
refreshingToken={refreshingToken === credential.uuid}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Credential Modal */}
|
||||
{addModalOpen && (
|
||||
<AddCredentialModal
|
||||
providerType={activeTab}
|
||||
onClose={() => {
|
||||
setAddModalOpen(false);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setAddModalOpen(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Credential Modal */}
|
||||
<EditCredentialModal
|
||||
credential={editingCredential}
|
||||
isOpen={editModalOpen}
|
||||
onClose={closeEditModal}
|
||||
onEdit={handleEditSubmit}
|
||||
/>
|
||||
|
||||
{/* Error Display */}
|
||||
<ErrorDisplay
|
||||
errors={errors}
|
||||
onDismiss={dismissError}
|
||||
onRetry={(error) => {
|
||||
// 根据错误类型提供重试功能
|
||||
switch (error.type) {
|
||||
case "health_check":
|
||||
if (error.uuid) {
|
||||
handleCheckHealth(error.uuid);
|
||||
}
|
||||
break;
|
||||
case "refresh_token":
|
||||
if (error.uuid) {
|
||||
handleRefreshToken(error.uuid);
|
||||
}
|
||||
break;
|
||||
case "reset":
|
||||
if (error.uuid) {
|
||||
handleReset(error.uuid);
|
||||
}
|
||||
break;
|
||||
}
|
||||
dismissError(error.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ProviderPoolPage.displayName = "ProviderPoolPage";
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ProviderPoolPage } from "./ProviderPoolPage";
|
||||
export { CredentialCard } from "./CredentialCard";
|
||||
export { AddCredentialModal } from "./AddCredentialModal";
|
||||
export { EditCredentialModal } from "./EditCredentialModal";
|
||||
@@ -70,9 +70,7 @@ export function RepoManagerPanel({
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">仓库管理</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
管理 Skill 仓库源
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">管理 Skill 仓库源</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -82,10 +80,7 @@ export function RepoManagerPanel({
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 hover:bg-muted"
|
||||
>
|
||||
<button onClick={onClose} className="rounded-lg p-2 hover:bg-muted">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
providerPoolApi,
|
||||
ProviderPoolOverview,
|
||||
CredentialDisplay,
|
||||
PoolProviderType,
|
||||
HealthCheckResult,
|
||||
UpdateCredentialRequest,
|
||||
OAuthStatus,
|
||||
} from "@/lib/api/providerPool";
|
||||
|
||||
export function useProviderPool() {
|
||||
const [overview, setOverview] = useState<ProviderPoolOverview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [checkingHealth, setCheckingHealth] = useState<string | null>(null);
|
||||
const [refreshingToken, setRefreshingToken] = useState<string | null>(null);
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await providerPoolApi.getOverview();
|
||||
setOverview(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverview();
|
||||
}, [fetchOverview]);
|
||||
|
||||
// Add Kiro OAuth credential
|
||||
const addKiroOAuth = async (credsFilePath: string, name?: string) => {
|
||||
await providerPoolApi.addKiroOAuth(credsFilePath, name);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Add Gemini OAuth credential
|
||||
const addGeminiOAuth = async (
|
||||
credsFilePath: string,
|
||||
projectId?: string,
|
||||
name?: string,
|
||||
) => {
|
||||
await providerPoolApi.addGeminiOAuth(credsFilePath, projectId, name);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Add Qwen OAuth credential
|
||||
const addQwenOAuth = async (credsFilePath: string, name?: string) => {
|
||||
await providerPoolApi.addQwenOAuth(credsFilePath, name);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Add OpenAI API Key credential
|
||||
const addOpenAIKey = async (
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
name?: string,
|
||||
) => {
|
||||
await providerPoolApi.addOpenAIKey(apiKey, baseUrl, name);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Add Claude API Key credential
|
||||
const addClaudeKey = async (
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
name?: string,
|
||||
) => {
|
||||
await providerPoolApi.addClaudeKey(apiKey, baseUrl, name);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Update credential
|
||||
const updateCredential = async (
|
||||
uuid: string,
|
||||
request: UpdateCredentialRequest,
|
||||
) => {
|
||||
await providerPoolApi.updateCredential(uuid, request);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Delete credential
|
||||
const deleteCredential = async (uuid: string) => {
|
||||
await providerPoolApi.deleteCredential(uuid);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Toggle credential enabled/disabled
|
||||
const toggleCredential = async (uuid: string, isDisabled: boolean) => {
|
||||
await providerPoolApi.toggleCredential(uuid, isDisabled);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Reset credential counters
|
||||
const resetCredential = async (uuid: string) => {
|
||||
await providerPoolApi.resetCredential(uuid);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Reset health for all credentials of a type
|
||||
const resetHealth = async (providerType: PoolProviderType) => {
|
||||
await providerPoolApi.resetHealth(providerType);
|
||||
await fetchOverview();
|
||||
};
|
||||
|
||||
// Check health of a single credential
|
||||
const checkCredentialHealth = async (
|
||||
uuid: string,
|
||||
): Promise<HealthCheckResult> => {
|
||||
setCheckingHealth(uuid);
|
||||
try {
|
||||
const result = await providerPoolApi.checkCredentialHealth(uuid);
|
||||
await fetchOverview();
|
||||
return result;
|
||||
} finally {
|
||||
setCheckingHealth(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Check health of all credentials of a type
|
||||
const checkTypeHealth = async (
|
||||
providerType: PoolProviderType,
|
||||
): Promise<HealthCheckResult[]> => {
|
||||
setCheckingHealth(providerType);
|
||||
try {
|
||||
const results = await providerPoolApi.checkTypeHealth(providerType);
|
||||
await fetchOverview();
|
||||
return results;
|
||||
} finally {
|
||||
setCheckingHealth(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh OAuth token for a credential
|
||||
const refreshCredentialToken = async (uuid: string): Promise<string> => {
|
||||
setRefreshingToken(uuid);
|
||||
try {
|
||||
const result = await providerPoolApi.refreshCredentialToken(uuid);
|
||||
await fetchOverview();
|
||||
return result;
|
||||
} finally {
|
||||
setRefreshingToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Get OAuth status for a credential
|
||||
const getCredentialOAuthStatus = async (
|
||||
uuid: string,
|
||||
): Promise<OAuthStatus> => {
|
||||
return providerPoolApi.getCredentialOAuthStatus(uuid);
|
||||
};
|
||||
|
||||
// Get credentials for a specific provider type
|
||||
const getCredentialsByType = (
|
||||
providerType: PoolProviderType,
|
||||
): CredentialDisplay[] => {
|
||||
const pool = overview.find((p) => p.provider_type === providerType);
|
||||
return pool?.credentials || [];
|
||||
};
|
||||
|
||||
// Get stats for a specific provider type
|
||||
const getStatsByType = (providerType: PoolProviderType) => {
|
||||
const pool = overview.find((p) => p.provider_type === providerType);
|
||||
return pool?.stats;
|
||||
};
|
||||
|
||||
return {
|
||||
overview,
|
||||
loading,
|
||||
error,
|
||||
checkingHealth,
|
||||
refreshingToken,
|
||||
refresh: fetchOverview,
|
||||
addKiroOAuth,
|
||||
addGeminiOAuth,
|
||||
addQwenOAuth,
|
||||
addOpenAIKey,
|
||||
addClaudeKey,
|
||||
updateCredential,
|
||||
deleteCredential,
|
||||
toggleCredential,
|
||||
resetCredential,
|
||||
resetHealth,
|
||||
checkCredentialHealth,
|
||||
checkTypeHealth,
|
||||
refreshCredentialToken,
|
||||
getCredentialOAuthStatus,
|
||||
getCredentialsByType,
|
||||
getStatsByType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
// Provider types supported by the pool
|
||||
export type PoolProviderType = "kiro" | "gemini" | "qwen" | "openai" | "claude";
|
||||
|
||||
// Credential data types
|
||||
export interface KiroOAuthCredential {
|
||||
type: "kiro_oauth";
|
||||
creds_file_path: string;
|
||||
}
|
||||
|
||||
export interface GeminiOAuthCredential {
|
||||
type: "gemini_oauth";
|
||||
creds_file_path: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
export interface QwenOAuthCredential {
|
||||
type: "qwen_oauth";
|
||||
creds_file_path: string;
|
||||
}
|
||||
|
||||
export interface OpenAIKeyCredential {
|
||||
type: "openai_key";
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeKeyCredential {
|
||||
type: "claude_key";
|
||||
api_key: string;
|
||||
base_url?: string;
|
||||
}
|
||||
|
||||
export type CredentialData =
|
||||
| KiroOAuthCredential
|
||||
| GeminiOAuthCredential
|
||||
| QwenOAuthCredential
|
||||
| OpenAIKeyCredential
|
||||
| ClaudeKeyCredential;
|
||||
|
||||
// Provider credential
|
||||
export interface ProviderCredential {
|
||||
uuid: string;
|
||||
provider_type: PoolProviderType;
|
||||
credential: CredentialData;
|
||||
name?: string;
|
||||
is_healthy: boolean;
|
||||
is_disabled: boolean;
|
||||
check_health: boolean;
|
||||
check_model_name?: string;
|
||||
not_supported_models: string[];
|
||||
usage_count: number;
|
||||
error_count: number;
|
||||
last_used?: string;
|
||||
last_error_time?: string;
|
||||
last_error_message?: string;
|
||||
last_health_check_time?: string;
|
||||
last_health_check_model?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Credential display (for UI, hides sensitive data)
|
||||
export interface CredentialDisplay {
|
||||
uuid: string;
|
||||
provider_type: PoolProviderType;
|
||||
credential_type: string;
|
||||
name?: string;
|
||||
display_credential: string;
|
||||
is_healthy: boolean;
|
||||
is_disabled: boolean;
|
||||
check_health: boolean;
|
||||
check_model_name?: string;
|
||||
not_supported_models: string[];
|
||||
usage_count: number;
|
||||
error_count: number;
|
||||
last_used?: string;
|
||||
last_error_time?: string;
|
||||
last_error_message?: string;
|
||||
last_health_check_time?: string;
|
||||
last_health_check_model?: string;
|
||||
oauth_status?: OAuthStatus;
|
||||
token_cache_status?: TokenCacheStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Pool statistics
|
||||
export interface PoolStats {
|
||||
total: number;
|
||||
healthy: number;
|
||||
unhealthy: number;
|
||||
disabled: number;
|
||||
total_usage: number;
|
||||
total_errors: number;
|
||||
}
|
||||
|
||||
// Provider pool overview
|
||||
export interface ProviderPoolOverview {
|
||||
provider_type: string;
|
||||
stats: PoolStats;
|
||||
credentials: CredentialDisplay[];
|
||||
}
|
||||
|
||||
// Health check result
|
||||
export interface HealthCheckResult {
|
||||
uuid: string;
|
||||
success: boolean;
|
||||
model?: string;
|
||||
message?: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
// OAuth status
|
||||
export interface OAuthStatus {
|
||||
has_access_token: boolean;
|
||||
has_refresh_token: boolean;
|
||||
is_token_valid: boolean;
|
||||
expiry_info?: string;
|
||||
creds_path: string;
|
||||
}
|
||||
|
||||
// Token cache status (from database cache)
|
||||
export interface TokenCacheStatus {
|
||||
has_cached_token: boolean;
|
||||
is_valid: boolean;
|
||||
is_expiring_soon: boolean;
|
||||
expiry_time?: string;
|
||||
last_refresh?: string;
|
||||
refresh_error_count: number;
|
||||
last_refresh_error?: string;
|
||||
}
|
||||
|
||||
// Request types
|
||||
export interface AddCredentialRequest {
|
||||
provider_type: string;
|
||||
credential: CredentialData;
|
||||
name?: string;
|
||||
check_health?: boolean;
|
||||
check_model_name?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCredentialRequest {
|
||||
name?: string;
|
||||
is_disabled?: boolean;
|
||||
check_health?: boolean;
|
||||
check_model_name?: string;
|
||||
not_supported_models?: string[];
|
||||
/// 新的凭证文件路径(仅适用于OAuth凭证,用于重新上传文件)
|
||||
new_creds_file_path?: string;
|
||||
/// OAuth相关:新的project_id(仅适用于Gemini)
|
||||
new_project_id?: string;
|
||||
}
|
||||
|
||||
export const providerPoolApi = {
|
||||
// Get overview of all provider pools
|
||||
async getOverview(): Promise<ProviderPoolOverview[]> {
|
||||
return invoke("get_provider_pool_overview");
|
||||
},
|
||||
|
||||
// Get credentials for a specific provider type
|
||||
async getCredentials(
|
||||
providerType: PoolProviderType,
|
||||
): Promise<CredentialDisplay[]> {
|
||||
return invoke("get_provider_pool_credentials", { providerType });
|
||||
},
|
||||
|
||||
// Add a generic credential
|
||||
async addCredential(
|
||||
request: AddCredentialRequest,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_provider_pool_credential", { request });
|
||||
},
|
||||
|
||||
// Update a credential
|
||||
async updateCredential(
|
||||
uuid: string,
|
||||
request: UpdateCredentialRequest,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("update_provider_pool_credential", { uuid, request });
|
||||
},
|
||||
|
||||
// Delete a credential
|
||||
async deleteCredential(uuid: string): Promise<boolean> {
|
||||
return invoke("delete_provider_pool_credential", { uuid });
|
||||
},
|
||||
|
||||
// Toggle credential enabled/disabled
|
||||
async toggleCredential(
|
||||
uuid: string,
|
||||
isDisabled: boolean,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("toggle_provider_pool_credential", { uuid, isDisabled });
|
||||
},
|
||||
|
||||
// Reset credential counters
|
||||
async resetCredential(uuid: string): Promise<void> {
|
||||
return invoke("reset_provider_pool_credential", { uuid });
|
||||
},
|
||||
|
||||
// Reset health status for all credentials of a type
|
||||
async resetHealth(providerType: PoolProviderType): Promise<number> {
|
||||
return invoke("reset_provider_pool_health", { providerType });
|
||||
},
|
||||
|
||||
// Check health of a single credential
|
||||
async checkCredentialHealth(uuid: string): Promise<HealthCheckResult> {
|
||||
return invoke("check_provider_pool_credential_health", { uuid });
|
||||
},
|
||||
|
||||
// Check health of all credentials of a type
|
||||
async checkTypeHealth(
|
||||
providerType: PoolProviderType,
|
||||
): Promise<HealthCheckResult[]> {
|
||||
return invoke("check_provider_pool_type_health", { providerType });
|
||||
},
|
||||
|
||||
// Provider-specific add methods
|
||||
async addKiroOAuth(
|
||||
credsFilePath: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_kiro_oauth_credential", { credsFilePath, name });
|
||||
},
|
||||
|
||||
async addGeminiOAuth(
|
||||
credsFilePath: string,
|
||||
projectId?: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_gemini_oauth_credential", {
|
||||
credsFilePath,
|
||||
projectId,
|
||||
name,
|
||||
});
|
||||
},
|
||||
|
||||
async addQwenOAuth(
|
||||
credsFilePath: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_qwen_oauth_credential", { credsFilePath, name });
|
||||
},
|
||||
|
||||
async addOpenAIKey(
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_openai_key_credential", { apiKey, baseUrl, name });
|
||||
},
|
||||
|
||||
async addClaudeKey(
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("add_claude_key_credential", { apiKey, baseUrl, name });
|
||||
},
|
||||
|
||||
// OAuth token management
|
||||
async refreshCredentialToken(uuid: string): Promise<string> {
|
||||
return invoke("refresh_pool_credential_token", { uuid });
|
||||
},
|
||||
|
||||
async getCredentialOAuthStatus(uuid: string): Promise<OAuthStatus> {
|
||||
return invoke("get_pool_credential_oauth_status", { uuid });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface RouteEndpoint {
|
||||
path: string;
|
||||
protocol: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RouteInfo {
|
||||
selector: string;
|
||||
provider_type: string;
|
||||
credential_count: number;
|
||||
endpoints: RouteEndpoint[];
|
||||
tags: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RouteListResponse {
|
||||
base_url: string;
|
||||
default_provider: string;
|
||||
routes: RouteInfo[];
|
||||
}
|
||||
|
||||
export interface CurlExample {
|
||||
description: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const routesApi = {
|
||||
async getAvailableRoutes(): Promise<RouteListResponse> {
|
||||
return invoke("get_available_routes");
|
||||
},
|
||||
|
||||
async getCurlExamples(selector: string): Promise<CurlExample[]> {
|
||||
return invoke("get_route_curl_examples", { selector });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user