Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/proxy/completions.ts
#	src/server/services/siteProxy.ts
This commit is contained in:
Hureru
2026-03-19 00:00:10 +08:00
20 changed files with 1747 additions and 137 deletions
+1
View File
@@ -0,0 +1 @@
NODE_ENV=test
+19 -32
View File
@@ -28,6 +28,7 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"socks": "^2.8.7",
"undici": "^6.24.1", "undici": "^6.24.1",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
@@ -227,7 +228,6 @@
"integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@algolia/client-common": "5.49.1", "@algolia/client-common": "5.49.1",
"@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1",
@@ -380,7 +380,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -707,7 +706,6 @@
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -753,7 +751,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -1243,6 +1240,7 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"cross-dirname": "^0.1.0", "cross-dirname": "^0.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -1264,6 +1262,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -1280,6 +1279,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -1294,6 +1294,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -4444,7 +4445,6 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -4854,7 +4854,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -4882,7 +4881,6 @@
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
"pg-protocol": "*", "pg-protocol": "*",
@@ -4914,7 +4912,6 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -5641,7 +5638,6 @@
"integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@algolia/abtesting": "1.15.1", "@algolia/abtesting": "1.15.1",
"@algolia/client-abtesting": "5.49.1", "@algolia/client-abtesting": "5.49.1",
@@ -6076,7 +6072,6 @@
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.11", "follow-redirects": "^1.15.11",
"form-data": "^4.0.5", "form-data": "^4.0.5",
@@ -6131,7 +6126,6 @@
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
@@ -6211,7 +6205,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -6615,7 +6608,6 @@
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/cst-dts-gen": "11.1.2",
"@chevrotain/gast": "11.1.2", "@chevrotain/gast": "11.1.2",
@@ -6971,7 +6963,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/cross-env": { "node_modules/cross-env": {
"version": "7.0.3", "version": "7.0.3",
@@ -7020,7 +7013,6 @@
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10" "node": ">=0.10"
} }
@@ -7454,7 +7446,6 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@@ -7889,7 +7880,6 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "26.8.1", "app-builder-lib": "26.8.1",
"builder-util": "26.8.1", "builder-util": "26.8.1",
@@ -8485,6 +8475,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.1", "@electron/asar": "^3.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@@ -8505,6 +8496,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0", "jsonfile": "^4.0.0",
@@ -8694,7 +8686,6 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -9085,7 +9076,6 @@
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tabbable": "^6.4.0" "tabbable": "^6.4.0"
} }
@@ -9852,7 +9842,6 @@
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -11079,6 +11068,7 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"minimist": "^1.2.6" "minimist": "^1.2.6"
}, },
@@ -11123,7 +11113,6 @@
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.18.2.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.18.2.tgz",
"integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==", "integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"aws-ssl-profiles": "^1.1.2", "aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0", "denque": "^2.1.0",
@@ -11666,7 +11655,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@@ -11764,7 +11752,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -11945,6 +11932,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"commander": "^9.4.0" "commander": "^9.4.0"
}, },
@@ -11962,6 +11950,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": "^12.20.0 || >=14" "node": "^12.20.0 || >=14"
} }
@@ -12149,7 +12138,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -12162,7 +12150,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -12451,6 +12438,7 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported", "deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^7.1.3"
}, },
@@ -12463,7 +12451,8 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/rimraf/node_modules/brace-expansion": { "node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
@@ -12471,6 +12460,7 @@
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
"concat-map": "0.0.1" "concat-map": "0.0.1"
@@ -12483,6 +12473,7 @@
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
@@ -12504,6 +12495,7 @@
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
}, },
@@ -13064,7 +13056,6 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 6.0.0", "node": ">= 6.0.0",
@@ -13075,7 +13066,6 @@
"version": "2.8.7", "version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ip-address": "^10.0.1", "ip-address": "^10.0.1",
@@ -13485,6 +13475,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"rimraf": "~2.6.2" "rimraf": "~2.6.2"
@@ -14250,7 +14241,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -14504,7 +14494,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",
@@ -16646,7 +16635,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
@@ -16762,7 +16750,6 @@
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29", "@vue/compiler-sfc": "3.5.29",
+1
View File
@@ -78,6 +78,7 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"socks": "^2.8.7",
"undici": "^6.24.1", "undici": "^6.24.1",
"ws": "^8.19.0" "ws": "^8.19.0"
}, },
+2
View File
@@ -94,6 +94,8 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))), tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))), proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))), proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
routingWeights: { routingWeights: {
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5), baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5), valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
+10
View File
@@ -129,6 +129,16 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url'); const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url');
if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl; if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl;
const proxyErrorKeywords = parseSettingFromMap<string[] | string>(settingsMap, 'proxy_error_keywords');
if (proxyErrorKeywords !== undefined) {
config.proxyErrorKeywords = toStringList(proxyErrorKeywords);
}
const proxyEmptyContentFailEnabled = parseSettingFromMap<boolean>(settingsMap, 'proxy_empty_content_fail_enabled');
if (typeof proxyEmptyContentFailEnabled === 'boolean') {
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
}
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron'); const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron; if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
@@ -12,6 +12,8 @@ const createApiTokenMock = vi.fn();
const getUserGroupsMock = vi.fn(); const getUserGroupsMock = vi.fn();
const deleteApiTokenMock = vi.fn(); const deleteApiTokenMock = vi.fn();
type AccountTokenServiceModule = typeof import('../../services/accountTokenService.js');
vi.mock('../../services/platforms/index.js', () => ({ vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({ getAdapter: () => ({
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
@@ -28,6 +30,7 @@ describe('account tokens sync routes with site status', () => {
let app: FastifyInstance; let app: FastifyInstance;
let db: DbModule['db']; let db: DbModule['db'];
let schema: DbModule['schema']; let schema: DbModule['schema'];
let maskToken: AccountTokenServiceModule['maskToken'];
let dataDir = ''; let dataDir = '';
let seedId = 0; let seedId = 0;
@@ -63,9 +66,11 @@ describe('account tokens sync routes with site status', () => {
await import('../../db/migrate.js'); await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js'); const dbModule = await import('../../db/index.js');
const accountTokenServiceModule = await import('../../services/accountTokenService.js');
const routesModule = await import('./accountTokens.js'); const routesModule = await import('./accountTokens.js');
db = dbModule.db; db = dbModule.db;
schema = dbModule.schema; schema = dbModule.schema;
maskToken = accountTokenServiceModule.maskToken;
app = Fastify(); app = Fastify();
await app.register(routesModule.accountTokensRoutes); await app.register(routesModule.accountTokensRoutes);
@@ -196,6 +201,247 @@ describe('account tokens sync routes with site status', () => {
]); ]);
}); });
it('reuses an existing ready token when upstream only returns the matching masked token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-real-token-1234';
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 0,
total: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: fullToken,
source: 'sync',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('removes matching masked_pending placeholders after reusing a ready token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-real-token-1234';
const maskedToken = maskToken(fullToken);
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: maskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: maskedToken, enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 0,
total: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: fullToken,
source: 'sync',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('does not reuse a different ready token when another logical token shares the same masked value', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const firstFullToken = 'sk-real-token-1234';
const secondFullToken = 'sk-real-zzzzz-1234';
const sharedMaskedToken = maskToken(firstFullToken);
expect(maskToken(secondFullToken)).toBe(sharedMaskedToken);
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'first-token',
token: firstFullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'second-token',
token: sharedMaskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'second-token', key: sharedMaskedToken, enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 1,
total: 2,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(2);
expect(tokenRows.find((row) => row.token === firstFullToken)).toMatchObject({
name: 'first-token',
token: firstFullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
const maskedRow = tokenRows.find((row) => row.name === 'second-token');
expect(maskedRow).toMatchObject({
token: sharedMaskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
});
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
});
it('keeps fully ambiguous short masks as masked_pending instead of reusing a ready token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-abcd';
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'short-token',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'short-token', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
created: 1,
updated: 0,
maskedPending: 1,
total: 2,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(2);
expect(tokenRows.find((row) => row.token === fullToken)).toBeDefined();
const maskedRow = tokenRows.find((row) => row.token === 'sk-***');
expect(maskedRow).toMatchObject({
name: 'short-token',
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
});
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
});
it('rejects sync and token management for apikey connections', async () => { it('rejects sync and token management for apikey connections', async () => {
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' }); const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
await db.update(schema.accounts) await db.update(schema.accounts)
@@ -273,6 +273,60 @@ describe('settings and auth events', () => {
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890'); expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
}); });
it('splits proxy error keywords on newlines and commas when saving runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
proxyErrorKeywords: 'quota exceeded\nbad gateway,too many requests',
proxyEmptyContentFailEnabled: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as {
proxyErrorKeywords?: string[];
proxyEmptyContentFailEnabled?: boolean;
};
expect(updated.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(updated.proxyEmptyContentFailEnabled).toBe(true);
expect(config.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(config.proxyEmptyContentFailEnabled).toBe(true);
const rows = await db.select().from(schema.settings).all();
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
expect(settingsMap.get('proxy_error_keywords')).toBe(JSON.stringify([
'quota exceeded',
'bad gateway',
'too many requests',
]));
expect(settingsMap.get('proxy_empty_content_fail_enabled')).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as {
proxyErrorKeywords?: string[];
proxyEmptyContentFailEnabled?: boolean;
};
expect(runtime.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(runtime.proxyEmptyContentFailEnabled).toBe(true);
});
it('persists and returns log cleanup settings from runtime settings', async () => { it('persists and returns log cleanup settings from runtime settings', async () => {
const updateResponse = await app.inject({ const updateResponse = await app.inject({
method: 'PUT', method: 'PUT',
+83
View File
@@ -55,6 +55,8 @@ interface RuntimeSettingsBody {
adminIpAllowlist?: string[] | string; adminIpAllowlist?: string[] | string;
routingFallbackUnitCost?: number; routingFallbackUnitCost?: number;
routingWeights?: Partial<RoutingWeights>; routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean;
} }
interface DatabaseMigrationBody { interface DatabaseMigrationBody {
@@ -127,6 +129,33 @@ function toStringList(value: unknown): string[] {
return []; return [];
} }
function parseProxyErrorKeywords(value: unknown): string[] {
const splitKeywords = (input: string): string[] => input
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (Array.isArray(value)) {
const keywords = value.flatMap((item) => {
if (typeof item !== 'string') return [];
return splitKeywords(item);
});
return keywords;
}
if (typeof value === 'string') {
const keywords = splitKeywords(value);
return keywords;
}
throw new Error('上游错误关键词格式无效:需要 string 或 string[]');
}
function parseBooleanFlag(value: unknown, label: string): boolean {
if (typeof value === 'boolean') return value;
throw new Error(`${label}格式无效:需要 boolean`);
}
function isValidHttpUrl(raw: string): boolean { function isValidHttpUrl(raw: string): boolean {
const value = String(raw || '').trim(); const value = String(raw || '').trim();
if (!value) return false; if (!value) return false;
@@ -197,6 +226,22 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.systemProxyUrl = normalizeSiteProxyUrl(value) || ''; config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return; return;
} }
case 'proxy_error_keywords': {
try {
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
} catch {
return;
}
return;
}
case 'proxy_empty_content_fail_enabled': {
try {
config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关');
} catch {
return;
}
return;
}
case 'webhook_url': { case 'webhook_url': {
if (typeof value !== 'string') return; if (typeof value !== 'string') return;
config.webhookUrl = value.trim(); config.webhookUrl = value.trim();
@@ -347,6 +392,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
adminIpAllowlist: config.adminIpAllowlist, adminIpAllowlist: config.adminIpAllowlist,
currentAdminIp, currentAdminIp,
systemProxyUrl: config.systemProxyUrl, systemProxyUrl: config.systemProxyUrl,
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken), proxyTokenMasked: maskSecret(config.proxyToken),
}; };
} }
@@ -603,6 +650,42 @@ export async function settingsRoutes(app: FastifyInstance) {
invalidateSiteProxyCache(); invalidateSiteProxyCache();
} }
if (body.proxyErrorKeywords !== undefined) {
let nextKeywords: string[] = [];
try {
nextKeywords = parseProxyErrorKeywords(body.proxyErrorKeywords);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '上游错误关键词格式无效',
});
}
if (JSON.stringify(nextKeywords) !== JSON.stringify(config.proxyErrorKeywords || [])) {
changedLabels.push('上游错误关键词');
}
config.proxyErrorKeywords = nextKeywords;
upsertSetting('proxy_error_keywords', config.proxyErrorKeywords);
}
if (body.proxyEmptyContentFailEnabled !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.proxyEmptyContentFailEnabled, '空内容判定失败开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '空内容判定失败开关格式无效',
});
}
if (nextValue !== config.proxyEmptyContentFailEnabled) {
changedLabels.push('空内容判定失败');
}
config.proxyEmptyContentFailEnabled = nextValue;
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
}
if (body.webhookUrl !== undefined) { if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) { if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址'); changedLabels.push('Webhook 地址');
+139
View File
@@ -1,5 +1,6 @@
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
const fetchMock = vi.fn(); const fetchMock = vi.fn();
const selectChannelMock = vi.fn(); const selectChannelMock = vi.fn();
@@ -109,6 +110,8 @@ describe('chat proxy stream behavior', () => {
}); });
selectNextChannelMock.mockReturnValue(null); selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null); fetchModelPricingCatalogMock.mockResolvedValue(null);
config.proxyEmptyContentFailEnabled = false;
config.proxyErrorKeywords = [];
}); });
afterAll(async () => { afterAll(async () => {
@@ -150,6 +153,77 @@ describe('chat proxy stream behavior', () => {
expect(response.body).toContain('data: [DONE]'); expect(response.body).toContain('data: [DONE]');
}); });
it('returns upstream_error for empty non-stream chat responses when empty-content failure is enabled', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-empty',
object: 'chat.completion',
created: 1_706_000_000,
model: 'upstream-gpt',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 6, completion_tokens: 0, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hi' }],
},
});
expect(response.statusCode).toBe(502);
expect(response.json()?.error?.type).toBe('upstream_error');
expect(response.json()?.error?.message).toContain('empty content');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('returns HTTP upstream_error instead of hijacking when streamed chat requests receive empty non-SSE payloads', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'chatcmpl-empty-stream',
object: 'chat.completion',
created: 1_706_000_000,
model: 'upstream-gpt',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 4, completion_tokens: 0, total_tokens: 4 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
},
});
expect(response.statusCode).toBe(502);
expect(response.headers['content-type']).not.toContain('text/event-stream');
expect(response.json()?.error?.type).toBe('upstream_error');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => { it('returns clear 400 when /v1/chat/completions receives responses-style input without messages', async () => {
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
@@ -1989,6 +2063,71 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/responses'); expect(targetUrl).toContain('/v1/responses');
}); });
it('returns upstream_error for empty non-stream /v1/responses payloads when empty-content failure is enabled', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp-empty',
object: 'response',
model: 'gpt-5.4',
status: 'completed',
output: [],
output_text: '',
usage: { input_tokens: 3, output_tokens: 0, total_tokens: 3 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello',
},
});
expect(response.statusCode).toBe(502);
expect(response.json()?.error?.type).toBe('upstream_error');
expect(response.json()?.error?.message).toContain('empty content');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('returns HTTP upstream_error instead of hijacking when streamed /v1/responses receives empty non-SSE payloads', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp-empty-stream',
object: 'response',
model: 'gpt-5.4',
status: 'completed',
output: [],
output_text: '',
usage: { input_tokens: 2, output_tokens: 0, total_tokens: 2 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello',
stream: true,
},
});
expect(response.statusCode).toBe(502);
expect(response.headers['content-type']).not.toContain('text/event-stream');
expect(response.json()?.error?.type).toBe('upstream_error');
expect(recordSuccessMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledTimes(1);
});
it('prefers native /v1/responses for claude-family /v1/responses requests that include input_file file_url', async () => { it('prefers native /v1/responses for claude-family /v1/responses requests that include input_file file_url', async () => {
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 }, channel: { id: 11, routeId: 22 },
+142 -82
View File
@@ -21,7 +21,8 @@ import {
recordDownstreamCostUsage, recordDownstreamCostUsage,
} from './downstreamPolicy.js'; } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js'; import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
@@ -343,12 +344,15 @@ async function handleChatProxyRequest(
const successfulUpstreamPath = endpointResult.upstreamPath; const successfulUpstreamPath = endpointResult.upstreamPath;
if (isStream) { if (isStream) {
reply.hijack(); const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
reply.raw.statusCode = 200; const startSseResponse = () => {
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); reply.hijack();
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); reply.raw.statusCode = 200;
reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
};
let parsedUsage: ReturnType<typeof parseProxyUsage> = { let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0, promptTokens: 0,
@@ -378,10 +382,11 @@ async function handleChatProxyRequest(
reply.raw.write(chunk); reply.raw.write(chunk);
}, },
}); });
let rawText = '';
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
if (!upstreamContentType.includes('text/event-stream')) { if (!upstreamContentType.includes('text/event-stream')) {
const fallbackText = await upstream.text(); const fallbackText = await upstream.text();
rawText = fallbackText;
let fallbackData: unknown = null; let fallbackData: unknown = null;
try { try {
fallbackData = JSON.parse(fallbackText); fallbackData = JSON.parse(fallbackText);
@@ -391,7 +396,96 @@ async function handleChatProxyRequest(
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
fallbackData = unwrapGeminiCliPayload(fallbackData); fallbackData = unwrapGeminiCliPayload(fallbackData);
} }
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({
error: { message: failure.reason, type: 'upstream_error' },
});
}
startSseResponse();
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
} else {
startSseResponse();
// Preserve real SSE streaming instead of buffering the whole upstream body.
const upstreamReader = upstream.body?.getReader();
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
const decoder = new TextDecoder();
const reader = baseReader
? {
async read() {
const result = await baseReader.read();
if (result.value) {
rawText += decoder.decode(result.value, { stream: true });
}
return result;
},
async cancel(reason?: unknown) {
return baseReader.cancel(reason);
},
releaseLock() {
return baseReader.releaseLock();
},
}
: baseReader;
const streamResult = await streamSession.run(reader, reply.raw);
rawText += decoder.decode();
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
if (streamResult.status === 'failed') { if (streamResult.status === 'failed') {
@@ -417,83 +511,13 @@ async function handleChatProxyRequest(
return; return;
} }
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ // Once SSE has been hijacked and streamed downstream, we can no longer
site: selected.site, // safely fall back to an HTTP error response or retry by switching the
account: selected.account, // channel mid-flight. Stream-level failures must be handled in-band by
tokenValue: selected.tokenValue, // the proxy stream session itself.
tokenName: selected.tokenName,
modelName,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
site: selected.site,
account: selected.account,
modelName,
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
} }
const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader)
: upstreamReader;
const streamResult = await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
return;
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site, site: selected.site,
account: selected.account, account: selected.account,
@@ -563,6 +587,42 @@ async function handleChatProxyRequest(
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData); const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({
error: { message: failure.reason, type: 'upstream_error' },
});
}
const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText); const normalizedFinal = downstreamTransformer.transformFinalResponse(upstreamData, modelName, rawText);
const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage); const downstreamResponse = downstreamTransformer.serializeFinalResponse(normalizedFinal, parsedUsage);
+39 -2
View File
@@ -13,6 +13,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js'; import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js'; import { buildUpstreamUrl } from './upstreamUrl.js';
@@ -70,7 +71,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
'Authorization': `Bearer ${selected.tokenValue}`, 'Authorization': `Bearer ${selected.tokenValue}`,
}, },
body: JSON.stringify(forwardBody), body: JSON.stringify(forwardBody),
}, getProxyUrlFromExtraConfig(selected.account.extraConfig))); }));
if (!upstream.ok) { if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error'); const errText = await upstream.text().catch(() => 'unknown error');
@@ -182,9 +183,45 @@ export async function completionsProxyRoute(app: FastifyInstance) {
return; return;
} }
const data = await upstream.json() as any; const rawText = await upstream.text();
let data: any = rawText;
try {
data = JSON.parse(rawText);
} catch {
data = rawText;
}
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data); const parsedUsage = parseProxyUsage(data);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
const errText = failure.reason;
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
errText,
retryCount,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({
error: { message: errText, type: 'upstream_error' },
});
}
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site, site: selected.site,
account: selected.account, account: selected.account,
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it } from 'vitest';
import { config } from '../../config.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
describe('detectProxyFailure (empty content)', () => {
const originalEmptyFail = config.proxyEmptyContentFailEnabled;
const originalKeywords = Array.isArray(config.proxyErrorKeywords) ? [...config.proxyErrorKeywords] : config.proxyErrorKeywords;
afterEach(() => {
config.proxyEmptyContentFailEnabled = originalEmptyFail;
config.proxyErrorKeywords = originalKeywords as any;
});
it('flags empty assistant content even when total tokens > 0', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_empty',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 },
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 12, completionTokens: 0, totalTokens: 12 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('does not flag when output exists even if usage is missing', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_has_output',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: 'hi' },
finish_reason: 'stop',
}],
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toBeNull();
});
it('does not treat tool call payloads as empty content', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'resp_tool',
object: 'response',
status: 'completed',
output: [{
type: 'function_call',
id: 'fc_1',
call_id: 'call_abc',
name: 'Glob',
arguments: '{"pattern":"README*"}',
}],
output_text: '',
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toBeNull();
});
it('flags empty SSE streams that contain no content deltas', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = [
'data: {"id":"evt_1","choices":[{"delta":{}}]}',
'',
'data: [DONE]',
'',
'',
].join('\n');
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('treats DONE-only SSE as no output and flags failure', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = [
'data: [DONE]',
'',
'',
].join('\n');
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
});
expect(failure).toMatchObject({ status: 502 });
});
it('truncates fractional token counts before empty-content detection', () => {
config.proxyEmptyContentFailEnabled = true;
const rawText = JSON.stringify({
id: 'chatcmpl_fractional_empty',
object: 'chat.completion',
choices: [{
index: 0,
message: { role: 'assistant', content: '' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 3.9, completion_tokens: 0.6, total_tokens: 4.5 },
});
const failure = detectProxyFailure({
rawText,
usage: { promptTokens: 3.9, completionTokens: 0.6, totalTokens: 4.5 } as any,
});
expect(failure).toMatchObject({ status: 502 });
});
});
@@ -0,0 +1,203 @@
import { config } from '../../config.js';
import { pullSseDataEvents } from '../../services/proxyUsageParser.js';
type FailureResult = {
status: number;
reason: string;
};
type UsageSummary = {
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
function normalizeKeywords(values: string[]): string[] {
return values
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0)
.map((item) => item.toLowerCase());
}
function toNonNegativeInt(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.trunc(n));
}
function hasNonEmptyString(value: unknown): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function hasToolCallLike(value: unknown): boolean {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
if (isRecord(value)) return Object.keys(value).length > 0;
return false;
}
function hasCompletionContentFromChoice(choice: any): boolean {
if (hasNonEmptyString(choice?.text)) return true;
if (hasNonEmptyString(choice?.completion)) return true;
if (hasNonEmptyString(choice?.output_text)) return true;
const message = choice?.message;
if (hasNonEmptyString(message?.content)) return true;
if (Array.isArray(message?.content)) {
for (const part of message.content) {
if (hasNonEmptyString(part?.text) || hasNonEmptyString(part?.output_text) || hasNonEmptyString(part?.content)) {
return true;
}
}
}
if (hasNonEmptyString(message?.refusal)) return true;
if (hasToolCallLike(message?.tool_calls)) return true;
if (hasToolCallLike(message?.toolCalls)) return true;
if (hasToolCallLike(message?.function_call)) return true;
if (hasToolCallLike(message?.functionCall)) return true;
if (hasToolCallLike(choice?.tool_calls)) return true;
if (hasToolCallLike(choice?.toolCalls)) return true;
if (hasToolCallLike(choice?.function_call)) return true;
if (hasToolCallLike(choice?.functionCall)) return true;
const delta = choice?.delta;
if (hasNonEmptyString(delta?.content)) return true;
if (hasNonEmptyString(delta?.refusal)) return true;
if (hasToolCallLike(delta?.tool_calls)) return true;
if (hasToolCallLike(delta?.toolCalls)) return true;
if (hasToolCallLike(delta?.function_call)) return true;
if (hasToolCallLike(delta?.functionCall)) return true;
return false;
}
function hasCompletionContentFromPayload(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') return false;
const obj: any = payload;
if (Array.isArray(obj?.choices)) {
for (const choice of obj.choices) {
if (hasCompletionContentFromChoice(choice)) return true;
}
if (hasCompletionContentFromChoice(obj)) return true;
}
if (hasNonEmptyString(obj?.output_text)) return true;
if (hasNonEmptyString(obj?.outputText)) return true;
if (Array.isArray(obj?.output)) {
for (const item of obj.output) {
if (!isRecord(item)) continue;
const type = String((item as any).type || '').toLowerCase();
if (type.includes('function_call') || type.includes('tool_call')) return true;
if (hasNonEmptyString((item as any).text) || hasNonEmptyString((item as any).output_text)) return true;
if (Array.isArray((item as any).content)) {
for (const part of (item as any).content) {
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
return true;
}
const partType = String((part as any)?.type || '').toLowerCase();
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
}
}
if (hasToolCallLike((item as any).tool_calls) || hasToolCallLike((item as any).toolCalls)) return true;
if (hasToolCallLike((item as any).function_call) || hasToolCallLike((item as any).functionCall)) return true;
}
}
if (Array.isArray(obj?.content)) {
for (const part of obj.content) {
if (hasNonEmptyString((part as any)?.text) || hasNonEmptyString((part as any)?.output_text) || hasNonEmptyString((part as any)?.content)) {
return true;
}
const partType = String((part as any)?.type || '').toLowerCase();
if (partType.includes('function_call') || partType.includes('tool_call')) return true;
}
}
if (hasNonEmptyString(obj?.delta)) return true;
if (hasNonEmptyString(obj?.text)) return true;
if (hasToolCallLike(obj?.tool_calls) || hasToolCallLike(obj?.toolCalls)) return true;
if (hasToolCallLike(obj?.function_call) || hasToolCallLike(obj?.functionCall)) return true;
return false;
}
function detectHasUpstreamOutput(rawText: string): boolean {
const text = typeof rawText === 'string' ? rawText : '';
const trimmed = text.trim();
if (!trimmed) return false;
try {
const parsed = JSON.parse(trimmed);
return hasCompletionContentFromPayload(parsed);
} catch {
// Important: don't trim before SSE parsing, otherwise `data: [DONE]\n\n` can be lost.
const pulled = pullSseDataEvents(text);
if (pulled.events.length > 0) {
for (const event of pulled.events) {
const payload = event.trim();
if (!payload || payload === '[DONE]') continue;
try {
const parsedEvent = JSON.parse(payload);
if (hasCompletionContentFromPayload(parsedEvent)) return true;
} catch {
// Non-JSON payload still counts as upstream output.
return true;
}
}
// SSE payloads exist but none contain output.
return false;
}
// Looks like SSE but contains no non-DONE payloads.
if (text.includes('data:')) return false;
// Not JSON and not SSE: assume it's plain text output.
return true;
}
}
export function detectProxyFailure(input: {
rawText: string;
usage?: UsageSummary | null;
// Backward-compatible fields (older call sites)
completionTokens?: number;
totalTokens?: number;
}): FailureResult | null {
const rawText = typeof input.rawText === 'string' ? input.rawText : '';
const keywords = normalizeKeywords(config.proxyErrorKeywords || []);
if (keywords.length > 0) {
const normalizedText = rawText.toLowerCase();
const matched = keywords.find((keyword) => normalizedText.includes(keyword));
if (matched) {
return {
status: 502,
reason: `Upstream response matched failure keyword: ${matched}`,
};
}
}
if (config.proxyEmptyContentFailEnabled) {
const completionTokens = toNonNegativeInt(input.usage?.completionTokens ?? input.completionTokens);
const hasOutput = detectHasUpstreamOutput(rawText);
if (!hasOutput && completionTokens <= 0) {
return {
status: 502,
reason: 'Upstream returned empty content',
};
}
}
return null;
}
+190 -7
View File
@@ -17,6 +17,7 @@ import {
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js'; import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js'; import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
import { detectProxyFailure } from './proxyFailureJudge.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js'; import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
@@ -425,15 +426,140 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const successfulUpstreamPath = endpointResult.upstreamPath; const successfulUpstreamPath = endpointResult.upstreamPath;
if (isStream) { if (isStream) {
reply.hijack(); const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
reply.raw.statusCode = 200; const startSseResponse = () => {
reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); reply.hijack();
reply.raw.setHeader('Cache-Control', 'no-cache, no-transform'); reply.raw.statusCode = 200;
reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
reply.raw.setHeader('X-Accel-Buffering', 'no'); reply.raw.setHeader('Cache-Control', 'no-cache, no-transform');
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
};
if (!upstreamContentType.includes('text/event-stream')) {
const rawText = await upstream.text();
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
} catch {
upstreamData = rawText;
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
const parsedUsage = parseProxyUsage(upstreamData);
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
}
const normalized = openAiResponsesTransformer.transformFinalResponse(
upstreamData,
modelName,
rawText,
);
const downstreamData = openAiResponsesTransformer.outbound.serializeFinal({
upstreamPayload: upstreamData,
normalized,
usage: parsedUsage,
serializationMode: 'response',
});
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
tokenValue: selected.tokenValue,
tokenName: selected.tokenName,
modelName: selected.actualModel || requestedModel,
requestStartedAtMs: startTime,
requestEndedAtMs: startTime + latency,
localLatencyMs: latency,
usage: {
promptTokens: parsedUsage.promptTokens,
completionTokens: parsedUsage.completionTokens,
totalTokens: parsedUsage.totalTokens,
},
});
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
site: selected.site,
account: selected.account,
modelName: selected.actualModel || requestedModel,
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
const streamPayload = isRecord(downstreamData) ? downstreamData : {
id: `resp_${Date.now()}`,
object: 'response',
status: 'completed',
model: modelName,
output: [],
output_text: '',
usage: {
input_tokens: parsedUsage.promptTokens,
output_tokens: parsedUsage.completionTokens,
total_tokens: parsedUsage.totalTokens,
},
};
const createdPayload = {
...streamPayload,
status: 'in_progress',
output: [],
output_text: '',
};
startSseResponse();
reply.raw.write(`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`);
reply.raw.write(`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`);
reply.raw.write('data: [DONE]\n\n');
reply.raw.end();
return;
}
startSseResponse();
const upstreamReader = upstream.body?.getReader(); const upstreamReader = upstream.body?.getReader();
const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
? createGeminiCliStreamReader(upstreamReader) ? createGeminiCliStreamReader(upstreamReader)
: upstreamReader; : upstreamReader;
let parsedUsage: UsageSummary = { let parsedUsage: UsageSummary = {
@@ -444,6 +570,25 @@ export async function responsesProxyRoute(app: FastifyInstance) {
cacheCreationTokens: 0, cacheCreationTokens: 0,
promptTokensIncludeCache: null, promptTokensIncludeCache: null,
}; };
let rawText = '';
const decoder = new TextDecoder();
const reader = baseReader
? {
async read() {
const result = await baseReader.read();
if (result.value) {
rawText += decoder.decode(result.value, { stream: true });
}
return result;
},
async cancel(reason?: unknown) {
return baseReader.cancel(reason);
},
releaseLock() {
return baseReader.releaseLock();
},
}
: baseReader;
const writeLines = (lines: string[]) => { const writeLines = (lines: string[]) => {
for (const line of lines) reply.raw.write(line); for (const line of lines) reply.raw.write(line);
}; };
@@ -465,6 +610,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
}, },
}); });
const streamResult = await streamSession.run(reader, reply.raw); const streamResult = await streamSession.run(reader, reply.raw);
rawText += decoder.decode();
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
if (streamResult.status === 'failed') { if (streamResult.status === 'failed') {
@@ -490,6 +636,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return; return;
} }
// Once SSE has been hijacked and bytes may already be on the wire, we
// must not attempt to convert stream failures into a fresh HTTP error
// response or retry on another channel. Responses stream failures are
// handled in-band by the proxy stream session.
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site, site: selected.site,
account: selected.account, account: selected.account,
@@ -545,6 +696,38 @@ export async function responsesProxyRoute(app: FastifyInstance) {
} }
const latency = Date.now() - startTime; const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(upstreamData); const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(
selected,
requestedModel,
'failed',
failure.status,
latency,
failure.reason,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
}
await reportProxyAllFailed({
model: requestedModel,
reason: failure.reason,
});
return reply.code(failure.status).send({ error: { message: failure.reason, type: 'upstream_error' } });
}
const normalized = openAiResponsesTransformer.transformFinalResponse( const normalized = openAiResponsesTransformer.transformFinalResponse(
upstreamData, upstreamData,
modelName, modelName,
@@ -60,6 +60,41 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean {
return value.includes('*') || value.includes('•'); return value.includes('*') || value.includes('•');
} }
function normalizeMaskedTokenForCompare(token: string | null | undefined): string {
return normalizeTokenForDisplay(token).replace(/•/g, '*');
}
function matchesMaskedTokenValue(
fullToken: string | null | undefined,
maskedToken: string | null | undefined,
): boolean {
const normalizedFull = normalizeTokenForDisplay(fullToken);
const normalizedMasked = normalizeMaskedTokenForCompare(maskedToken);
if (!normalizedFull || !normalizedMasked) return false;
if (!isMaskedTokenValue(normalizedMasked)) {
return normalizedFull === normalizedMasked;
}
const firstMaskIndex = normalizedMasked.search(/[\*]/);
const lastMaskIndex = Math.max(
normalizedMasked.lastIndexOf('*'),
normalizedMasked.lastIndexOf('•'),
);
if (firstMaskIndex < 0 || lastMaskIndex < firstMaskIndex) {
return normalizedFull === normalizedMasked;
}
const prefix = normalizedMasked.slice(0, firstMaskIndex);
const suffix = normalizedMasked.slice(lastMaskIndex + 1);
const visiblePrefix = prefix.replace(/^sk-/i, '');
if (!visiblePrefix && !suffix) return false;
if (normalizedFull.length < prefix.length + suffix.length) return false;
if (prefix && !normalizedFull.startsWith(prefix)) return false;
if (suffix && !normalizedFull.endsWith(suffix)) return false;
return true;
}
function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus { function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus {
const normalized = (value || '').trim().toLowerCase(); const normalized = (value || '').trim().toLowerCase();
return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
@@ -306,6 +341,62 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens:
continue; continue;
} }
const matchingReadyByMaskedValue = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
? existing.filter((row) => (
resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY
&& matchesMaskedTokenValue(row.token, tokenValue)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
))
: [];
const readyMaskedMatch = matchingReadyByMaskedValue.length === 1
? matchingReadyByMaskedValue[0]
: null;
if (readyMaskedMatch) {
const staleMaskedPlaceholders = existing.filter((row) => (
row.id !== readyMaskedMatch.id
&& resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
&& matchesMaskedTokenValue(row.token, tokenValue)
&& row.name === tokenName
&& sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName)
));
await db.update(schema.accountTokens)
.set({
name: tokenName,
tokenGroup,
valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY,
source: 'sync',
enabled,
updatedAt: now,
})
.where(eq(schema.accountTokens.id, readyMaskedMatch.id))
.run();
readyMaskedMatch.name = tokenName;
readyMaskedMatch.tokenGroup = tokenGroup;
readyMaskedMatch.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY;
readyMaskedMatch.enabled = enabled;
readyMaskedMatch.source = 'sync';
readyMaskedMatch.updatedAt = now;
if (staleMaskedPlaceholders.length > 0) {
for (const placeholder of staleMaskedPlaceholders) {
await db.delete(schema.accountTokens)
.where(eq(schema.accountTokens.id, placeholder.id))
.run();
}
for (const placeholder of staleMaskedPlaceholders) {
const placeholderIndex = existing.findIndex((row) => row.id === placeholder.id);
if (placeholderIndex >= 0) {
existing.splice(placeholderIndex, 1);
}
}
}
updated++;
continue;
}
const matchingPlaceholder = existing.find((row) => ( const matchingPlaceholder = existing.find((row) => (
isMaskedPendingAccountToken(row) isMaskedPendingAccountToken(row)
&& row.name === tokenName && row.name === tokenName
+72 -2
View File
@@ -1,9 +1,13 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { once } from 'node:events';
import { mkdtempSync } from 'node:fs'; import { mkdtempSync } from 'node:fs';
import { createServer } from 'node:http';
import { connect as connectSocket } from 'node:net';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { Headers } from 'undici'; import { SocksClient } from 'socks';
import { Headers, fetch } from 'undici';
type DbModule = typeof import('../db/index.js'); type DbModule = typeof import('../db/index.js');
@@ -71,6 +75,72 @@ describe('siteProxy', () => {
expect('dispatcher' in requestInit).toBe(true); expect('dispatcher' in requestInit).toBe(true);
}); });
it('injects a working dispatcher for socks5 system proxies', async () => {
const upstreamServer = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ ok: true }));
});
upstreamServer.listen(0, '127.0.0.1');
await once(upstreamServer, 'listening');
const upstreamAddress = upstreamServer.address();
if (!upstreamAddress || typeof upstreamAddress === 'string') {
throw new Error('Failed to determine upstream server address');
}
const requestUrl = `http://proxy-site.example.com:${upstreamAddress.port}/v1/chat/completions`;
await db.insert(schema.settings).values({
key: 'system_proxy_url',
value: JSON.stringify('socks5h://127.0.0.1:1080'),
}).run();
await db.run(sql`
INSERT INTO sites (name, url, platform, use_system_proxy)
VALUES ('proxy-site', ${`http://proxy-site.example.com:${upstreamAddress.port}`}, 'new-api', 1)
`);
const createConnectionSpy = vi.spyOn(SocksClient, 'createConnection').mockImplementation(async () => {
const socket = connectSocket(upstreamAddress.port, '127.0.0.1');
await once(socket, 'connect');
return { socket } as Awaited<ReturnType<typeof SocksClient.createConnection>>;
});
try {
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
const requestInit = await withSiteProxyRequestInit(requestUrl, {
method: 'GET',
});
expect('dispatcher' in requestInit).toBe(true);
const response = await fetch(requestUrl, requestInit);
expect(response.status).toBe(200);
expect(createConnectionSpy).toHaveBeenCalledTimes(1);
expect(createConnectionSpy).toHaveBeenCalledWith(expect.objectContaining({
command: 'connect',
proxy: expect.objectContaining({
host: '127.0.0.1',
port: 1080,
type: 5,
}),
destination: expect.objectContaining({
host: 'proxy-site.example.com',
port: upstreamAddress.port,
}),
}));
} finally {
createConnectionSpy.mockRestore();
await new Promise<void>((resolve, reject) => {
upstreamServer.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
});
it('merges site custom headers by matched request url and keeps explicit headers authoritative', async () => { it('merges site custom headers by matched request url and keeps explicit headers authoritative', async () => {
await db.insert(schema.sites).values({ await db.insert(schema.sites).values({
name: 'headers-site', name: 'headers-site',
+164 -2
View File
@@ -2,8 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { config } from '../config.js'; import { config } from '../config.js';
import { lookup as dnsLookup } from 'node:dns';
import { isIP, type Socket } from 'node:net';
import { connect as tlsConnect, type TLSSocket } from 'node:tls';
import { SocksClient } from 'socks';
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici'; import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
import { ProxyAgent } from 'undici'; import { Agent as UndiciAgent, ProxyAgent } from 'undici';
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js'; import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js'; import { getProxyUrlFromExtraConfig } from './accountExtraConfig.js';
@@ -17,6 +21,15 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
'socks5:', 'socks5:',
'socks5h:', 'socks5h:',
]); ]);
const SOCKS_PROXY_PROTOCOLS = new Set([
'socks:',
'socks4:',
'socks4a:',
'socks5:',
'socks5h:',
]);
const DEFAULT_PROXY_CONNECT_TIMEOUT_MS = 10_000;
const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
type SiteProxyRow = { type SiteProxyRow = {
siteUrl: string; siteUrl: string;
@@ -58,6 +71,27 @@ export function withAccountProxyOverride<T>(
return accountProxyOverride.run(normalized, fn); return accountProxyOverride.run(normalized, fn);
} }
type ParsedSocksProxyConfig = {
shouldLookup: boolean;
proxy: {
host: string;
port: number;
type: 4 | 5;
userId?: string;
password?: string;
};
};
type UndiciConnectOptions = {
hostname: string;
host?: string;
protocol: string;
port: string;
servername?: string;
localAddress?: string | null;
httpSocket?: Socket;
};
function normalizeSiteUrl(value: string): string { function normalizeSiteUrl(value: string): string {
const trimmed = (value || '').trim(); const trimmed = (value || '').trim();
if (!trimmed) return ''; if (!trimmed) return '';
@@ -128,7 +162,10 @@ function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
if (cached) return cached; if (cached) return cached;
try { try {
const dispatcher = new ProxyAgent(normalized); const parsedProxyUrl = new URL(normalized);
const dispatcher = SOCKS_PROXY_PROTOCOLS.has(parsedProxyUrl.protocol.toLowerCase())
? createSocksDispatcher(parsedProxyUrl)
: new ProxyAgent(normalized);
dispatcherCache.set(normalized, dispatcher); dispatcherCache.set(normalized, dispatcher);
return dispatcher; return dispatcher;
} catch { } catch {
@@ -136,6 +173,131 @@ function getDispatcherByProxyUrl(proxyUrl: string): Dispatcher | undefined {
} }
} }
function parseSocksProxyUrl(proxyUrl: URL): ParsedSocksProxyConfig {
let shouldLookup = false;
let type: 4 | 5 = 5;
switch (proxyUrl.protocol.toLowerCase()) {
case 'socks4:':
shouldLookup = true;
type = 4;
break;
case 'socks4a:':
type = 4;
break;
case 'socks5:':
shouldLookup = true;
type = 5;
break;
case 'socks:':
case 'socks5h:':
type = 5;
break;
default:
throw new TypeError(`Unsupported SOCKS proxy protocol: ${proxyUrl.protocol}`);
}
const proxy: ParsedSocksProxyConfig['proxy'] = {
host: proxyUrl.hostname,
port: Number.parseInt(proxyUrl.port, 10) || 1080,
type,
};
if (proxyUrl.username) {
proxy.userId = decodeURIComponent(proxyUrl.username);
}
if (proxyUrl.password) {
proxy.password = decodeURIComponent(proxyUrl.password);
}
return { shouldLookup, proxy };
}
function applySocketDefaults(socket: Socket | TLSSocket) {
socket.setNoDelay(true);
socket.setKeepAlive(true, DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS);
}
async function resolveSocksDestinationHost(hostname: string): Promise<string> {
return new Promise((resolve, reject) => {
dnsLookup(hostname, {}, (error, address) => {
if (error) {
reject(error);
return;
}
resolve(address);
});
});
}
async function createSocksSocket(
connectOptions: UndiciConnectOptions,
socksProxy: ParsedSocksProxyConfig,
): Promise<Socket | TLSSocket> {
if (!connectOptions.hostname) {
throw new Error('Missing hostname for SOCKS proxy request');
}
const destinationHost = socksProxy.shouldLookup
? await resolveSocksDestinationHost(connectOptions.hostname)
: connectOptions.hostname;
const destinationPort = Number.parseInt(connectOptions.port, 10)
|| (connectOptions.protocol === 'https:' ? 443 : 80);
const { socket } = await SocksClient.createConnection({
proxy: socksProxy.proxy,
destination: {
host: destinationHost,
port: destinationPort,
},
command: 'connect',
timeout: DEFAULT_PROXY_CONNECT_TIMEOUT_MS,
socket_options: connectOptions.localAddress
? { localAddress: connectOptions.localAddress } as any
: undefined,
});
applySocketDefaults(socket);
if (connectOptions.protocol !== 'https:') {
return socket;
}
return await new Promise<TLSSocket>((resolve, reject) => {
const tlsSocket = tlsConnect({
socket,
host: connectOptions.hostname,
servername: connectOptions.servername || (!isIP(connectOptions.hostname) ? connectOptions.hostname : undefined),
ALPNProtocols: ['http/1.1'],
});
const cleanup = (error: Error) => {
socket.destroy();
tlsSocket.destroy();
reject(error);
};
tlsSocket.once('secureConnect', () => {
tlsSocket.off('error', cleanup);
applySocketDefaults(tlsSocket);
resolve(tlsSocket);
});
tlsSocket.once('error', cleanup);
});
}
function createSocksDispatcher(proxyUrl: URL): Dispatcher {
const socksProxy = parseSocksProxyUrl(proxyUrl);
return new UndiciAgent({
connect: (connectOptions, callback) => {
void createSocksSocket(connectOptions, socksProxy)
.then((socket) => callback(null, socket))
.catch((error) => {
callback(error instanceof Error ? error : new Error(String(error)), null as any);
});
},
});
}
export function normalizeSiteProxyUrl(input: unknown): string | null { export function normalizeSiteProxyUrl(input: unknown): string | null {
if (typeof input !== 'string') return null; if (typeof input !== 'string') return null;
const trimmed = input.trim(); const trimmed = input.trim();
+60 -10
View File
@@ -1413,6 +1413,8 @@ export default function ModelTester() {
setActiveDebugTab(DEBUG_TABS.RESPONSE); setActiveDebugTab(DEBUG_TABS.RESPONSE);
const reader = response.body.getReader(); const reader = response.body.getReader();
let doneReceived = false; let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -1447,6 +1449,12 @@ export default function ModelTester() {
} }
const delta = parseAnyStreamDelta(eventPayload); const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) { if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, { setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta, reasoningDelta: delta.reasoningDelta,
@@ -1457,21 +1465,38 @@ export default function ModelTester() {
} }
} }
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => { setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev); const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev; if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, { return replaceMessageAt(prev, idx, {
...finalizeIncompleteMessage(prev[idx]), ...finalized,
status: MESSAGE_STATUS.COMPLETE, status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true, isThinkingComplete: true,
}); });
}); });
setPendingPayload(null); setPendingPayload(null);
setError(''); if (emptyOutput) {
pushDebug(doneReceived ? 'info' : 'warn', doneReceived const message = '上游返回空内容';
? '流式传输已成功完成。' setError(message);
: '流式传输未收到 [DONE] 信号,已在本地完成。'); pushDebug('error', '流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '流式传输已成功完成。'
: '流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) { } catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current; const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted const abortedUnexpectedly = controller.signal.aborted
@@ -1539,6 +1564,8 @@ export default function ModelTester() {
const reader = response.body.getReader(); const reader = response.body.getReader();
let doneReceived = false; let doneReceived = false;
let hasAnyContent = false;
let hasAnyReasoning = false;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -1571,6 +1598,12 @@ export default function ModelTester() {
} }
const delta = parseAnyStreamDelta(eventPayload); const delta = parseAnyStreamDelta(eventPayload);
if (typeof delta.reasoningDelta === 'string' && delta.reasoningDelta.trim().length > 0) {
hasAnyReasoning = true;
}
if (typeof delta.contentDelta === 'string' && delta.contentDelta.trim().length > 0) {
hasAnyContent = true;
}
if (delta.reasoningDelta || delta.contentDelta) { if (delta.reasoningDelta || delta.contentDelta) {
setMessages((prev) => applyAssistantDelta(prev, { setMessages((prev) => applyAssistantDelta(prev, {
reasoningDelta: delta.reasoningDelta, reasoningDelta: delta.reasoningDelta,
@@ -1581,20 +1614,37 @@ export default function ModelTester() {
} }
} }
const emptyOutput = !hasAnyContent && !hasAnyReasoning;
setMessages((prev) => { setMessages((prev) => {
const idx = findLastLoadingAssistantIndex(prev); const idx = findLastLoadingAssistantIndex(prev);
if (idx === -1) return prev; if (idx === -1) return prev;
const finalized = finalizeIncompleteMessage(prev[idx]);
if (emptyOutput && !(finalized.content || '').trim() && !(finalized.reasoningContent || '').trim()) {
return replaceMessageAt(prev, idx, {
...finalized,
content: '空回复(上游未返回任何内容)',
status: MESSAGE_STATUS.ERROR,
isThinkingComplete: true,
});
}
return replaceMessageAt(prev, idx, { return replaceMessageAt(prev, idx, {
...finalizeIncompleteMessage(prev[idx]), ...finalized,
status: MESSAGE_STATUS.COMPLETE, status: MESSAGE_STATUS.COMPLETE,
isThinkingComplete: true, isThinkingComplete: true,
}); });
}); });
setError(''); if (emptyOutput) {
pushDebug(doneReceived ? 'info' : 'warn', doneReceived const message = '上游返回空内容';
? '代理流式传输已成功完成。' setError(message);
: '代理流式传输未收到 [DONE] 信号,已在本地完成。'); pushDebug('error', '代理流式传输完成但内容为空。');
} else {
setError('');
pushDebug(doneReceived ? 'info' : 'warn', doneReceived
? '代理流式传输已成功完成。'
: '代理流式传输未收到 [DONE] 信号,已在本地完成。');
}
} catch (streamError: any) { } catch (streamError: any) {
const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current; const abortedByUser = controller.signal.aborted && streamStopRequestedRef.current;
const abortedUnexpectedly = controller.signal.aborted const abortedUnexpectedly = controller.signal.aborted
+79
View File
@@ -34,6 +34,8 @@ type RuntimeSettings = {
routingFallbackUnitCost: number; routingFallbackUnitCost: number;
routingWeights: RoutingWeights; routingWeights: RoutingWeights;
systemProxyUrl: string; systemProxyUrl: string;
proxyErrorKeywords: string[];
proxyEmptyContentFailEnabled: boolean;
proxyTokenMasked?: string; proxyTokenMasked?: string;
adminIpAllowlist?: string[]; adminIpAllowlist?: string[];
currentAdminIp?: string; currentAdminIp?: string;
@@ -193,13 +195,17 @@ export default function Settings() {
routingFallbackUnitCost: 1, routingFallbackUnitCost: 1,
routingWeights: defaultWeights, routingWeights: defaultWeights,
systemProxyUrl: '', systemProxyUrl: '',
proxyErrorKeywords: [],
proxyEmptyContentFailEnabled: false,
}); });
const [proxyTokenSuffix, setProxyTokenSuffix] = useState(''); const [proxyTokenSuffix, setProxyTokenSuffix] = useState('');
const [proxyErrorKeywordsText, setProxyErrorKeywordsText] = useState('');
const [maskedToken, setMaskedToken] = useState(''); const [maskedToken, setMaskedToken] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingSchedule, setSavingSchedule] = useState(false); const [savingSchedule, setSavingSchedule] = useState(false);
const [savingToken, setSavingToken] = useState(false); const [savingToken, setSavingToken] = useState(false);
const [savingSystemProxy, setSavingSystemProxy] = useState(false); const [savingSystemProxy, setSavingSystemProxy] = useState(false);
const [savingProxyFailureRules, setSavingProxyFailureRules] = useState(false);
const [savingRouting, setSavingRouting] = useState(false); const [savingRouting, setSavingRouting] = useState(false);
const [showAdvancedRouting, setShowAdvancedRouting] = useState(false); const [showAdvancedRouting, setShowAdvancedRouting] = useState(false);
const [savingSecurity, setSavingSecurity] = useState(false); const [savingSecurity, setSavingSecurity] = useState(false);
@@ -406,12 +412,21 @@ export default function Settings() {
...(runtimeInfo.routingWeights || {}), ...(runtimeInfo.routingWeights || {}),
}, },
systemProxyUrl: typeof runtimeInfo.systemProxyUrl === 'string' ? runtimeInfo.systemProxyUrl : '', systemProxyUrl: typeof runtimeInfo.systemProxyUrl === 'string' ? runtimeInfo.systemProxyUrl : '',
proxyErrorKeywords: Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string')
: [],
proxyEmptyContentFailEnabled: !!runtimeInfo.proxyEmptyContentFailEnabled,
proxyTokenMasked: runtimeInfo.proxyTokenMasked || '', proxyTokenMasked: runtimeInfo.proxyTokenMasked || '',
adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist) adminIpAllowlist: Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string') ? runtimeInfo.adminIpAllowlist.filter((item: unknown) => typeof item === 'string')
: [], : [],
currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '', currentAdminIp: typeof runtimeInfo.currentAdminIp === 'string' ? runtimeInfo.currentAdminIp : '',
}); });
setProxyErrorKeywordsText(
Array.isArray(runtimeInfo.proxyErrorKeywords)
? runtimeInfo.proxyErrorKeywords.filter((item: unknown) => typeof item === 'string').join('\n')
: '',
);
setAdminIpAllowlistText( setAdminIpAllowlistText(
Array.isArray(runtimeInfo.adminIpAllowlist) Array.isArray(runtimeInfo.adminIpAllowlist)
? runtimeInfo.adminIpAllowlist.join('\n') ? runtimeInfo.adminIpAllowlist.join('\n')
@@ -463,6 +478,11 @@ export default function Settings() {
return compact; return compact;
}; };
const parseProxyErrorKeywords = (raw: string) => raw
.split(/\r?\n|,/g)
.map((item) => item.trim())
.filter((item) => item.length > 0);
const saveSchedule = async () => { const saveSchedule = async () => {
setSavingSchedule(true); setSavingSchedule(true);
try { try {
@@ -521,6 +541,33 @@ export default function Settings() {
} }
}; };
const saveProxyFailureRules = async () => {
setSavingProxyFailureRules(true);
try {
const keywords = parseProxyErrorKeywords(proxyErrorKeywordsText);
const res = await api.updateRuntimeSettings({
proxyErrorKeywords: keywords,
proxyEmptyContentFailEnabled: runtime.proxyEmptyContentFailEnabled,
});
const nextKeywords = Array.isArray(res?.proxyErrorKeywords)
? res.proxyErrorKeywords
: keywords;
setRuntime((prev) => ({
...prev,
proxyErrorKeywords: nextKeywords,
proxyEmptyContentFailEnabled: typeof res?.proxyEmptyContentFailEnabled === 'boolean'
? res.proxyEmptyContentFailEnabled
: prev.proxyEmptyContentFailEnabled,
}));
setProxyErrorKeywordsText(nextKeywords.join('\n'));
toast.success('代理失败规则已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingProxyFailureRules(false);
}
};
const resetDownstreamForm = () => { const resetDownstreamForm = () => {
setEditingDownstreamId(null); setEditingDownstreamId(null);
setDownstreamCreate({ setDownstreamCreate({
@@ -1007,6 +1054,38 @@ export default function Settings() {
</button> </button>
</div> </div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>代理失败判定</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
命中任一关键词或空内容时判定失败,可触发重试。
</div>
<textarea
value={proxyErrorKeywordsText}
onChange={(e) => setProxyErrorKeywordsText(e.target.value)}
placeholder="一行一个关键词,或逗号分隔"
style={{
...inputStyle,
fontFamily: 'var(--font-mono)',
minHeight: 96,
resize: 'vertical',
marginBottom: 12,
}}
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 12 }}>
<input
type="checkbox"
checked={runtime.proxyEmptyContentFailEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, proxyEmptyContentFailEnabled: e.target.checked }))}
/>
空内容(completion=0,即使 prompt 有 token 也算)判定失败
</label>
<div>
<button onClick={saveProxyFailureRules} disabled={savingProxyFailureRules} className="btn btn-primary">
{savingProxyFailureRules ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存失败规则'}
</button>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}> <div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div> <div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游访问令牌(PROXY_TOKEN)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Many of our web tests rely on React's test utilities (act, etc.).
// If NODE_ENV is accidentally set to "production" in the environment,
// React switches to the production build where act() is not supported.
// Force a safe default so local/CI runs are stable.
env: {
NODE_ENV: process.env.NODE_ENV && process.env.NODE_ENV !== 'production' ? process.env.NODE_ENV : 'test',
},
},
});