21 Commits

Author SHA1 Message Date
Sakurasan 4a68ff6162 add claude proxy 2025-07-30 11:18:47 +08:00
Sakurasan 6b2d78fe56 fix:maxTokens 2025-05-04 02:52:04 +08:00
Sakurasan 9c604460b1 fix empty models 2025-04-22 02:52:56 +08:00
Sakurasan 8d34f8d6fe up 2025-04-22 02:16:28 +08:00
Sakurasan 6d1d0f3b6b up 2025-04-22 02:08:32 +08:00
Sakurasan 24529189d9 fix daily usage 2025-04-22 01:50:50 +08:00
Sakurasan 000162b1b1 fix usage 2025-04-22 01:06:03 +08:00
Sakurasan 6662ea5e04 fix record usage 2025-04-22 00:48:24 +08:00
Sakurasan 5789d50e9e update record usage 2025-04-21 23:59:30 +08:00
Sakurasan ca3d89751d fix stream usage 2025-04-21 22:48:28 +08:00
Sakurasan 2bc857cf88 add log 2025-04-21 21:59:13 +08:00
Sakurasan a9ff7e1c94 add log 2025-04-21 21:50:29 +08:00
Sakurasan 51d4651c6c up 2025-04-21 20:19:48 +08:00
Sakurasan e112f3af12 collect usage 2025-04-21 19:10:27 +08:00
Sakurasan 73e53c2333 add models task 2025-04-21 01:40:06 +08:00
Sakurasan 470e49b850 support fetch models 2025-04-21 01:30:17 +08:00
Sakurasan d426781e47 UP 2025-04-20 19:21:51 +08:00
Sakurasan b80f0759a5 fix select key 2025-04-20 18:33:59 +08:00
Sakurasan b83c6d9786 fix active key,suffix 2025-04-20 16:22:23 +08:00
Sakurasan fe0f2a7e88 key icon 2025-04-20 02:59:08 +08:00
Sakurasan ed42f3ded7 fix UI & token copy 2025-04-20 02:03:13 +08:00
50 changed files with 845 additions and 305 deletions
+8
View File
@@ -0,0 +1,8 @@
version: '3.9'
services:
adminer:
image: adminer
restart: always
ports:
- 8080:8080
+2 -6
View File
@@ -4,6 +4,7 @@ services:
mariadb:
image: mariadb
container_name: mysql
restart: unless-stopped
ports:
- "3306:3306"
volumes:
@@ -17,9 +18,4 @@ services:
MYSQL_DATABASE: openteam
MYSQL_USER: openteam
MYSQL_PASSWORD: openteam
# adminer:
# image: adminer
# restart: always
# ports:
# - 8080:8080
-5
View File
@@ -20,8 +20,3 @@ services:
volumes:
- $PWD/pgdata:/var/lib/postgresql/data
# adminer:
# image: adminer
# restart: always
# ports:
# - 8080:8080
+10
View File
@@ -0,0 +1,10 @@
version: '3.7'
services:
sqlite-web:
image: vaalacat/sqlite-web
ports:
- 8800:8080
volumes:
- $PWD/db:/data
environment:
- SQLITE_DATABASE=openteam.db
+1
View File
@@ -14,6 +14,7 @@
"axios": "^1.8.4",
"element-plus": "^2.9.7",
"lucide-vue-next": "^0.479.0",
"qrcode.vue": "^3.6.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
+169
View File
@@ -23,6 +23,9 @@ importers:
lucide-vue-next:
specifier: ^0.479.0
version: 0.479.0(vue@3.5.13)
qrcode.vue:
specifier: ^3.6.0
version: 3.6.0(vue@3.5.13)
vue:
specifier: ^3.5.13
version: 3.5.13
@@ -556,6 +559,10 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001714:
resolution: {integrity: sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==}
@@ -563,6 +570,9 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -604,6 +614,10 @@ packages:
dayjs@1.11.13:
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@@ -611,6 +625,9 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@@ -692,6 +709,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
@@ -720,6 +741,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -797,6 +822,10 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
@@ -882,9 +911,25 @@ packages:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -924,6 +969,10 @@ packages:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -968,6 +1017,16 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
qrcode.vue@3.6.0:
resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
peerDependencies:
vue: ^3.0.0
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -978,6 +1037,13 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve@1.22.10:
resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
engines: {node: '>= 0.4'}
@@ -995,6 +1061,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -1132,11 +1201,18 @@ packages:
typescript:
optional: true
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -1145,11 +1221,22 @@ packages:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yaml@2.7.1:
resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==}
engines: {node: '>= 14'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
snapshots:
'@alloc/quick-lru@5.2.0': {}
@@ -1540,6 +1627,8 @@ snapshots:
camelcase-css@2.0.1: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001714: {}
chokidar@3.6.0:
@@ -1554,6 +1643,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -1594,10 +1689,14 @@ snapshots:
dayjs@1.11.13: {}
decamelize@1.2.0: {}
delayed-stream@1.0.0: {}
didyoumean@1.2.2: {}
dijkstrajs@1.0.3: {}
dlv@1.1.3: {}
dunder-proto@1.0.1:
@@ -1708,6 +1807,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
follow-redirects@1.15.9: {}
foreground-child@3.3.1:
@@ -1729,6 +1833,8 @@ snapshots:
function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -1808,6 +1914,10 @@ snapshots:
lines-and-columns@1.2.4: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash-es@4.17.21: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
@@ -1871,8 +1981,20 @@ snapshots:
object-hash@3.0.0: {}
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
@@ -1900,6 +2022,8 @@ snapshots:
pirates@4.0.7: {}
pngjs@5.0.0: {}
postcss-import@15.1.0(postcss@8.5.3):
dependencies:
postcss: 8.5.3
@@ -1939,6 +2063,16 @@ snapshots:
proxy-from-env@1.1.0: {}
qrcode.vue@3.6.0(vue@3.5.13):
dependencies:
vue: 3.5.13
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
read-cache@1.0.0:
@@ -1949,6 +2083,10 @@ snapshots:
dependencies:
picomatch: 2.3.1
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve@1.22.10:
dependencies:
is-core-module: 2.16.1
@@ -1987,6 +2125,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
set-blocking@2.0.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -2113,10 +2253,18 @@ snapshots:
'@vue/server-renderer': 3.5.13(vue@3.5.13)
'@vue/shared': 3.5.13
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -2129,4 +2277,25 @@ snapshots:
string-width: 5.1.2
strip-ansi: 7.1.0
y18n@4.0.3: {}
yaml@2.7.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 581 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="56" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>

After

Width:  |  Height:  |  Size: 913 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+119
View File
@@ -0,0 +1,119 @@
<template>
<div class="flex flex-col items-center space-y-4 p-6 bg-base-100 rounded-xl shadow-lg mt-2 max-w-sm mx-auto backdrop-blur-xl glass">
<div class="p-3 bg-white rounded-lg shadow-inner cursor-pointer" @click="toggleQRCode">
<qrcode-vue :value="currentValue" :size="size" level="H" />
</div>
<div class="relative w-full p-4 ">
<p
class="text-sm break-all whitespace-pre-wrap m-1 p-1 pr-5 text-base-content border border-dashed border-base-content rounded-lg">
{{ currentValue }}
</p>
<button @click="copyValue" class="absolute top-2 right-2 btn btn-xs btn-ghost bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2M16 8h2a2 2 0 012 2v8a2 2 0 01-2-2h-8a2 2 0 01-2-2v-2" />
</svg>
</button>
<div v-if="showCopied"
class="absolute -top-5 -right-2 bg-neutral text-neutral-content px-2 py-1 rounded text-xs opacity-100 transition-opacity duration-300">
Copied
</div>
</div>
<div class="grid grid-cols-2 gap-2 w-full">
<button v-for="app in applist" :key="app.name"
class="btn btn-sm btn-outline btn-ghost flex items-center justify-center"
@click="applyPrefix(app.name)">
<img :src="app.url" alt="" class="w-5 h-5 mr-1">
<span>{{ app.name }}</span>
</button>
</div>
<div class="w-full">
<button class="btn btn-outline btn-sm w-full" @click="resetValue">
Reset
</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue';
import QrcodeVue from 'qrcode.vue';
// 定义组件接收的 props
const props = defineProps({
value: { // 二维码的原始值
type: String,
required: true
},
size: { // 二维码的尺寸 (像素)
type: Number,
default: 160 // 默认大小
}
});
// 使用 ref 创建一个响应式变量,用于存储当前显示的二维码值
// 初始值是来自 props 的 value
const currentValue = ref(props.value);
// 监听 props.value 的变化,如果外部传入的 value 改变,更新 currentValue
watch(() => props.value, (newValue) => {
currentValue.value = newValue;
});
const showCopied = ref(false);
const copyValue = async () => {
try {
await navigator.clipboard.writeText(currentValue.value);
showCopied.value = true;
setTimeout(() => {
showCopied.value = false;
}, 1500); // 1.5 秒后恢复
} catch (err) {
console.error('Failed to copy: ', err);
// 可以在这里添加错误提示
}
};
const applist = reactive([
// { name: 'openteam', url: '/assets/logo.svg' },
{ name: 'botgem', url: 'https://botgem.com/favicon.ico' },
{ name: 'opencat', url: 'https://opencat.app/favicon.ico' },
])
const applyPrefix = (name) => {
let origin = window.location.origin;
switch (name) {
case 'botgem':
currentValue.value = `ama://set-api-key?server=${origin}&key=${props.value}`;
break;
case 'opencat':
currentValue.value = `opencat://team/join?domain=${origin}&token=${props.value}`;
break;
default:
currentValue.value = name + props.value;
break;
}
};
const toggleQRCode = () => {
if (currentValue.value.startsWith('sk-')) {
return
} else {
window.open(currentValue.value, '_blank')
}
}
// 清除前缀,恢复到原始值
const resetValue = () => {
currentValue.value = props.value;
};
</script>
+1 -1
View File
@@ -11,7 +11,7 @@ if (import.meta.env.DEV) { // Vite 的方式判断开发环境
const service = axios.create({
baseURL: baseURL,
timeout: 5000,
timeout: 6000,
headers: {
'Content-Type': 'application/json',
},
+36 -38
View File
@@ -12,17 +12,6 @@
</p>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>Error! {{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
<div class="card border border-base-300/40 shadow-sm">
<form @submit.prevent="createApiKey" class="card-body space-y-5 p-3 sm:p-8">
<div class="space-y-4">
@@ -60,24 +49,7 @@
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="password-visibility-toggle">
<template v-if="newApiKey.type === 'openai'">
<img src="../../assets/openai.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="newApiKey.type === 'claude'">
<img src="../../assets/claude.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="newApiKey.type === 'gemini'">
<img src="../../assets/gemini.svg" class="w-5 h-5" alt="">
</template>
<template v-else="newApiKey.type ==='azure'">
<img src="../../assets/azure.svg" class="w-5 h-5" alt="">
</template>
<template v-else="newApiKey.type ==='github'">
<img src="../../assets/github.svg" class="w-5 h-5" alt="">
</template>
<template v-else="newApiKey.type">
<img src="../../assets/logo.svg" class="w-5 h-5" alt="">
</template>
<img :src="apiKeyImageUrl(newApiKey.type)" class="w-5 h-5" alt="">
</button>
</div>
</div>
@@ -117,13 +89,13 @@
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<!-- <div class="form-control">
<label for="deployment_name" class="label">
<span class="label-text">Deployment Name</span>
</label>
<input id="deployment_name" type="text" v-model="newApiKey.deployment_name"
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
</div>
</div> -->
<div class="form-control">
<label for="api_secret" class="label">
@@ -164,7 +136,7 @@
<!-- <textarea id="support_models" v-model="newApiKey.support_models_text"
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
<el-input-tag v-model="newApiKey.support_models_array" :trigger="'Enter'" clearable
placeholder="Please input" @change="onchange_supportmodel"/>
placeholder="Please input" @change="onchange_supportmodel" />
</div>
<div class="form-control">
@@ -182,10 +154,23 @@
</div>
</div>
</div>
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<span>{{ error }}</span>
</div>
<button class="btn btn-sm" @click="error = null">X</button>
</div>
<div class="flex justify-end pt-4 items-center gap-2">
<button @click="cancel" class="btn btn-sm btn-outline">Cancel</button>
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success" :disabled="!isFormValid">
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success"
:disabled="!isFormValid">
Create
</button>
</div>
@@ -217,12 +202,12 @@ const newApiKey = ref({
active: true,
endpoint: '',
resource_name: '',
deployment_name: '',
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '',
support_models: '[]',
support_models_array: [],
})
@@ -234,12 +219,12 @@ const resetNewApiKey = () => {
active: true,
endpoint: '',
resource_name: '',
deployment_name: '',
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '',
support_models: '[]',
support_models_array: [],
}
}
@@ -260,12 +245,25 @@ const cancel = () => {
emit('closeModal', true)
}
const apiKeyImageMap = {
'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg',
'azure': '/assets/azure.svg',
'github': '/assets/github.svg'
};
const apiKeyImageUrl = (keytype) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg';
};
const createApiKey = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
return
}
try {
try {
if (!Array.isArray(newApiKey.value.support_models_array)) {
+27 -15
View File
@@ -33,22 +33,13 @@
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="azure">Azure</option>
<option value="github">Github</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
<button type="button" class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="password-visibility-toggle">
<template v-if="key.type === 'openai'">
<img src="../../assets/openai.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="key.type === 'claude'">
<img src="../../assets/claude.svg" class="w-5 h-5" alt="">
</template>
<template v-else-if="key.type === 'gemini'">
<img src="../../assets/gemini.svg" class="w-5 h-5" alt="">
</template>
<template v-else="key.type">
<img src="../../assets/logo.svg" class="w-5 h-5" alt="">
</template>
<img :src="apiKeyImageUrl(key.type)" class="w-5 h-5" alt="">
</button>
</div>
</div>
@@ -88,13 +79,13 @@
class="input input-sm input-bordered w-full" />
</div>
<div class="form-control">
<!-- <div class="form-control">
<label for="deployment_name" class="label">
<span class="label-text">Deployment Name</span>
</label>
<input id="deployment_name" type="text" v-model="key.deployment_name"
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
</div>
</div> -->
<div class="form-control">
<label for="api_secret" class="label">
@@ -167,7 +158,7 @@
</template>
<script setup>
import { ref, computed, onMounted, inject } from 'vue';
import { ref, computed, onMounted, inject, reactive } from 'vue';
import { useRoute,useRouter } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next';
import { useKeyStore } from '../../stores/key';
@@ -194,6 +185,27 @@ onMounted(async () => {
}
});
const keyOption = reactive([
{name: 'openai', label: 'OpenAI'},
{name: 'claude', label: 'Claude'},
{name: 'gemini', label: 'Gemini'},
{name: 'azure', label: 'Azure'},
{name: 'github', label: 'Github'},
{name: 'openai-compatible', label: 'OpenAI Compatible'}
])
const apiKeyImageMap = {
'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg',
'azure': '/assets/azure.svg',
'github': '/assets/github.svg'
};
const apiKeyImageUrl = (keytype) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg';
};
const onchange_supportmodel = () => {
key.value.support_models = JSON.stringify(key.value.support_models_array)
}
+52 -19
View File
@@ -2,12 +2,12 @@
<div class="min-h-screen bg-base-100 p-4">
<!-- Breadcrumb and Title -->
<BreadcrumbHeader />
<!-- <div v-if="keyStore.loading" class="loading loading-spinner loading-lg"></div> -->
<div class="flex flex-wrap gap-2 mb-4" v-if="keys">
<div class="flex flex-1 items-center space-x-2">
<input
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[150px] lg:w-[250px]"
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
placeholder="Filter" value="">
<div class="dropdown">
@@ -33,7 +33,8 @@
</div>
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<div class="flex flex-1 items-center justify-end space-x-2">
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
@@ -48,6 +49,7 @@
<button>关闭</button>
</form>
</dialog>
</div>
<div class="dropdown dropdown-end dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
@@ -85,13 +87,15 @@
<thead>
<tr>
<th>
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
<div class="flex gap-1 items-center">
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
</div>
</th>
<th>Type</th>
<th>Name</th>
<th>Active</th>
<th>Key</th>
<th>Endpoint</th>
<!-- <th>Key</th> -->
<!-- <th>Endpoint</th> -->
<th></th>
</tr>
</thead>
@@ -100,27 +104,38 @@
<tr v-for="key in keys" :key="key.id"
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
<td>
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
@change="toggleUserSelection(key)" />
<div class="flex gap-1 items-center">
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
@change="toggleUserSelection(key)" />
</div>
</td>
<td class="text-xs dark:text-white">
<div class="flex gap-1 items-center">
<span class="backdrop-blur-lg glass rounded-full border-none"> <img :src="displayIcon(key.type)"
class="w-5 h-5" alt=""></span>
{{ key.type }}
</div>
</td>
<td class="text-xs dark:text-white">{{ key.type }}</td>
<td class="text-xs dark:text-white">{{ key.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
v-model="key.active" @change="updateStatus(key)" />
</td>
<td class="text-xs dark:text-white">{{ key.apikey }}</td>
<td class="text-xs dark:text-white">{{ key.endpoint }}</td>
<td>
<div class="flex gap-1">
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
v-model="key.active" @change="updateStatus(key)" />
</div>
</td>
<!-- <td class="text-xs dark:text-white">{{ key.apikey }}</td> -->
<!-- <td class="text-xs dark:text-white">{{ key.endpoint }}</td> -->
<td>
<div class="flex gap-2">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square " @click="viewKey(key)">
<EyeIcon class="w-3.5 h-3.5 dark:text-white" />
<EyeIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除">
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30" @click="confirmDeleteKey(key)">
<TrashIcon class="w-3.5 h-3.5 dark:text-white" />
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
@click="confirmDeleteKey(key)">
<TrashIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
</div>
@@ -133,7 +148,7 @@
<!-- Pagination -->
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[ 10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
</div>
</template>
@@ -305,6 +320,24 @@ const deleteKey = async (key) => {
}
};
const displayIcon = (apitype) => {
switch (apitype) {
case 'openai':
return '/assets/openai.svg';
case 'claude':
return '/assets/claude.svg';
case 'gemini':
return '/assets/gemini.svg'
case 'azure':
return '/assets/azure.svg';
case 'github':
return '/assets/github.svg';
default:
return '/assets/logo.svg';
}
}
// 关闭模态框
const modalRef = ref(null);
const closeModal = async () => {
+1 -1
View File
@@ -65,7 +65,7 @@
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="token-visibility-toggle">
<template v-if="!isPasswordVisible">
<template v-if="!isTokenVisible">
<EyeOff class="w-5 h-5" />
</template>
<template v-else>
+89 -50
View File
@@ -24,69 +24,90 @@
</div>
<!-- Table -->
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
<div class="card card-bordered bg-base-100 shadow-sm mt-6">
<div class="card-body">
<h3 class="card-title text-lg">Tokens</h3>
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto -mx-6">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
<th class="px-2 py-3">Token Name</th>
<th class="px-2 py-3">Status</th>
<th class="px-2 py-3">Key</th>
<th class="px-2 py-3">Expired At</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used Quota</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="hover">
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs"
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
@change="updateStatus(token)" />
</td>
<td class="font-mono text-xs px-2 py-3">{{ token.key }}</td>
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
<td class="px-2 py-3">
<template v-if="token.unlimited_quota">
<Infinity />
</template>
<template v-else>{{ token.quota }}</template>
</td>
<td class="px-2 py-3">{{ token.used_quota }}</td>
<td class="text-right px-2 py-3 flex justify-between items-center gap-1">
<div class="md:tooltip" data-tip="clean usedquota">
<button class="btn btn-ghost btn-xs btn-square text-sky-300" @click="cleanUsedToken(token)"
aria-label="Revoke token">
<Eraser class="w-4 h-4" />
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200" v-if="user">
<table class="table table-sm w-full">
<thead>
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
<th class="px-2 py-3">Token</th>
<th class="px-2 py-3">Status</th>
<!-- <th class="px-2 py-3">Key</th> -->
<th class="px-2 py-3">Expired</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="hover">
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs"
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
@change="updateStatus(token)" />
</td>
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
<td class="px-2 py-3">
<template v-if="token.unlimited_quota">
<Infinity />
</template>
<template v-else>{{ token.quota }}</template>
</td>
<td class="px-2 py-3">{{ token.used_quota }}</td>
<td class="text-right px-1 py-3">
<div class="flex items-center gap-2.5">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open pt-1" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square" onclick="" @click="viewToken(token)">
<EyeIcon class="w-5 h-5 dark:text-white" />
</button>
</div>
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRevokeToken(token)" aria-label="Revoke token">
<TrashIcon class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="md:tooltip" data-tip="clean used">
<button class="btn btn-ghost btn-xs btn-square text-sky-300 mt-1" @click="cleanUsedToken(token)"
aria-label="Revoke token">
<Eraser class="w-5 h-5" />
</button>
</div>
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
<button v-if="token.name !== 'default'"
class="btn btn-ghost btn-xs btn-square text-error items-center" @click="confirmRevokeToken(token)"
aria-label="Revoke token">
<TrashIcon class="w-5 h-5" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
<dialog id="myToken" class="modal" ref="tokenRef">
<div class="modal-box px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<QRCodeCard :value="qrCodeValue" :size="120" />
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
</div>
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
</div>
<!-- </div> -->
</div>
</template>
<script setup>
import { ref, onMounted, inject, computed,watch } from 'vue';
import { ref, onMounted, inject, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import QRCodeCard from '@/components/QRCodeCard.vue';
import TokenNew from '@/views/dashboard/TokenNew.vue';
import { useAuthStore } from '@/stores/auth';
import {
@@ -104,7 +125,7 @@ onMounted(async () => {
})
watch(() => authStore.user, async (newUser) => {
if (newUser.expired_at>0) {
if (newUser.expired_at > 0) {
newUser.format_expired_at = unixToDate(newUser.expired_at);
}
})
@@ -160,6 +181,24 @@ const cleanUsedToken = async (token) => {
}
}
const showTokenModel = ref(false);
const tokenRef = ref(null);
const viewToken = (token) => {
const dialog = tokenRef.value;
if (dialog) {
if (!dialog.hasAttribute('open')) {
qrCodeValue.value = token.key;
dialog.showModal();
} else {
if (dialog.hasAttribute('open')) {
dialog.close();
}
}
}
showTokenModel.value = !showTokenModel.value
}
const qrCodeValue = ref('');
// 关闭模态框
+27 -23
View File
@@ -8,7 +8,7 @@
<div class="flex flex-wrap gap-2 mb-4">
<div class="flex flex-1 items-center space-x-2">
<input
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[150px] lg:w-[250px]"
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
placeholder="Filter" value="">
<div class="dropdown">
@@ -34,21 +34,23 @@
</div>
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<UserNew @closeModal="closeModal" />
<div class="flex flex-1 items-center justify-end space-x-1">
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
<PlusIcon class="w-3.5 h-3.5" />New
</button>
<dialog id="myModal" class="modal" ref="modalRef">
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<UserNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
</div>
<form method="dialog" class="modal-backdrop">
<button>关闭</button>
</form>
</dialog>
</div>
<div class="dropdown dropdown-end dropdown-hover">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
@@ -92,7 +94,7 @@
<th>Name</th>
<th>Active</th>
<th>Quota</th>
<th>UsedQuota</th>
<th>Used</th>
<th></th>
</tr>
</thead>
@@ -107,8 +109,10 @@
<td class="text-xs dark:text-white">{{ user.id }}</td>
<td class="text-xs dark:text-white">{{ user.username }}</td>
<td>
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" />
<div class="flex items-center gap-1">
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" />
</div>
</td>
<td class="text-xs dark:text-white">
<template v-if="user.unlimited_quota">
@@ -121,13 +125,13 @@
<div class="flex gap-1">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
<button class="btn btn-ghost btn-xs btn-square " @click="viewUser(user)">
<EyeIcon class="w-3.5 h-3.5 dark:text-white" />
<EyeIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role<20">
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role < 20">
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
@click="confirmDeleteUser(user)">
<TrashIcon class="w-3.5 h-3.5 dark:text-white" />
<TrashIcon class="w-4 h-4 dark:text-white" />
</button>
</div>
</div>
@@ -168,7 +172,7 @@ const pageSize = ref(10);
const totalItems = computed(() => userStore.totalUsers);
// 封装公共的用户列表获取方法
const listUsers = async (size = pageSize.value, page = currentPage.value, active=selectedStatuses.map(status => status.value)) => {
const listUsers = async (size = pageSize.value, page = currentPage.value, active = selectedStatuses.map(status => status.value)) => {
currentPage.value = page || currentPage.value;
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
await userStore.listUser(size, page, active);
@@ -231,7 +235,7 @@ const toggleStatusFilter = async (status) => {
selectedStatuses.push({ status, value: statusValue });
}
await listUsers(undefined,1,undefined);
await listUsers(undefined, 1, undefined);
};
// 处理批量操作
+1 -1
View File
@@ -180,7 +180,7 @@
<th class="px-2 py-3">Key</th>
<th class="px-2 py-3">Expired At</th>
<th class="px-2 py-3">Quota</th>
<th class="px-2 py-3">Used Quota</th>
<th class="px-2 py-3">Used</th>
<th class="text-right px-2 py-3"></th>
</tr>
</thead>
+1 -1
View File
@@ -28,7 +28,7 @@ func (a Api) CreateApiKey(c *gin.Context) {
}
if slice.Contain([]string{"openai", "azure", "claude"}, *newkey.ApiType) {
sma, err := utils.FetchKeyModel(a.db, newkey)
if err == nil {
if err == nil && len(sma) > 0 {
newkey.SupportModelsArray = sma
var buf = new(bytes.Buffer)
json.NewEncoder(buf).Encode(sma) //nolint:errcheck
+21 -4
View File
@@ -4,15 +4,23 @@ import (
"fmt"
"net/http"
"opencatd-open/internal/dto"
"opencatd-open/internal/model"
"opencatd-open/llm"
"opencatd-open/llm/claude/v2"
"opencatd-open/llm/google/v2"
"opencatd-open/llm/openai_compatible"
"opencatd-open/pkg/tokenizer"
"github.com/gin-gonic/gin"
)
func (h *Proxy) ChatHandler(c *gin.Context) {
user := c.MustGet("user").(*model.User)
if user == nil {
dto.WrapErrorAsOpenAI(c, 401, "Unauthorized")
return
}
var chatreq llm.ChatRequest
if err := c.ShouldBindJSON(&chatreq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -35,10 +43,10 @@ func (h *Proxy) ChatHandler(c *gin.Context) {
fallthrough
default:
llm, err = openai_compatible.NewOpenAICompatible(h.apikey)
if err != nil {
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
return
}
}
if err != nil {
dto.WrapErrorAsOpenAI(c, 500, fmt.Errorf("create llm client error: %w", err).Error())
return
}
if !chatreq.Stream {
@@ -57,4 +65,13 @@ func (h *Proxy) ChatHandler(c *gin.Context) {
c.SSEvent("", data)
}
}
llmusage := llm.GetTokenUsage()
llmusage.User = user
llmusage.TokenID = c.GetInt64("token_id")
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens+llmusage.ToolsTokens, llmusage.CompletionTokens)
h.SendUsage(llmusage)
defer fmt.Println("cost:", cost, "prompt_tokens:", llmusage.PromptTokens, "completion_tokens:", llmusage.CompletionTokens, "total_tokens:", llmusage.TotalTokens)
}
+58
View File
@@ -0,0 +1,58 @@
package controller
import (
"encoding/json"
"fmt"
"net/http"
"opencatd-open/internal/dto"
"github.com/gin-gonic/gin"
)
func (p *Proxy) HandleModels(c *gin.Context) {
models, err := p.getModelCache()
if err != nil {
dto.Fail(c, http.StatusBadGateway, err.Error())
return
}
type _model struct {
ID string `json:"id"`
}
var ms []_model
for _, model := range models {
ms = append(ms, _model{ID: model})
}
dto.Success(c, ms)
}
func (p *Proxy) setModelCache() error {
apikeys, err := p.apiKeyDao.FindKeys(nil)
models := make(map[string]bool)
if err == nil && len(apikeys) > 0 {
for _, k := range apikeys {
if len(k.SupportModelsArray) > 0 {
for _, sm := range k.SupportModelsArray {
models[sm] = true
}
} else {
var sma []string
json.Unmarshal([]byte(*k.SupportModels), &sma) // nolint:errCheck
for _, sm := range sma {
models[sm] = true
}
}
}
} else {
return fmt.Errorf("empty data")
}
var support_models []string
for m, _ := range models {
support_models = append(support_models, m)
}
return p.cache.Set("models", support_models)
}
func (p *Proxy) getModelCache() ([]string, error) {
models, err := p.cache.Get("models")
return models.([]string), err
}
+100 -41
View File
@@ -12,17 +12,20 @@ import (
"net/url"
"opencatd-open/internal/dao"
"opencatd-open/internal/model"
"opencatd-open/internal/utils"
"opencatd-open/llm"
"opencatd-open/pkg/config"
"opencatd-open/pkg/tokenizer"
"os"
"strings"
"sync"
"time"
"github.com/bluele/gcache"
"github.com/gin-gonic/gin"
"github.com/lib/pq"
"github.com/tidwall/gjson"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Proxy struct {
@@ -30,9 +33,10 @@ type Proxy struct {
cfg *config.Config
db *gorm.DB
wg *sync.WaitGroup
usageChan chan *model.Usage // 用于异步处理的channel
usageChan chan *llm.TokenUsage // 用于异步处理的channel
apikey *model.ApiKey
httpClient *http.Client
cache gcache.Cache
userDAO *dao.UserDAO
apiKeyDao *dao.ApiKeyDAO
@@ -52,13 +56,15 @@ func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.Wai
client.Transport = tr
}
}
np := &Proxy{
ctx: ctx,
cfg: cfg,
db: db,
wg: wg,
httpClient: client,
usageChan: make(chan *model.Usage, cfg.UsageChanSize),
cache: gcache.New(1).Build(),
usageChan: make(chan *llm.TokenUsage, cfg.UsageChanSize),
userDAO: userDAO,
apiKeyDao: apiKeyDAO,
tokenDAO: tokenDAO,
@@ -68,7 +74,7 @@ func NewProxy(ctx context.Context, cfg *config.Config, db *gorm.DB, wg *sync.Wai
go np.ProcessUsage()
go np.ScheduleTask()
np.setModelCache()
return np
}
@@ -77,9 +83,13 @@ func (p *Proxy) HandleProxy(c *gin.Context) {
p.ChatHandler(c)
return
}
if strings.HasPrefix(c.Request.URL.Path, "/v1/messages") {
p.ProxyClaude(c)
return
}
}
func (p *Proxy) SendUsage(usage *model.Usage) {
func (p *Proxy) SendUsage(usage *llm.TokenUsage) {
select {
case p.usageChan <- usage:
default:
@@ -135,46 +145,90 @@ func (p *Proxy) ProcessUsage() {
}
}
func (p *Proxy) Do(usage *model.Usage) error {
func (p *Proxy) Do(llmusage *llm.TokenUsage) error {
err := p.db.Transaction(func(tx *gorm.DB) error {
now := time.Now()
today, _ := time.Parse("2006-01-02", now.Format("2006-01-02"))
cost := tokenizer.Cost(llmusage.Model, llmusage.PromptTokens, llmusage.CompletionTokens)
token, err := p.tokenDAO.GetByID(p.ctx, llmusage.TokenID)
if err != nil {
return err
}
usage := &model.Usage{
UserID: llmusage.User.ID,
TokenID: llmusage.TokenID,
Date: now,
Model: llmusage.Model,
Stream: llmusage.Stream,
PromptTokens: llmusage.PromptTokens,
CompletionTokens: llmusage.CompletionTokens,
TotalTokens: llmusage.TotalTokens,
Cost: fmt.Sprintf("%.8f", cost),
}
// 1. 记录使用记录
if err := tx.WithContext(p.ctx).Create(usage).Error; err != nil {
return fmt.Errorf("create usage error: %w", err)
}
// 2. 更新每日统计upsert 操作)
dailyUsage := model.DailyUsage{
UserID: usage.UserID,
TokenID: usage.TokenID,
Capability: usage.Capability,
Date: time.Date(usage.Date.Year(), usage.Date.Month(), usage.Date.Day(), 0, 0, 0, 0, usage.Date.Location()),
Model: usage.Model,
Stream: usage.Stream,
PromptTokens: usage.PromptTokens,
CompletionTokens: usage.CompletionTokens,
TotalTokens: usage.TotalTokens,
Cost: usage.Cost,
}
// 使用 OnConflict 实现 upsert
if err := tx.WithContext(p.ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "token_id"}, {Name: "capability"}, {Name: "date"}}, // 唯一键
DoUpdates: clause.Assignments(map[string]interface{}{
"prompt_tokens": gorm.Expr("prompt_tokens + ?", usage.PromptTokens),
"completion_tokens": gorm.Expr("completion_tokens + ?", usage.CompletionTokens),
"total_tokens": gorm.Expr("total_tokens + ?", usage.TotalTokens),
"cost": gorm.Expr("cost + ?", usage.Cost),
}),
}).Create(&dailyUsage).Error; err != nil {
return fmt.Errorf("upsert daily usage error: %w", err)
// 2. 更新每日统计
var dailyUsage model.DailyUsage
result := tx.WithContext(p.ctx).Where("user_id = ? and date = ?", llmusage.User.ID, today).First(&dailyUsage)
if result.RowsAffected == 0 {
dailyUsage.UserID = llmusage.User.ID
dailyUsage.TokenID = llmusage.TokenID
dailyUsage.Date = today
dailyUsage.Model = llmusage.Model
dailyUsage.Stream = llmusage.Stream
dailyUsage.PromptTokens = llmusage.PromptTokens
dailyUsage.CompletionTokens = llmusage.CompletionTokens
dailyUsage.TotalTokens = llmusage.TotalTokens
dailyUsage.Cost = fmt.Sprintf("%.8f", cost)
if err := tx.WithContext(p.ctx).Create(&dailyUsage).Error; err != nil {
return fmt.Errorf("create daily usage error: %w", err)
}
} else {
if err := tx.WithContext(p.ctx).Model(&model.DailyUsage{}).Where("user_id = ? and date = ?", llmusage.User.ID, today).
Updates(map[string]interface{}{
"prompt_tokens": gorm.Expr("prompt_tokens + ?", llmusage.PromptTokens),
"completion_tokens": gorm.Expr("completion_tokens + ?", llmusage.CompletionTokens),
"total_tokens": gorm.Expr("total_tokens + ?", llmusage.TotalTokens),
}).Error; err != nil {
return fmt.Errorf("update daily usage error: %w", err)
}
}
// 3. 更新用户额度
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", usage.UserID).Updates(map[string]interface{}{
"quota": gorm.Expr("quota - ?", usage.Cost),
"used_quota": gorm.Expr("used_quota + ?", usage.Cost),
}).Error; err != nil {
return fmt.Errorf("update user quota and used_quota error: %w", err)
if *llmusage.User.UnlimitedQuota {
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update user quota and used_quota error: %w", err)
}
} else {
if err := tx.WithContext(p.ctx).Model(&model.User{}).Where("id = ?", llmusage.User.ID).Updates(map[string]interface{}{
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update user quota and used_quota error: %w", err)
}
}
//4 . 更新token额度
if *token.UnlimitedQuota {
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update token quota and used_quota error: %w", err)
}
} else {
if err := tx.WithContext(p.ctx).Model(&model.Token{}).Where("id = ?", llmusage.TokenID).Updates(map[string]interface{}{
"quota": gorm.Expr("quota - ?", fmt.Sprintf("%.8f", cost)),
"used_quota": gorm.Expr("used_quota + ?", fmt.Sprintf("%.8f", cost)),
}).Error; err != nil {
return fmt.Errorf("update token quota and used_quota error: %w", err)
}
}
return nil
@@ -184,10 +238,9 @@ func (p *Proxy) Do(usage *model.Usage) error {
func (p *Proxy) SelectApiKey(model string) error {
akpikeys, err := p.apiKeyDao.FindApiKeysBySupportModel(p.db, model)
if err != nil || len(akpikeys) == 0 {
if strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o1") || strings.HasPrefix(model, "o3") || strings.HasPrefix(model, "o4") {
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "openai"})
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "openai"})
if err != nil {
return err
}
@@ -195,7 +248,7 @@ func (p *Proxy) SelectApiKey(model string) error {
}
if strings.HasPrefix(model, "gemini") {
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "gemini"})
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "gemini"})
if err != nil {
return err
}
@@ -203,7 +256,7 @@ func (p *Proxy) SelectApiKey(model string) error {
}
if strings.HasPrefix(model, "claude") {
keys, err := p.apiKeyDao.FindKeys(map[string]any{"apitype = ?": "claude"})
keys, err := p.apiKeyDao.FindKeys(map[string]any{"active = ?": true, "apitype = ?": "claude"})
if err != nil {
return err
}
@@ -270,7 +323,10 @@ func (p *Proxy) ScheduleTask() {
select {
case <-time.After(time.Duration(p.cfg.TaskTimeInterval) * time.Minute):
p.updateSupportModel()
case <-time.After(time.Hour * 12):
if err := p.setModelCache(); err != nil {
fmt.Println("refrash model cache err:", err)
}
case <-p.ctx.Done():
fmt.Println("schedule task done")
return
@@ -287,6 +343,9 @@ func (p *Proxy) getOpenAISupportModels(apikey model.ApiKey) ([]string, error) {
var supportModels []string
var req *http.Request
if *apikey.ApiType == "azure" {
if strings.HasSuffix(*apikey.Endpoint, "/") {
apikey.Endpoint = utils.ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
}
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("api-key", *apikey.ApiKey)
+14
View File
@@ -0,0 +1,14 @@
package controller
import (
"fmt"
"io"
"github.com/gin-gonic/gin"
)
func (p *Proxy) ProxyClaude(c *gin.Context) {
fmt.Println(c.Request.URL.String())
data, _ := io.ReadAll(c.Request.Body)
fmt.Println(string(data))
}
+1 -2
View File
@@ -1,7 +1,6 @@
package controller
import (
"fmt"
"net/http"
"opencatd-open/internal/dto"
"opencatd-open/internal/model"
@@ -142,7 +141,7 @@ func (a Api) CreateUser(c *gin.Context) {
dto.Fail(c, 400, err.Error())
return
}
fmt.Printf("user:%+v\n", user)
err = a.userService.Create(c, &user)
if err != nil {
dto.Fail(c, http.StatusInternalServerError, err.Error())
+1 -1
View File
@@ -90,7 +90,7 @@ func (a Api) ResetToken(c *gin.Context) {
dto.Fail(c, http.StatusNotFound, "token not found")
return
}
token.UsedQuota = utils.ToPtr(int64(0))
token.UsedQuota = utils.ToPtr(float64(0))
err = a.tokenService.UpdateToken(c, token)
if err != nil {
+16 -5
View File
@@ -3,6 +3,7 @@ package dao
import (
"errors"
"opencatd-open/internal/model"
"opencatd-open/internal/utils"
"opencatd-open/pkg/config"
"gorm.io/gorm"
@@ -38,6 +39,9 @@ func (dao *ApiKeyDAO) Create(apiKey *model.ApiKey) error {
if apiKey == nil {
return errors.New("apiKey is nil")
}
if len(*apiKey.SupportModels) < 2 {
apiKey.SupportModels = utils.ToPtr("[]")
}
return dao.db.Create(apiKey).Error
}
@@ -87,14 +91,21 @@ func (dao *ApiKeyDAO) FindApiKeysBySupportModel(db *gorm.DB, modelName string) (
var apiKeys []model.ApiKey
switch dao.cfg.DB_Type {
case "mysql":
return nil, errors.New("not support")
err := db.Raw(`
SELECT *
FROM apikeys
WHERE active = true
AND JSON_CONTAINS(support_models, ?, '$')`, modelName).
Scan(&apiKeys).Error
return apiKeys, err
case "postgres":
return nil, errors.New("not support")
}
err := db.Model(&model.ApiKey{}).
Joins("CROSS JOIN JSON_EACH(apikeys.support_models)").
Where("value = ?", modelName).
Find(&apiKeys).Error
err := db.Raw(`
SELECT a.*
FROM apikeys a
JOIN json_each(a.support_models) AS je ON je.value = ?
WHERE a.active = true`, modelName).Scan(&apiKeys).Error
return apiKeys, err
}
+2 -10
View File
@@ -212,11 +212,7 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "user_id"},
{Name: "token_id"},
{Name: "capability"},
{Name: "date"},
{Name: "model"},
{Name: "stream"},
},
DoUpdates: clause.Assignments(updateColumns),
}).Create(dailyUsage).Error
@@ -231,11 +227,7 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "user_id"},
{Name: "token_id"},
{Name: "capability"},
{Name: "date"},
{Name: "model"},
{Name: "stream"},
},
DoUpdates: clause.Assignments(updateColumns),
}).Create(dailyUsage).Error
@@ -244,8 +236,8 @@ func (d *DailyUsageDAO) UpsertDailyUsage(ctx context.Context, usage *model.Usage
default:
return db.Transaction(func(tx *gorm.DB) error {
var existing model.DailyUsage
err := tx.Where("user_id = ? AND token_id = ? AND capability = ? AND date = ? AND model = ? AND stream = ?",
usage.UserID, usage.TokenID, usage.Capability, date, usage.Model, usage.Stream).
err := tx.Where("user_id = ? AND date = ?",
usage.UserID, date).
First(&existing).Error
if err == gorm.ErrRecordNotFound {
+16 -16
View File
@@ -3,14 +3,14 @@ package model
import "github.com/lib/pq" //pq.StringArray
type ApiKey_PG struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
ApiSecret *string `gorm:"column:api_secret" json:"api_secret,omitempty"`
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`
ModelAlias *string `gorm:"column:model_alias" json:"model_alias,omitempty"`
@@ -26,14 +26,14 @@ func (ApiKey_PG) TableName() string {
}
type ApiKey struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
Name *string `gorm:"column:name;not null;unique;index:idx_apikey_name" json:"name,omitempty"`
ApiType *string `gorm:"column:apitype;not null;index:idx_apikey_apitype" json:"type,omitempty"`
ApiKey *string `gorm:"column:apikey;not null;index:idx_apikey_apikey" json:"apikey,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"`
Endpoint *string `gorm:"column:endpoint" json:"endpoint,omitempty"`
ResourceNmae *string `gorm:"column:resource_name" json:"resource_name,omitempty"`
// DeploymentName *string `gorm:"column:deployment_name" json:"deployment_name,omitempty"`
AccessKey *string `gorm:"column:access_key" json:"access_key,omitempty"`
SecretKey *string `gorm:"column:secret_key" json:"secret_key,omitempty"`
ModelPrefix *string `gorm:"column:model_prefix" json:"model_prefix,omitempty"`
+13 -13
View File
@@ -2,19 +2,19 @@ package model
// 用户的token
type Token struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
UserID int64 `gorm:"column:user_id;not null;index:idx_token_user_id" json:"userid,omitempty"`
Name string `gorm:"column:name;not null;index:idx_token_name" json:"name,omitempty" binding:"required,min=1,max=20"`
Key string `gorm:"column:key;not null;uniqueIndex:idx_token_key;comment:token key" json:"key,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"` //
Quota *int64 `gorm:"column:quota;type:bigint;default:0" json:"quota,omitempty"` // default 0
UnlimitedQuota *bool `gorm:"column:unlimited_quota;default:true" json:"unlimited_quota,omitempty"` // set Quota 1 unlimited
UsedQuota *int64 `gorm:"column:used_quota;type:bigint;default:0" json:"used_quota,omitempty"`
ExpiredAt *int64 `gorm:"column:expired_at;type:bigint;default:0" json:"expired_at,omitempty"`
NeverExpired *bool `gorm:"column:never_expires;type:bigint;" json:"never_expires,omitempty"`
CreatedAt int64 `gorm:"column:created_at;type:bigint;autoCreateTime" json:"created_at,omitempty"`
LastUsedAt int64 `gorm:"column:lastused_at;type:bigint;autoUpdateTime" json:"lastused_at,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id,omitempty"`
UserID int64 `gorm:"column:user_id;not null;index:idx_token_user_id" json:"userid,omitempty"`
Name string `gorm:"column:name;not null;index:idx_token_name" json:"name,omitempty" binding:"required,min=1,max=20"`
Key string `gorm:"column:key;not null;uniqueIndex:idx_token_key;comment:token key" json:"key,omitempty"`
Active *bool `gorm:"column:active;default:true" json:"active,omitempty"` //
Quota *float64 `gorm:"column:quota;type:bigint;default:0" json:"quota,omitempty"` // default 0
UnlimitedQuota *bool `gorm:"column:unlimited_quota;default:true" json:"unlimited_quota,omitempty"` // set Quota 1 unlimited
UsedQuota *float64 `gorm:"column:used_quota;type:bigint;default:0" json:"used_quota,omitempty"`
ExpiredAt *int64 `gorm:"column:expired_at;type:bigint;default:0" json:"expired_at,omitempty"`
NeverExpired *bool `gorm:"column:never_expires;type:bigint;" json:"never_expires,omitempty"`
CreatedAt int64 `gorm:"column:created_at;type:bigint;autoCreateTime" json:"created_at,omitempty"`
LastUsedAt int64 `gorm:"column:lastused_at;type:bigint;autoUpdateTime" json:"lastused_at,omitempty"`
User *User `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-"`
}
func (Token) TableName() string {
+9 -42
View File
@@ -1,11 +1,7 @@
package model
import (
"net/http"
"opencatd-open/store"
"time"
"github.com/gin-gonic/gin"
)
type Usage struct {
@@ -16,9 +12,9 @@ type Usage struct {
Date time.Time `gorm:"column:date;autoCreateTime;index:idx_date"`
Model string `gorm:"column:model"`
Stream bool `gorm:"column:stream"`
PromptTokens float64 `gorm:"column:prompt_tokens"`
CompletionTokens float64 `gorm:"column:completion_tokens"`
TotalTokens float64 `gorm:"column:total_tokens"`
PromptTokens int `gorm:"column:prompt_tokens"`
CompletionTokens int `gorm:"column:completion_tokens"`
TotalTokens int `gorm:"column:total_tokens"`
Cost string `gorm:"column:cost"`
}
@@ -28,47 +24,18 @@ func (Usage) TableName() string {
type DailyUsage struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
UserID int64 `gorm:"column:user_id;uniqueIndex:idx_daily_unique,priority:1"`
TokenID int64 `gorm:"column:token_id;index:idx_daily_token_id"`
Capability string `gorm:"column:capability;uniqueIndex:idx_daily_unique,priority:2;comment:模型能力"`
UserID int64 `gorm:"column:user_id;uniqueIndex:idx_daily_unique,priority:1"` // uniqueIndex:idx_daily_unique,priority:1
TokenID int64 `gorm:"column:token_id;uniqueIndex:idx_daily_unique,priority:2"`
Capability string `gorm:"column:capability;index:idx_daily_usage_capability;comment:模型能力"`
Date time.Time `gorm:"column:date;autoCreateTime;uniqueIndex:idx_daily_unique,priority:3"`
Model string `gorm:"column:model"`
Stream bool `gorm:"column:stream"`
PromptTokens float64 `gorm:"column:prompt_tokens"`
CompletionTokens float64 `gorm:"column:completion_tokens"`
TotalTokens float64 `gorm:"column:total_tokens"`
PromptTokens int `gorm:"column:prompt_tokens"`
CompletionTokens int `gorm:"column:completion_tokens"`
TotalTokens int `gorm:"column:total_tokens"`
Cost string `gorm:"column:cost"`
}
func (DailyUsage) TableName() string {
return "daily_usages"
}
func HandleUsage(c *gin.Context) {
fromStr := c.Query("from")
toStr := c.Query("to")
getMonthStartAndEnd := func() (start, end string) {
loc, _ := time.LoadLocation("Local")
now := time.Now().In(loc)
year, month, _ := now.Date()
startOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, loc)
endOfMonth := startOfMonth.AddDate(0, 1, 0)
start = startOfMonth.Format("2006-01-02")
end = endOfMonth.Format("2006-01-02")
return
}
if fromStr == "" || toStr == "" {
fromStr, toStr = getMonthStartAndEnd()
}
usage, err := store.QueryUsage(fromStr, toStr)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(200, usage)
}
-3
View File
@@ -52,9 +52,6 @@ func (s *ApiKeyServiceImpl) UpdateApiKey(ctx context.Context, apikey *model.ApiK
if apikey.ResourceNmae != nil {
_key.ResourceNmae = apikey.ResourceNmae
}
if apikey.DeploymentName != nil {
_key.DeploymentName = apikey.DeploymentName
}
if apikey.AccessKey != nil {
_key.AccessKey = apikey.AccessKey
}
+8 -4
View File
@@ -8,12 +8,13 @@ import (
"opencatd-open/internal/model"
"os"
"strings"
"time"
"github.com/tidwall/gjson"
"gorm.io/gorm"
)
var client = &http.Client{}
var client = &http.Client{Timeout: 2 * time.Second}
func init() {
if os.Getenv("LOCAL_PROXY") != "" {
@@ -29,13 +30,13 @@ func FetchKeyModel(db *gorm.DB, key *model.ApiKey) ([]string, error) {
var err error
if *key.ApiType == "openai" || *key.ApiType == "azure" {
supportModels, err = FetchOpenAISupportModels(db, key)
if err != nil {
fmt.Println(err)
}
}
if *key.ApiType == "claude" {
supportModels, err = FetchClaudeSupportModels(db, key)
}
if err != nil {
fmt.Println(err)
}
return supportModels, err
}
@@ -47,6 +48,9 @@ func FetchOpenAISupportModels(db *gorm.DB, apikey *model.ApiKey) ([]string, erro
var supportModels []string
var req *http.Request
if *apikey.ApiType == "azure" {
if strings.HasSuffix(*apikey.Endpoint, "/") {
apikey.Endpoint = ToPtr(strings.TrimSuffix(*apikey.Endpoint, "/"))
}
req, _ = http.NewRequest("GET", *apikey.Endpoint+azureModelsUrl, nil)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("api-key", *apikey.ApiKey)
+6 -1
View File
@@ -94,7 +94,9 @@ func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
if chatReq.MaxTokens > 0 {
maxTokens = chatReq.MaxTokens
} else {
if strings.Contains(chatReq.Model, "sonnet") || strings.Contains(chatReq.Model, "haiku") {
if strings.Contains(chatReq.Model, "3-7") {
maxTokens = 64000
} else if strings.Contains(chatReq.Model, "3-5") {
maxTokens = 8192
} else {
maxTokens = 4096
@@ -111,6 +113,9 @@ func (c *Claude) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
return nil, err
}
if c.tokenUsage.Model == "" && resp.Model != "" {
c.tokenUsage.Model = string(resp.Model)
}
c.tokenUsage.PromptTokens += resp.Usage.InputTokens
c.tokenUsage.CompletionTokens += resp.Usage.OutputTokens
c.tokenUsage.TotalTokens += resp.Usage.InputTokens + resp.Usage.OutputTokens
+3
View File
@@ -110,6 +110,9 @@ func (g *Gemini) Chat(ctx context.Context, chatReq llm.ChatRequest) (*llm.ChatRe
return nil, err
}
if g.tokenUsage.Model == "" && response.ModelVersion != "" {
g.tokenUsage.Model = response.ModelVersion
}
if response.UsageMetadata != nil {
g.tokenUsage.PromptTokens += int(response.UsageMetadata.PromptTokenCount)
g.tokenUsage.CompletionTokens += int(response.UsageMetadata.CandidatesTokenCount)
+1 -1
View File
@@ -13,7 +13,7 @@ type LLM interface {
type llm struct {
ApiKey *model.ApiKey
Usage *model.Usage
Usage *TokenUsage
tools any // TODO
Messages []any // TODO
llm LLM
+10
View File
@@ -14,6 +14,8 @@ import (
"opencatd-open/llm"
"os"
"strings"
"github.com/sashabaranov/go-openai"
)
// https://learn.microsoft.com/en-us/azure/ai-services/openai/api-version-deprecation#latest-preview-api-releases
@@ -86,6 +88,9 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
}
var buildurl string
if *o.ApiKey.Endpoint != "" {
if strings.HasSuffix(*o.ApiKey.Endpoint, "/") {
o.ApiKey.ApiKey = utils.ToPtr(strings.TrimSuffix(*o.ApiKey.Endpoint, "/"))
}
buildurl = fmt.Sprintf("%s/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.Endpoint, formatModel(chatReq.Model), AzureApiVersion)
} else {
buildurl = fmt.Sprintf("https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s", *o.ApiKey.ResourceNmae, formatModel(chatReq.Model), AzureApiVersion)
@@ -116,6 +121,9 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
return nil, err
}
if o.tokenUsage.Model == "" && chatResp.Model != "" {
o.tokenUsage.Model = chatResp.Model
}
o.tokenUsage.PromptTokens = chatResp.Usage.PromptTokens
o.tokenUsage.CompletionTokens = chatResp.Usage.CompletionTokens
o.tokenUsage.TotalTokens = chatResp.Usage.TotalTokens
@@ -124,6 +132,7 @@ func (o *OpenAICompatible) Chat(ctx context.Context, chatReq llm.ChatRequest) (*
func (o *OpenAICompatible) StreamChat(ctx context.Context, chatReq llm.ChatRequest) (chan *llm.StreamChatResponse, error) {
chatReq.Stream = true
chatReq.StreamOptions = &openai.StreamOptions{IncludeUsage: true}
dst, err := utils.StructToMap(chatReq)
if err != nil {
return nil, err
@@ -197,6 +206,7 @@ func (o *OpenAICompatible) StreamChat(ctx context.Context, chatReq llm.ChatReque
if err := json.Unmarshal(line, &streamResp); err != nil {
continue
}
if streamResp.Usage != nil {
o.tokenUsage.PromptTokens += streamResp.Usage.PromptTokens
o.tokenUsage.CompletionTokens += streamResp.Usage.CompletionTokens
+6 -1
View File
@@ -2,6 +2,7 @@ package llm
import (
"fmt"
"opencatd-open/internal/model"
"github.com/sashabaranov/go-openai"
)
@@ -15,9 +16,13 @@ type StreamChatResponse openai.ChatCompletionStreamResponse
type ChatMessage openai.ChatCompletionMessage
type TokenUsage struct {
User *model.User
TokenID int64
Model string `json:"model"`
Stream bool
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
ToolsTokens int `json:"total_tokens"`
ToolsTokens int `json:"tools_tokens"`
TotalTokens int `json:"total_tokens"`
}
+2
View File
@@ -94,6 +94,8 @@ func AuthLLM(db *gorm.DB) gin.HandlerFunc {
}
c.Set("user", token.User)
c.Set("user_id", token.User.ID)
c.Set("token_id", token.ID)
c.Set("authed", true)
// 可以在这里对 token 进行验证并检查权限
+1 -1
View File
@@ -124,7 +124,7 @@ func SetRouter(cfg *config.Config, db *gorm.DB, web *embed.FS) {
{
// v1.POST("/v2/*proxypath", router.HandleProxy)
v1.POST("/*proxypath", proxy.HandleProxy)
// v1.GET("/models", dashboard.HandleModels)
v1.GET("/models", proxy.HandleModels)
}
idxFS, err := fs.Sub(web, "dist")