forked from boos/BoosAPI
feat: extend proxy runtime parity
This commit is contained in:
Generated
+52
-19
@@ -28,7 +28,8 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"undici": "^6.24.1"
|
||||
"undici": "^6.24.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.0",
|
||||
@@ -226,6 +227,7 @@
|
||||
"integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.49.1",
|
||||
"@algolia/requester-browser-xhr": "5.49.1",
|
||||
@@ -378,6 +380,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -704,6 +707,7 @@
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -749,6 +753,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -1238,7 +1243,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1260,7 +1264,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1277,7 +1280,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1292,7 +1294,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -4443,6 +4444,7 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -4852,6 +4854,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4879,6 +4882,7 @@
|
||||
"integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -4910,6 +4914,7 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -5636,6 +5641,7 @@
|
||||
"integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/abtesting": "1.15.1",
|
||||
"@algolia/client-abtesting": "5.49.1",
|
||||
@@ -6070,6 +6076,7 @@
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
@@ -6124,6 +6131,7 @@
|
||||
"integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -6203,6 +6211,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6606,6 +6615,7 @@
|
||||
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "11.1.2",
|
||||
"@chevrotain/gast": "11.1.2",
|
||||
@@ -6961,8 +6971,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "7.0.3",
|
||||
@@ -7011,6 +7020,7 @@
|
||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
@@ -7444,6 +7454,7 @@
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -7878,6 +7889,7 @@
|
||||
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.8.1",
|
||||
"builder-util": "26.8.1",
|
||||
@@ -8473,7 +8485,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -8494,7 +8505,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -8684,6 +8694,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -9074,6 +9085,7 @@
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
@@ -11067,7 +11079,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11112,6 +11123,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.18.2.tgz",
|
||||
"integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
"denque": "^2.1.0",
|
||||
@@ -11654,6 +11666,7 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.12.0",
|
||||
"pg-pool": "^3.13.0",
|
||||
@@ -11751,6 +11764,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11931,7 +11945,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -11949,7 +11962,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -12137,6 +12149,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -12149,6 +12162,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -12437,7 +12451,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -12450,8 +12463,7 @@
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf/node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
@@ -12459,7 +12471,6 @@
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -12472,7 +12483,6 @@
|
||||
"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,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
@@ -12494,7 +12504,6 @@
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -13476,7 +13485,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -14242,6 +14250,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -14495,6 +14504,7 @@
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -16636,6 +16646,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -16751,6 +16762,7 @@
|
||||
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.29",
|
||||
"@vue/compiler-sfc": "3.5.29",
|
||||
@@ -16940,6 +16952,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlbuilder": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
|
||||
+2
-1
@@ -78,7 +78,8 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"undici": "^6.24.1"
|
||||
"undici": "^6.24.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.0",
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const reportProxyAllFailedMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
|
||||
...usage,
|
||||
estimatedCostFromQuota: 0,
|
||||
recoveredFromSelfLog: false,
|
||||
}));
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertService.js', () => ({
|
||||
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertRules.js', () => ({
|
||||
isTokenExpiredError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelPricingService.js', () => ({
|
||||
estimateProxyCost: async () => 0,
|
||||
buildProxyBillingDetails: async () => null,
|
||||
fetchModelPricingCatalog: async () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
||||
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/oauth/quota.js', () => ({
|
||||
recordOauthQuotaResetHint: async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => ({
|
||||
db: {
|
||||
insert: (arg: any) => dbInsertMock(arg),
|
||||
},
|
||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||
schema: {
|
||||
proxyLogs: {},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('claude count_tokens proxy route', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { claudeMessagesProxyRoute } = await import('./chat.js');
|
||||
app = Fastify();
|
||||
await app.register(claudeMessagesProxyRoute);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||
dbInsertMock.mockClear();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' },
|
||||
account: { id: 33, username: 'claude-user@example.com' },
|
||||
tokenName: 'default',
|
||||
tokenValue: 'sk-claude',
|
||||
actualModel: 'claude-opus-4-6',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('forwards /v1/messages/count_tokens to the claude count_tokens upstream path', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
input_tokens: 42,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/messages/count_tokens',
|
||||
payload: {
|
||||
model: 'claude-opus-4-6',
|
||||
tools: [
|
||||
{ name: 'lookup', input_schema: { type: 'object' } },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'count these tokens' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ input_tokens: 42 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true');
|
||||
expect(options.headers['anthropic-version']).toBe('2023-06-01');
|
||||
expect(options.headers['anthropic-beta']).toContain('claude-code-20250219');
|
||||
expect(options.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
|
||||
|
||||
const forwardedBody = JSON.parse(String(options.body));
|
||||
expect(forwardedBody.model).toBe('claude-opus-4-6');
|
||||
expect(forwardedBody.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'count these tokens', cache_control: { type: 'ephemeral' } }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+301
-28
@@ -1,7 +1,6 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
||||
import { fetch } from 'undici';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||
@@ -11,6 +10,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse
|
||||
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
buildClaudeCountTokensUpstreamRequest,
|
||||
buildUpstreamEndpointRequest,
|
||||
resolveUpstreamEndpointCandidates,
|
||||
} from './upstreamEndpoint.js';
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
recordDownstreamCostUsage,
|
||||
} from './downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow } from './endpointFlow.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
|
||||
@@ -36,15 +36,25 @@ import {
|
||||
buildOauthProviderHeaders,
|
||||
} from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
} from './geminiCliCompat.js';
|
||||
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export async function chatProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleChatProxyRequest(request, reply, 'openai'));
|
||||
@@ -53,6 +63,8 @@ export async function chatProxyRoute(app: FastifyInstance) {
|
||||
export async function claudeMessagesProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/messages', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleChatProxyRequest(request, reply, 'claude'));
|
||||
app.post('/v1/messages/count_tokens', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleClaudeCountTokensRequest(request, reply));
|
||||
}
|
||||
|
||||
async function handleChatProxyRequest(
|
||||
@@ -96,6 +108,12 @@ async function handleChatProxyRequest(
|
||||
}
|
||||
}
|
||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
|
||||
const codexSessionCacheKey = deriveCodexSessionCacheKey({
|
||||
downstreamFormat,
|
||||
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
|
||||
requestedModel,
|
||||
proxyToken: getProxyAuthContext(request)?.token || null,
|
||||
});
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
@@ -153,29 +171,46 @@ async function handleChatProxyRequest(
|
||||
options: { forceNormalizeClaudeBody?: boolean } = {},
|
||||
) => {
|
||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
stream: upstreamStream,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
stream: upstreamStream,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
oauthProjectId: oauth?.projectId,
|
||||
sitePlatform: selected.site.platform,
|
||||
siteUrl: selected.site.url,
|
||||
openaiBody: resolvedOpenAiBody,
|
||||
downstreamFormat,
|
||||
claudeOriginalBody,
|
||||
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildProviderHeaders(),
|
||||
codexSessionCacheKey,
|
||||
});
|
||||
return {
|
||||
endpoint,
|
||||
path: endpointRequest.path,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = (
|
||||
compatibilityRequest: BuiltEndpointRequest,
|
||||
targetUrl?: string,
|
||||
) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
|
||||
downstreamFormat,
|
||||
endpointCandidates,
|
||||
@@ -187,14 +222,7 @@ async function handleChatProxyRequest(
|
||||
endpoint,
|
||||
{ forceNormalizeClaudeBody },
|
||||
),
|
||||
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
|
||||
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: compatibilityRequest.headers,
|
||||
body: JSON.stringify(compatibilityRequest.body),
|
||||
}),
|
||||
),
|
||||
dispatchRequest,
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
@@ -208,14 +236,7 @@ async function handleChatProxyRequest(
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await fetch(
|
||||
refreshedTargetUrl,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: refreshedRequest.headers,
|
||||
body: JSON.stringify(refreshedRequest.body),
|
||||
}),
|
||||
);
|
||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
@@ -239,6 +260,7 @@ async function handleChatProxyRequest(
|
||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
dispatchRequest,
|
||||
tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: (ctx) => {
|
||||
@@ -266,6 +288,7 @@ async function handleChatProxyRequest(
|
||||
if (!endpointResult.ok) {
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
const rawErrText = endpointResult.rawErrText || errText;
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
@@ -285,6 +308,11 @@ async function handleChatProxyRequest(
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
await recordOauthQuotaResetHint({
|
||||
accountId: selected.account.id,
|
||||
statusCode: status,
|
||||
errorText: rawErrText,
|
||||
});
|
||||
|
||||
if (isTokenExpiredError({ status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
@@ -577,6 +605,251 @@ async function handleChatProxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function deriveCodexSessionCacheKey(input: {
|
||||
downstreamFormat: DownstreamFormat | 'responses';
|
||||
body: unknown;
|
||||
requestedModel: string;
|
||||
proxyToken: string | null;
|
||||
}): string | null {
|
||||
if (isRecord(input.body)) {
|
||||
if (input.downstreamFormat === 'claude' && isRecord(input.body.metadata)) {
|
||||
const userId = asTrimmedString(input.body.metadata.user_id);
|
||||
if (userId) return `${input.requestedModel}:claude:${userId}`;
|
||||
}
|
||||
const promptCacheKey = asTrimmedString(input.body.prompt_cache_key);
|
||||
if (promptCacheKey) return `${input.requestedModel}:responses:${promptCacheKey}`;
|
||||
}
|
||||
|
||||
const proxyToken = asTrimmedString(input.proxyToken);
|
||||
if (proxyToken) {
|
||||
return `${input.requestedModel}:proxy:${proxyToken}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handleClaudeCountTokensRequest(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const rawBody = isRecord(request.body) ? { ...request.body } : null;
|
||||
if (!rawBody) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: 'Request body must be a JSON object',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const requestedModel = asTrimmedString(rawBody.model);
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
message: 'model is required',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPath = '/v1/messages/count_tokens';
|
||||
const clientContext = detectDownstreamClientContext({
|
||||
downstreamPath,
|
||||
headers: request.headers as Record<string, unknown>,
|
||||
body: rawBody,
|
||||
});
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await reportProxyAllFailed({
|
||||
model: requestedModel,
|
||||
reason: 'No available channels after retries',
|
||||
});
|
||||
return reply.code(503).send({
|
||||
error: { message: 'No available channels for this model', type: 'server_error' },
|
||||
});
|
||||
}
|
||||
|
||||
excludeChannelIds.push(selected.channel.id);
|
||||
if (String(selected.site.platform || '').trim().toLowerCase() !== 'claude') {
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(501).send({
|
||||
error: {
|
||||
message: 'Claude count_tokens compatibility is not implemented for this upstream',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const modelName = selected.actualModel || requestedModel;
|
||||
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
|
||||
const startTime = Date.now();
|
||||
|
||||
const buildRequest = () => {
|
||||
const upstreamRequest = buildClaudeCountTokensUpstreamRequest({
|
||||
modelName,
|
||||
tokenValue: selected.tokenValue,
|
||||
oauthProvider: oauth?.provider,
|
||||
sitePlatform: selected.site.platform,
|
||||
claudeBody: rawBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
});
|
||||
return {
|
||||
endpoint: 'messages' as const,
|
||||
path: upstreamRequest.path,
|
||||
headers: upstreamRequest.headers,
|
||||
body: upstreamRequest.body,
|
||||
runtime: upstreamRequest.runtime,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
let upstreamRequest = buildRequest();
|
||||
let upstream = await dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
request: upstreamRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
});
|
||||
|
||||
if (upstream.status === 401 && oauth) {
|
||||
try {
|
||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||
selected.tokenValue = refreshed.accessToken;
|
||||
selected.account = {
|
||||
...selected.account,
|
||||
accessToken: refreshed.accessToken,
|
||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||
};
|
||||
upstreamRequest = buildRequest();
|
||||
upstream = await dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
request: upstreamRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// Fall through to the regular upstream error handling below.
|
||||
}
|
||||
}
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
const text = await upstream.text();
|
||||
let payload: unknown = text;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
upstream.status,
|
||||
latency,
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload),
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
upstreamRequest.path,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(upstream.status).type(contentType).send(payload);
|
||||
}
|
||||
|
||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
|
||||
recordDownstreamCostUsage(request, 0);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'success',
|
||||
upstream.status,
|
||||
latency,
|
||||
null,
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
upstreamRequest.path,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
return reply.code(upstream.status).type(contentType).send(payload);
|
||||
} catch (error: any) {
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
requestedModel,
|
||||
'failed',
|
||||
0,
|
||||
Date.now() - startTime,
|
||||
error?.message || 'network error',
|
||||
retryCount,
|
||||
downstreamPath,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount += 1;
|
||||
continue;
|
||||
}
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
message: `Upstream error: ${error?.message || 'network failure'}`,
|
||||
type: 'upstream_error',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
|
||||
@@ -58,6 +58,24 @@ describe('executeEndpointFlow', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses the injected dispatchRequest hook instead of the default fetch path', async () => {
|
||||
const dispatchRequest = vi.fn(async () => toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})));
|
||||
|
||||
const result = await executeEndpointFlow({
|
||||
siteUrl: 'https://example.com',
|
||||
endpointCandidates: ['responses'],
|
||||
buildRequest: () => requestFor('/v1/responses'),
|
||||
dispatchRequest,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(dispatchRequest).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('avoids duplicated /v1 when base url already ends with /v1', async () => {
|
||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
|
||||
@@ -9,6 +9,13 @@ export type BuiltEndpointRequest = {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime?: {
|
||||
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
|
||||
modelName?: string;
|
||||
stream?: boolean;
|
||||
oauthProjectId?: string | null;
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
};
|
||||
};
|
||||
|
||||
export type EndpointAttemptContext = {
|
||||
@@ -35,6 +42,7 @@ export type EndpointFlowResult =
|
||||
ok: false;
|
||||
status: number;
|
||||
errText: string;
|
||||
rawErrText?: string;
|
||||
};
|
||||
|
||||
export function withUpstreamPath(path: string, message: string): string {
|
||||
@@ -46,6 +54,10 @@ type ExecuteEndpointFlowInput = {
|
||||
proxyUrl?: string | null;
|
||||
endpointCandidates: UpstreamEndpoint[];
|
||||
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
||||
dispatchRequest?: (
|
||||
request: BuiltEndpointRequest,
|
||||
targetUrl: string,
|
||||
) => Promise<Awaited<ReturnType<typeof fetch>>>;
|
||||
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
|
||||
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
|
||||
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
||||
@@ -63,17 +75,20 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
||||
|
||||
let finalStatus = 0;
|
||||
let finalErrText = 'unknown error';
|
||||
let finalRawErrText: string | undefined;
|
||||
|
||||
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
|
||||
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
|
||||
const request = input.buildRequest(endpoint, endpointIndex);
|
||||
const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
|
||||
|
||||
let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(request.body),
|
||||
}));
|
||||
let response = input.dispatchRequest
|
||||
? await input.dispatchRequest(request, targetUrl)
|
||||
: await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(request.body),
|
||||
}));
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
@@ -124,6 +139,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
||||
|
||||
finalStatus = response.status;
|
||||
finalErrText = errText;
|
||||
finalRawErrText = rawErrText;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -131,5 +147,6 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
||||
ok: false,
|
||||
status: finalStatus || 502,
|
||||
errText: finalErrText || 'unknown error',
|
||||
rawErrText: finalRawErrText,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -742,15 +742,21 @@ describe('gemini native proxy routes', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:generateContent');
|
||||
expect(targetUrl).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent');
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Authorization: 'Bearer antigravity-access-token',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
});
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
const upstreamBody = JSON.parse(String(requestInit.body));
|
||||
expect(upstreamBody).toMatchObject({
|
||||
project: 'project-demo',
|
||||
model: 'gemini-3-pro-preview',
|
||||
userAgent: 'antigravity',
|
||||
requestType: 'agent',
|
||||
request: {
|
||||
sessionId: expect.any(String),
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
@@ -759,6 +765,8 @@ describe('gemini native proxy routes', () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(upstreamBody.requestId).toMatch(/^agent-[0-9a-f-]{36}$/i);
|
||||
expect(String(upstreamBody.request.sessionId)).toMatch(/^-\d+$/);
|
||||
expect(response.json()).toEqual({
|
||||
responseId: 'antigravity-response-1',
|
||||
modelVersion: 'gemini-3-pro-preview',
|
||||
|
||||
@@ -12,7 +12,7 @@ import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.j
|
||||
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
import { getDownstreamRoutingPolicy } from './downstreamPolicy.js';
|
||||
import { executeEndpointFlow } from './endpointFlow.js';
|
||||
import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import {
|
||||
buildUpstreamEndpointRequest,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
unwrapGeminiCliPayload,
|
||||
wrapGeminiCliRequest,
|
||||
} from './geminiCliCompat.js';
|
||||
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
const GEMINI_MODEL_PROBES = [
|
||||
@@ -442,11 +443,36 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
selected.tokenValue,
|
||||
query,
|
||||
);
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const upstream = isInternalGemini
|
||||
? await dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: {
|
||||
endpoint: 'chat',
|
||||
path: upstreamPath,
|
||||
headers: requestHeaders,
|
||||
body: requestBody as Record<string, unknown>,
|
||||
runtime: {
|
||||
executor: isGeminiCli ? 'gemini-cli' : 'antigravity',
|
||||
modelName: actualModel,
|
||||
stream: isStreamAction,
|
||||
oauthProjectId: oauth?.projectId || null,
|
||||
action: isCountTokensAction
|
||||
? 'countTokens'
|
||||
: (isStreamAction ? 'streamGenerateContent' : 'generateContent'),
|
||||
},
|
||||
},
|
||||
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
: await fetch(targetUrl, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const contentType = upstream.headers.get('content-type') || 'application/json';
|
||||
if (!upstream.ok) {
|
||||
lastStatus = upstream.status;
|
||||
@@ -661,8 +687,21 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
path: endpointRequest.path,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const endpointStrategy = createChatEndpointStrategy({
|
||||
downstreamFormat: 'openai',
|
||||
endpointCandidates,
|
||||
@@ -674,19 +713,13 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
||||
endpoint,
|
||||
{ forceNormalizeClaudeBody },
|
||||
),
|
||||
dispatchRequest: async (compatibilityRequest, targetUrl) => fetch(
|
||||
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
|
||||
await withSiteProxyRequestInit(selected.site.url, {
|
||||
method: 'POST',
|
||||
headers: compatibilityRequest.headers,
|
||||
body: JSON.stringify(compatibilityRequest.body),
|
||||
}),
|
||||
),
|
||||
dispatchRequest,
|
||||
});
|
||||
const endpointResult = await executeEndpointFlow({
|
||||
siteUrl: selected.site.url,
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
dispatchRequest,
|
||||
tryRecover: endpointStrategy.tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
||||
recoveredFromSelfLog: false,
|
||||
}));
|
||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||
const recordOauthQuotaResetHintMock = vi.fn();
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
@@ -65,6 +66,10 @@ vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/oauth/quota.js', () => ({
|
||||
recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => ({
|
||||
db: {
|
||||
insert: (arg: any) => dbInsertMock(arg),
|
||||
@@ -110,6 +115,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
reportTokenExpiredMock.mockReset();
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||
recordOauthQuotaResetHintMock.mockReset();
|
||||
dbInsertMock.mockClear();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
@@ -185,6 +191,14 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
|
||||
expect(secondOptions.headers.Originator || secondOptions.headers.originator).toBe('codex_cli_rs');
|
||||
expect(secondOptions.headers['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123');
|
||||
expect(secondOptions.headers.Version || secondOptions.headers.version).toBe('0.101.0');
|
||||
expect(String(secondOptions.headers.Session_id || secondOptions.headers.session_id || '')).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(secondOptions.headers.Conversation_id || secondOptions.headers.conversation_id).toBe(
|
||||
secondOptions.headers.Session_id || secondOptions.headers.session_id,
|
||||
);
|
||||
expect(secondOptions.headers['User-Agent'] || secondOptions.headers['user-agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464');
|
||||
expect(secondOptions.headers.Accept || secondOptions.headers.accept).toBe('text/event-stream');
|
||||
expect(secondOptions.headers.Connection || secondOptions.headers.connection).toBe('Keep-Alive');
|
||||
expect(response.json()?.output_text).toContain('ok after codex token refresh');
|
||||
});
|
||||
|
||||
@@ -216,6 +230,9 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
const [, options] = fetchMock.mock.calls[0] as [string, any];
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.instructions).toBe('');
|
||||
expect(forwardedBody.prompt_cache_key).toBe(
|
||||
options.headers.Session_id || options.headers.session_id,
|
||||
);
|
||||
expect(forwardedBody.stream).toBe(true);
|
||||
expect(forwardedBody.input).toEqual([
|
||||
{
|
||||
@@ -226,6 +243,41 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
error: {
|
||||
type: 'usage_limit_reached',
|
||||
resets_at: 1773800400,
|
||||
message: 'quota exceeded',
|
||||
},
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
input: 'hello codex',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(429);
|
||||
expect(recordOauthQuotaResetHintMock).toHaveBeenCalledWith({
|
||||
accountId: 33,
|
||||
statusCode: 429,
|
||||
errorText: JSON.stringify({
|
||||
error: {
|
||||
type: 'usage_limit_reached',
|
||||
resets_at: 1773800400,
|
||||
message: 'quota exceeded',
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('forces codex upstream responses requests to stream and aggregates the SSE payload for non-stream downstream callers', async () => {
|
||||
fetchMock.mockResolvedValue(createSseResponse([
|
||||
'event: response.created\n',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { fetch } from 'undici';
|
||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||
@@ -16,11 +15,12 @@ import {
|
||||
} from './upstreamEndpoint.js';
|
||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||
import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||
import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||
import { dispatchRuntimeRequest } from './runtimeExecutor.js';
|
||||
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||
import {
|
||||
ProxyInputFileResolutionError,
|
||||
@@ -31,12 +31,14 @@ import {
|
||||
buildOauthProviderHeaders,
|
||||
} from '../../services/oauth/service.js';
|
||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
|
||||
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||
import {
|
||||
createGeminiCliStreamReader,
|
||||
unwrapGeminiCliPayload,
|
||||
} from './geminiCliCompat.js';
|
||||
import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js';
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
@@ -122,7 +124,28 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
|
||||
|
||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||
|
||||
function deriveCodexSessionCacheKey(input: {
|
||||
body: Record<string, unknown>;
|
||||
requestedModel: string;
|
||||
proxyToken: string | null;
|
||||
}): string | null {
|
||||
const promptCacheKey = typeof input.body.prompt_cache_key === 'string'
|
||||
? input.body.prompt_cache_key.trim()
|
||||
: '';
|
||||
if (promptCacheKey) {
|
||||
return `${input.requestedModel}:responses:${promptCacheKey}`;
|
||||
}
|
||||
|
||||
const proxyToken = typeof input.proxyToken === 'string' ? input.proxyToken.trim() : '';
|
||||
if (proxyToken) {
|
||||
return `${input.requestedModel}:proxy:${proxyToken}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
ensureResponsesWebsocketTransport(app);
|
||||
const handleResponsesRequest = async (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
@@ -248,6 +271,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
);
|
||||
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
|
||||
const upstreamStream = isStream || (isCodexSite && endpoint === 'responses');
|
||||
const codexSessionCacheKey = deriveCodexSessionCacheKey({
|
||||
body: normalizedResponsesBody,
|
||||
requestedModel,
|
||||
proxyToken: getProxyAuthContext(request)?.token || null,
|
||||
});
|
||||
const endpointRequest = buildUpstreamEndpointRequest({
|
||||
endpoint,
|
||||
modelName,
|
||||
@@ -262,6 +290,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
responsesOriginalBody: normalizedResponsesBody,
|
||||
downstreamHeaders: request.headers as Record<string, unknown>,
|
||||
providerHeaders: buildProviderHeaders(),
|
||||
codexSessionCacheKey,
|
||||
});
|
||||
const upstreamPath = (
|
||||
isCompactRequest && endpoint === 'responses'
|
||||
@@ -273,19 +302,25 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
path: upstreamPath,
|
||||
headers: endpointRequest.headers,
|
||||
body: endpointRequest.body as Record<string, unknown>,
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => (
|
||||
dispatchRuntimeRequest({
|
||||
siteUrl: selected.site.url,
|
||||
targetUrl,
|
||||
request: compatibilityRequest,
|
||||
buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: requestForFetch.headers,
|
||||
body: JSON.stringify(requestForFetch.body),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream: isStream || isCodexSite,
|
||||
requiresNativeResponsesFileUrl,
|
||||
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
|
||||
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: compatibilityRequest.headers,
|
||||
body: JSON.stringify(compatibilityRequest.body),
|
||||
}),
|
||||
),
|
||||
dispatchRequest,
|
||||
});
|
||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||
if (ctx.response.status === 401 && oauth) {
|
||||
@@ -299,14 +334,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
};
|
||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
||||
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
|
||||
const refreshedResponse = await fetch(
|
||||
refreshedTargetUrl,
|
||||
withSiteRecordProxyRequestInit(selected.site, {
|
||||
method: 'POST',
|
||||
headers: refreshedRequest.headers,
|
||||
body: JSON.stringify(refreshedRequest.body),
|
||||
}),
|
||||
);
|
||||
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||
if (refreshedResponse.ok) {
|
||||
return {
|
||||
upstream: refreshedResponse,
|
||||
@@ -331,6 +359,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
proxyUrl: resolveProxyUrlForSite(selected.site),
|
||||
endpointCandidates,
|
||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||
dispatchRequest,
|
||||
tryRecover,
|
||||
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||
onDowngrade: (ctx) => {
|
||||
@@ -358,6 +387,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
if (!endpointResult.ok) {
|
||||
const status = endpointResult.status || 502;
|
||||
const errText = endpointResult.errText || 'unknown error';
|
||||
const rawErrText = endpointResult.rawErrText || errText;
|
||||
tokenRouter.recordFailure(selected.channel.id);
|
||||
logProxy(
|
||||
selected,
|
||||
@@ -377,6 +407,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
clientContext,
|
||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
||||
);
|
||||
await recordOauthQuotaResetHint({
|
||||
accountId: selected.account.id,
|
||||
statusCode: status,
|
||||
errorText: rawErrText,
|
||||
});
|
||||
|
||||
if (isTokenExpiredError({ status, message: errText })) {
|
||||
await reportTokenExpired({
|
||||
@@ -578,6 +613,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
|
||||
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleResponsesRequest(request, reply, '/v1/responses'));
|
||||
app.get('/v1/responses', async (_request: FastifyRequest, reply: FastifyReply) =>
|
||||
reply.code(426).send({
|
||||
error: {
|
||||
message: 'WebSocket upgrade required for GET /v1/responses',
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
}));
|
||||
app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) =>
|
||||
handleResponsesRequest(request, reply, '/v1/responses/compact'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import WebSocket from 'ws';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
const selectNextChannelMock = vi.fn();
|
||||
const recordSuccessMock = vi.fn();
|
||||
const recordFailureMock = vi.fn();
|
||||
const refreshModelsAndRebuildRoutesMock = vi.fn();
|
||||
const reportProxyAllFailedMock = vi.fn();
|
||||
const reportTokenExpiredMock = vi.fn();
|
||||
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
|
||||
...usage,
|
||||
estimatedCostFromQuota: 0,
|
||||
recoveredFromSelfLog: false,
|
||||
}));
|
||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||
values: () => ({
|
||||
run: () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/tokenRouter.js', () => ({
|
||||
tokenRouter: {
|
||||
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
|
||||
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
|
||||
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
|
||||
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelService.js', () => ({
|
||||
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertService.js', () => ({
|
||||
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
|
||||
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/alertRules.js', () => ({
|
||||
isTokenExpiredError: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/modelPricingService.js', () => ({
|
||||
estimateProxyCost: async () => 0,
|
||||
buildProxyBillingDetails: async () => null,
|
||||
fetchModelPricingCatalog: async () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyRetryPolicy.js', () => ({
|
||||
shouldRetryProxyRequest: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
||||
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/oauth/quota.js', () => ({
|
||||
recordOauthQuotaResetHint: async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => ({
|
||||
db: {
|
||||
insert: (arg: any) => dbInsertMock(arg),
|
||||
},
|
||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||
schema: {
|
||||
proxyLogs: {},
|
||||
},
|
||||
}));
|
||||
|
||||
function createSseResponse(chunks: string[], status = 200) {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}), {
|
||||
status,
|
||||
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSocketOpen(socket: WebSocket) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
socket.once('open', () => resolve());
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function waitForSocketMessages(socket: WebSocket, count: number) {
|
||||
return new Promise<any[]>((resolve, reject) => {
|
||||
const messages: any[] = [];
|
||||
const onMessage = (payload: WebSocket.RawData) => {
|
||||
messages.push(JSON.parse(String(payload)));
|
||||
if (messages.length >= count) {
|
||||
socket.off('message', onMessage);
|
||||
socket.off('error', reject);
|
||||
resolve(messages);
|
||||
}
|
||||
};
|
||||
socket.on('message', onMessage);
|
||||
socket.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('responses websocket transport', () => {
|
||||
let app: FastifyInstance;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { responsesProxyRoute } = await import('./responses.js');
|
||||
app = Fastify();
|
||||
await app.register(responsesProxyRoute);
|
||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||
const address = app.server.address() as AddressInfo;
|
||||
baseUrl = `ws://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
selectNextChannelMock.mockReset();
|
||||
recordSuccessMock.mockReset();
|
||||
recordFailureMock.mockReset();
|
||||
refreshModelsAndRebuildRoutesMock.mockReset();
|
||||
reportProxyAllFailedMock.mockReset();
|
||||
reportTokenExpiredMock.mockReset();
|
||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||
dbInsertMock.mockClear();
|
||||
|
||||
selectChannelMock.mockReturnValue({
|
||||
channel: { id: 11, routeId: 22 },
|
||||
site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' },
|
||||
account: {
|
||||
id: 33,
|
||||
username: 'codex-user@example.com',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-123',
|
||||
email: 'codex-user@example.com',
|
||||
},
|
||||
}),
|
||||
},
|
||||
tokenName: 'default',
|
||||
tokenValue: 'oauth-access-token',
|
||||
actualModel: 'gpt-5.4',
|
||||
});
|
||||
selectNextChannelMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => {
|
||||
fetchMock.mockResolvedValue(createSseResponse([
|
||||
'event: response.created\n',
|
||||
'data: {"type":"response.created","response":{"id":"resp_ws","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||
'event: response.output_item.added\n',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_ws","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||
'event: response.output_text.delta\n',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"pong"}\n\n',
|
||||
'event: response.completed\n',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_ws","model":"gpt-5.4","status":"completed","output":[{"id":"msg_ws","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"pong"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]));
|
||||
|
||||
const socket = new WebSocket(`${baseUrl}/v1/responses`);
|
||||
await waitForSocketOpen(socket);
|
||||
const messagesPromise = waitForSocketMessages(socket, 4);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'hello websocket' }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const messages = await messagesPromise;
|
||||
socket.close();
|
||||
|
||||
expect(messages.map((message) => message.type)).toEqual([
|
||||
'response.created',
|
||||
'response.output_item.added',
|
||||
'response.output_text.delta',
|
||||
'response.completed',
|
||||
]);
|
||||
expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
|
||||
const installedApps = new WeakSet<FastifyInstance>();
|
||||
|
||||
type NormalizedResponsesWebsocketRequest =
|
||||
| {
|
||||
ok: true;
|
||||
request: Record<string, unknown>;
|
||||
nextRequestSnapshot: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: RawData): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(String(raw));
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneJsonObject<T>(value: T): T {
|
||||
return structuredClone(value);
|
||||
}
|
||||
|
||||
function toResponseInputArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? cloneJsonObject(value) : [];
|
||||
}
|
||||
|
||||
function normalizeResponsesWebsocketRequest(
|
||||
raw: RawData,
|
||||
lastRequest: Record<string, unknown> | null,
|
||||
lastResponseOutput: unknown[],
|
||||
): NormalizedResponsesWebsocketRequest {
|
||||
const parsed = parseJsonObject(raw);
|
||||
if (!parsed) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'Invalid websocket JSON payload',
|
||||
};
|
||||
}
|
||||
|
||||
const requestType = asTrimmedString(parsed.type);
|
||||
if (requestType !== 'response.create' && requestType !== 'response.append') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: `unsupported websocket request type: ${requestType || 'unknown'}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestType === 'response.create' && parsed.generate === false) {
|
||||
const next = cloneJsonObject(parsed);
|
||||
delete next.type;
|
||||
delete next.generate;
|
||||
next.stream = true;
|
||||
if (!Array.isArray(next.input)) next.input = [];
|
||||
const modelName = asTrimmedString(next.model);
|
||||
if (!modelName) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'missing model in response.create request',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
request: next,
|
||||
nextRequestSnapshot: cloneJsonObject(next),
|
||||
};
|
||||
}
|
||||
|
||||
if (!lastRequest) {
|
||||
if (requestType !== 'response.create') {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'websocket request received before response.create',
|
||||
};
|
||||
}
|
||||
const next = cloneJsonObject(parsed);
|
||||
delete next.type;
|
||||
next.stream = true;
|
||||
if (!Array.isArray(next.input)) next.input = [];
|
||||
const modelName = asTrimmedString(next.model);
|
||||
if (!modelName) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'missing model in response.create request',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
request: next,
|
||||
nextRequestSnapshot: cloneJsonObject(next),
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed.input)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
message: 'websocket request requires array field: input',
|
||||
};
|
||||
}
|
||||
|
||||
const mergedInput = [
|
||||
...toResponseInputArray(lastRequest.input),
|
||||
...cloneJsonObject(lastResponseOutput),
|
||||
...cloneJsonObject(parsed.input),
|
||||
];
|
||||
const next = cloneJsonObject(parsed);
|
||||
delete next.type;
|
||||
delete next.previous_response_id;
|
||||
next.input = mergedInput;
|
||||
next.stream = true;
|
||||
if (!('model' in next) && typeof lastRequest.model === 'string') {
|
||||
next.model = lastRequest.model;
|
||||
}
|
||||
if (!('instructions' in next) && lastRequest.instructions !== undefined) {
|
||||
next.instructions = cloneJsonObject(lastRequest.instructions);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
request: next,
|
||||
nextRequestSnapshot: cloneJsonObject(next),
|
||||
};
|
||||
}
|
||||
|
||||
function writeResponsesWebsocketError(
|
||||
socket: WebSocket,
|
||||
status: number,
|
||||
message: string,
|
||||
errorPayload?: unknown,
|
||||
) {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'error',
|
||||
status,
|
||||
error: isRecord(errorPayload) && isRecord(errorPayload.error)
|
||||
? errorPayload.error
|
||||
: {
|
||||
type: status >= 500 ? 'server_error' : 'invalid_request_error',
|
||||
message,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function synthesizePrewarmResponsePayloads(request: Record<string, unknown>) {
|
||||
const responseId = `resp_prewarm_${randomUUID()}`;
|
||||
const modelName = asTrimmedString(request.model) || 'unknown';
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
return [
|
||||
{
|
||||
type: 'response.created',
|
||||
response: {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: createdAt,
|
||||
status: 'in_progress',
|
||||
model: modelName,
|
||||
output: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
id: responseId,
|
||||
object: 'response',
|
||||
created_at: createdAt,
|
||||
status: 'completed',
|
||||
model: modelName,
|
||||
output: [],
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function collectResponsesOutput(payloads: unknown[]): unknown[] {
|
||||
const outputByIndex = new Map<number, unknown>();
|
||||
let completedOutput: unknown[] | null = null;
|
||||
|
||||
for (const payload of payloads) {
|
||||
if (!isRecord(payload)) continue;
|
||||
const type = asTrimmedString(payload.type);
|
||||
if ((type === 'response.output_item.added' || type === 'response.output_item.done')
|
||||
&& Number.isInteger(payload.output_index)
|
||||
&& payload.item !== undefined) {
|
||||
outputByIndex.set(Number(payload.output_index), cloneJsonObject(payload.item));
|
||||
continue;
|
||||
}
|
||||
if (type === 'response.completed' && isRecord(payload.response) && Array.isArray(payload.response.output)) {
|
||||
completedOutput = cloneJsonObject(payload.response.output);
|
||||
}
|
||||
}
|
||||
|
||||
if (completedOutput) return completedOutput;
|
||||
return [...outputByIndex.entries()]
|
||||
.sort((left, right) => left[0] - right[0])
|
||||
.map(([, value]) => value);
|
||||
}
|
||||
|
||||
function buildInjectHeaders(request: IncomingMessage): Record<string, string | string[]> {
|
||||
const headers: Record<string, string | string[]> = {};
|
||||
for (const [rawKey, rawValue] of Object.entries(request.headers)) {
|
||||
const key = rawKey.toLowerCase();
|
||||
if (!rawValue) continue;
|
||||
if (
|
||||
key === 'host'
|
||||
|| key === 'connection'
|
||||
|| key === 'upgrade'
|
||||
|| key === 'sec-websocket-key'
|
||||
|| key === 'sec-websocket-version'
|
||||
|| key === 'sec-websocket-extensions'
|
||||
|| key === 'sec-websocket-protocol'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
headers[rawKey] = rawValue as string | string[];
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function handleResponsesWebsocketConnection(
|
||||
app: FastifyInstance,
|
||||
socket: WebSocket,
|
||||
request: IncomingMessage,
|
||||
) {
|
||||
let lastRequest: Record<string, unknown> | null = null;
|
||||
let lastResponseOutput: unknown[] = [];
|
||||
|
||||
socket.on('message', async (raw) => {
|
||||
const parsed = parseJsonObject(raw);
|
||||
if (!parsed) {
|
||||
writeResponsesWebsocketError(socket, 400, 'Invalid websocket JSON payload');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput);
|
||||
if (!normalized.ok) {
|
||||
writeResponsesWebsocketError(socket, normalized.status, normalized.message);
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequest = normalized.nextRequestSnapshot;
|
||||
if (parsed.generate === false && asTrimmedString(parsed.type) === 'response.create') {
|
||||
lastResponseOutput = [];
|
||||
for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) {
|
||||
socket.send(JSON.stringify(payload));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: buildInjectHeaders(request),
|
||||
payload: normalized.request,
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = JSON.parse(response.body);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
writeResponsesWebsocketError(
|
||||
socket,
|
||||
response.statusCode,
|
||||
response.statusMessage || 'Upstream error',
|
||||
payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = String(response.headers['content-type'] || '').toLowerCase();
|
||||
if (!contentType.includes('text/event-stream')) {
|
||||
try {
|
||||
const payload = JSON.parse(response.body);
|
||||
socket.send(JSON.stringify(payload));
|
||||
lastResponseOutput = isRecord(payload?.response) && Array.isArray(payload.response.output)
|
||||
? cloneJsonObject(payload.response.output)
|
||||
: [];
|
||||
} catch {
|
||||
writeResponsesWebsocketError(socket, 502, 'Unexpected non-JSON websocket proxy response');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pulled = openAiResponsesTransformer.pullSseEvents(response.body);
|
||||
const forwardedPayloads: unknown[] = [];
|
||||
for (const event of pulled.events) {
|
||||
if (event.data === '[DONE]') continue;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
forwardedPayloads.push(payload);
|
||||
socket.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
// Ignore malformed SSE frames; the HTTP route already normalizes them.
|
||||
}
|
||||
}
|
||||
lastResponseOutput = collectResponsesOutput(forwardedPayloads);
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureResponsesWebsocketTransport(app: FastifyInstance) {
|
||||
if (installedApps.has(app)) return;
|
||||
installedApps.add(app);
|
||||
|
||||
const websocketServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
app.server.on('upgrade', (request, socket, head) => {
|
||||
const url = new URL(request.url || '/', 'http://localhost');
|
||||
if (url.pathname !== '/v1/responses') return;
|
||||
websocketServer.handleUpgrade(request, socket, head, (client) => {
|
||||
void handleResponsesWebsocketConnection(app, client, request);
|
||||
});
|
||||
});
|
||||
|
||||
app.addHook('onClose', async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
websocketServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetch } from 'undici';
|
||||
import type { BuiltEndpointRequest } from './endpointFlow.js';
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
|
||||
describe('dispatchRuntimeRequest', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('routes antigravity runtime requests through daily then sandbox base urls and rewrites the payload fingerprint', async () => {
|
||||
const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js');
|
||||
const request: BuiltEndpointRequest = {
|
||||
endpoint: 'chat',
|
||||
path: '/v1internal:generateContent',
|
||||
headers: {
|
||||
Authorization: 'Bearer antigravity-token',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'google-api-nodejs-client/9.15.1',
|
||||
},
|
||||
body: {
|
||||
project: 'project-demo',
|
||||
model: 'gemini-3-pro-preview',
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello runtime executor' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
executor: 'antigravity',
|
||||
modelName: 'gemini-3-pro-preview',
|
||||
stream: false,
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
error: { message: 'try fallback base url' },
|
||||
}), {
|
||||
status: 429,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
response: { responseId: 'ok' },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>);
|
||||
|
||||
const response = await dispatchRuntimeRequest({
|
||||
siteUrl: 'https://cloudcode-pa.googleapis.com',
|
||||
request,
|
||||
buildInit: (_url, nextRequest) => ({
|
||||
method: 'POST',
|
||||
headers: nextRequest.headers,
|
||||
body: JSON.stringify(nextRequest.body),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent');
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent');
|
||||
|
||||
const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(firstInit.headers).toMatchObject({
|
||||
Authorization: 'Bearer antigravity-token',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
});
|
||||
|
||||
const upstreamBody = JSON.parse(String(firstInit.body));
|
||||
expect(upstreamBody).toMatchObject({
|
||||
project: 'project-demo',
|
||||
model: 'gemini-3-pro-preview',
|
||||
userAgent: 'antigravity',
|
||||
requestType: 'agent',
|
||||
request: {
|
||||
sessionId: expect.any(String),
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello runtime executor' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(upstreamBody.requestId).toMatch(/^agent-[0-9a-f-]{36}$/i);
|
||||
});
|
||||
|
||||
it('keeps gemini-cli countTokens payload lean while forcing a model-aware user agent', async () => {
|
||||
const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js');
|
||||
const request: BuiltEndpointRequest = {
|
||||
endpoint: 'chat',
|
||||
path: '/v1internal:countTokens',
|
||||
headers: {
|
||||
Authorization: 'Bearer gemini-cli-token',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)',
|
||||
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
|
||||
},
|
||||
body: {
|
||||
project: 'project-demo',
|
||||
model: 'gemini-2.5-pro',
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'count these tokens' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
executor: 'gemini-cli',
|
||||
modelName: 'gemini-2.5-pro',
|
||||
stream: false,
|
||||
action: 'countTokens',
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
totalTokens: 12,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>);
|
||||
|
||||
const response = await dispatchRuntimeRequest({
|
||||
siteUrl: 'https://cloudcode-pa.googleapis.com',
|
||||
request,
|
||||
buildInit: (_url, nextRequest) => ({
|
||||
method: 'POST',
|
||||
headers: nextRequest.headers,
|
||||
body: JSON.stringify(nextRequest.body),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Authorization: 'Bearer gemini-cli-token',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)',
|
||||
'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0',
|
||||
});
|
||||
expect(JSON.parse(String(requestInit.body))).toEqual({
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'count these tokens' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('retries antigravity runtime requests on transport errors before falling back to the next base url', async () => {
|
||||
const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js');
|
||||
const request: BuiltEndpointRequest = {
|
||||
endpoint: 'chat',
|
||||
path: '/v1internal:generateContent',
|
||||
headers: {
|
||||
Authorization: 'Bearer antigravity-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
project: 'project-demo',
|
||||
model: 'gemini-3-pro-preview',
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello runtime executor' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
executor: 'antigravity',
|
||||
modelName: 'gemini-3-pro-preview',
|
||||
stream: false,
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new Error('socket hang up'))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
response: { responseId: 'ok' },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>);
|
||||
|
||||
const response = await dispatchRuntimeRequest({
|
||||
siteUrl: 'https://cloudcode-pa.googleapis.com',
|
||||
request,
|
||||
buildInit: (_url, nextRequest) => ({
|
||||
method: 'POST',
|
||||
headers: nextRequest.headers,
|
||||
body: JSON.stringify(nextRequest.body),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent');
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { fetch, type RequestInit as UndiciRequestInit } from 'undici';
|
||||
import type { BuiltEndpointRequest } from './endpointFlow.js';
|
||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||
|
||||
const ANTIGRAVITY_RUNTIME_BASE_URLS = [
|
||||
'https://daily-cloudcode-pa.googleapis.com',
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
] as const;
|
||||
|
||||
type RuntimeDispatchInput = {
|
||||
siteUrl: string;
|
||||
request: BuiltEndpointRequest;
|
||||
targetUrl?: string;
|
||||
buildInit: (requestUrl: string, request: BuiltEndpointRequest) => Promise<UndiciRequestInit> | UndiciRequestInit;
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function withRequestBody(
|
||||
request: BuiltEndpointRequest,
|
||||
body: Record<string, unknown>,
|
||||
headers?: Record<string, string>,
|
||||
): BuiltEndpointRequest {
|
||||
return {
|
||||
...request,
|
||||
headers: headers ? { ...headers } : { ...request.headers },
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
async function performFetch(
|
||||
input: RuntimeDispatchInput,
|
||||
request: BuiltEndpointRequest,
|
||||
requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path),
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
const init = await input.buildInit(requestUrl, request);
|
||||
return fetch(requestUrl, init);
|
||||
}
|
||||
|
||||
async function materializeErrorResponse(
|
||||
response: Awaited<ReturnType<typeof fetch>>,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
if (response.ok) return response;
|
||||
const text = await response.text().catch(() => '');
|
||||
return new Response(text, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>;
|
||||
}
|
||||
|
||||
function parseGeminiCliPreviewFallbackOrder(modelName: string): string[] {
|
||||
switch (modelName.trim()) {
|
||||
case 'gemini-2.5-pro':
|
||||
return [];
|
||||
case 'gemini-2.5-flash':
|
||||
return [];
|
||||
case 'gemini-2.5-flash-lite':
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined {
|
||||
const raw = asTrimmedString(userAgent);
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `);
|
||||
}
|
||||
|
||||
function buildGeminiCliAttemptRequest(request: BuiltEndpointRequest, modelName: string): BuiltEndpointRequest {
|
||||
const body = structuredClone(request.body);
|
||||
const action = request.runtime?.action;
|
||||
if (action === 'countTokens') {
|
||||
delete body.model;
|
||||
delete body.project;
|
||||
} else {
|
||||
body.model = modelName;
|
||||
}
|
||||
const headers = { ...request.headers };
|
||||
const nextUserAgent = replaceGeminiCliModelInUserAgent(
|
||||
headers['User-Agent'] || headers['user-agent'],
|
||||
modelName,
|
||||
);
|
||||
if (nextUserAgent) {
|
||||
headers['User-Agent'] = nextUserAgent;
|
||||
delete headers['user-agent'];
|
||||
}
|
||||
return withRequestBody(request, body, headers);
|
||||
}
|
||||
|
||||
function antigravityRequestType(modelName: string): 'image_gen' | 'agent' {
|
||||
return modelName.includes('image') ? 'image_gen' : 'agent';
|
||||
}
|
||||
|
||||
function generateAntigravityProjectId(): string {
|
||||
const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold'];
|
||||
const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge'];
|
||||
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful';
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal';
|
||||
const suffix = Math.floor(100000 + Math.random() * 900000);
|
||||
return `${adjective}-${noun}-${suffix}`;
|
||||
}
|
||||
|
||||
function extractFirstUserText(value: unknown): string {
|
||||
if (!Array.isArray(value)) return '';
|
||||
for (const content of value) {
|
||||
if (!content || typeof content !== 'object' || Array.isArray(content)) continue;
|
||||
const record = content as Record<string, unknown>;
|
||||
if (asTrimmedString(record.role) !== 'user') continue;
|
||||
const parts = Array.isArray(record.parts) ? record.parts : [];
|
||||
for (const part of parts) {
|
||||
if (!part || typeof part !== 'object' || Array.isArray(part)) continue;
|
||||
const text = asTrimmedString((part as Record<string, unknown>).text);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function generateStableAntigravitySessionId(payload: Record<string, unknown>): string {
|
||||
const firstUserText = extractFirstUserText(
|
||||
payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request)
|
||||
? (payload.request as Record<string, unknown>).contents
|
||||
: undefined,
|
||||
);
|
||||
if (!firstUserText) {
|
||||
return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`;
|
||||
}
|
||||
const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16);
|
||||
const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff');
|
||||
return `-${bigint.toString()}`;
|
||||
}
|
||||
|
||||
function renameParametersJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => renameParametersJsonSchema(item));
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(input)) {
|
||||
const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key;
|
||||
output[nextKey] = renameParametersJsonSchema(entry);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function deleteNestedMaxOutputTokens(payload: Record<string, unknown>): void {
|
||||
const request = payload.request;
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) return;
|
||||
const generationConfig = (request as Record<string, unknown>).generationConfig;
|
||||
if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return;
|
||||
delete (generationConfig as Record<string, unknown>).maxOutputTokens;
|
||||
}
|
||||
|
||||
function buildAntigravityRuntimeBody(
|
||||
originalBody: Record<string, unknown>,
|
||||
modelName: string,
|
||||
action?: NonNullable<BuiltEndpointRequest['runtime']>['action'],
|
||||
): Record<string, unknown> {
|
||||
const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record<string, unknown>;
|
||||
if (action === 'countTokens') {
|
||||
return payload;
|
||||
}
|
||||
const requestType = antigravityRequestType(modelName);
|
||||
const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId();
|
||||
|
||||
payload.model = modelName;
|
||||
payload.project = projectId;
|
||||
payload.userAgent = 'antigravity';
|
||||
payload.requestType = requestType;
|
||||
payload.requestId = requestType === 'image_gen'
|
||||
? `image_gen/${Date.now()}/${randomUUID()}/12`
|
||||
: `agent-${randomUUID()}`;
|
||||
|
||||
const request = payload.request;
|
||||
if (request && typeof request === 'object' && !Array.isArray(request)) {
|
||||
delete (request as Record<string, unknown>).safetySettings;
|
||||
if (requestType !== 'image_gen') {
|
||||
(request as Record<string, unknown>).sessionId = generateStableAntigravitySessionId(payload);
|
||||
}
|
||||
if (modelName.includes('claude')) {
|
||||
const toolConfig = (
|
||||
(request as Record<string, unknown>).toolConfig
|
||||
&& typeof (request as Record<string, unknown>).toolConfig === 'object'
|
||||
&& !Array.isArray((request as Record<string, unknown>).toolConfig)
|
||||
)
|
||||
? (request as Record<string, unknown>).toolConfig as Record<string, unknown>
|
||||
: (((request as Record<string, unknown>).toolConfig = {}) as Record<string, unknown>);
|
||||
toolConfig.functionCallingConfig = { mode: 'VALIDATED' };
|
||||
} else {
|
||||
deleteNestedMaxOutputTokens(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function antigravityShouldRetryNoCapacity(
|
||||
status: number,
|
||||
responseText: string,
|
||||
): boolean {
|
||||
if (status !== 503) return false;
|
||||
return responseText.toLowerCase().includes('no capacity available');
|
||||
}
|
||||
|
||||
function antigravityNoCapacityRetryDelay(attempt: number): number {
|
||||
const delay = Math.min((attempt + 1) * 250, 2000);
|
||||
return delay;
|
||||
}
|
||||
|
||||
async function waitMs(ms: number): Promise<void> {
|
||||
if (ms <= 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function dispatchGeminiCliRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const models = [baseModel, ...parseGeminiCliPreviewFallbackOrder(baseModel)].filter(Boolean);
|
||||
let lastResponse: Awaited<ReturnType<typeof fetch>> | null = null;
|
||||
|
||||
for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) {
|
||||
const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName);
|
||||
const response = await performFetch(input, attemptRequest);
|
||||
if (response.ok) return response;
|
||||
|
||||
if (response.status === 429) {
|
||||
lastResponse = await materializeErrorResponse(response);
|
||||
continue;
|
||||
}
|
||||
|
||||
return materializeErrorResponse(response);
|
||||
}
|
||||
|
||||
return lastResponse || performFetch(input, input.request);
|
||||
}
|
||||
|
||||
async function dispatchCodexRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
return performFetch(input, input.request);
|
||||
}
|
||||
|
||||
async function dispatchClaudeRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
return performFetch(input, input.request);
|
||||
}
|
||||
|
||||
async function dispatchAntigravityRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model);
|
||||
const runtimeBody = buildAntigravityRuntimeBody(
|
||||
input.request.body,
|
||||
modelName,
|
||||
input.request.runtime?.action,
|
||||
);
|
||||
const baseAttempts = 3;
|
||||
let lastResponse: Awaited<ReturnType<typeof fetch>> | null = null;
|
||||
|
||||
attemptLoop:
|
||||
for (let attempt = 0; attempt < baseAttempts; attempt += 1) {
|
||||
for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) {
|
||||
const requestUrl = `${baseUrl}${input.request.path}`;
|
||||
const minimalHeaders: Record<string, string> = {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
};
|
||||
let response: Awaited<ReturnType<typeof fetch>>;
|
||||
try {
|
||||
response = await performFetch(
|
||||
input,
|
||||
withRequestBody(input.request, runtimeBody, minimalHeaders),
|
||||
requestUrl,
|
||||
);
|
||||
} catch (error) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
continue attemptLoop;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (response.ok) return response;
|
||||
|
||||
const errorResponse = await materializeErrorResponse(response);
|
||||
const errorText = await errorResponse.text().catch(() => '');
|
||||
lastResponse = new Response(errorText, {
|
||||
status: errorResponse.status,
|
||||
headers: errorResponse.headers,
|
||||
}) as unknown as Awaited<ReturnType<typeof fetch>>;
|
||||
|
||||
if (errorResponse.status === 429) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) {
|
||||
if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) {
|
||||
continue;
|
||||
}
|
||||
if (attempt + 1 < baseAttempts) {
|
||||
await waitMs(antigravityNoCapacityRetryDelay(attempt));
|
||||
continue attemptLoop;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse;
|
||||
}
|
||||
}
|
||||
|
||||
return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, {
|
||||
Authorization: input.request.headers.Authorization || input.request.headers.authorization || '',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': 'antigravity/1.19.6 darwin/arm64',
|
||||
}));
|
||||
}
|
||||
|
||||
export async function dispatchRuntimeRequest(
|
||||
input: RuntimeDispatchInput,
|
||||
): Promise<Awaited<ReturnType<typeof fetch>>> {
|
||||
const executor = input.request.runtime?.executor || 'default';
|
||||
if (executor === 'codex') {
|
||||
return dispatchCodexRuntimeRequest(input);
|
||||
}
|
||||
if (executor === 'claude') {
|
||||
return dispatchClaudeRuntimeRequest(input);
|
||||
}
|
||||
if (executor === 'gemini-cli') {
|
||||
return dispatchGeminiCliRuntimeRequest(input);
|
||||
}
|
||||
if (executor === 'antigravity') {
|
||||
return dispatchAntigravityRuntimeRequest(input);
|
||||
}
|
||||
return performFetch(input, input.request);
|
||||
}
|
||||
@@ -356,7 +356,14 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(request.headers.Originator).toBe('codex_cli_rs');
|
||||
expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123');
|
||||
expect(request.headers.Version).toBe('0.101.0');
|
||||
expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(request.headers.Conversation_id).toBe(request.headers.Session_id);
|
||||
expect(request.headers['User-Agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464');
|
||||
expect(request.headers.Accept).toBe('text/event-stream');
|
||||
expect(request.headers.Connection).toBe('Keep-Alive');
|
||||
expect(request.body.instructions).toBe('');
|
||||
expect(request.body.prompt_cache_key).toBe(request.headers.Session_id);
|
||||
expect(request.body.stream).toBe(true);
|
||||
expect(request.body.store).toBe(false);
|
||||
expect(request.body.parallel_tool_calls).toBe(true);
|
||||
@@ -368,6 +375,48 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.body.service_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reuses a stable codex session id when the same downstream continuity key is provided', () => {
|
||||
const firstRequest = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-5.4',
|
||||
stream: false,
|
||||
tokenValue: 'oauth-access-token',
|
||||
sitePlatform: 'codex',
|
||||
siteUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
openaiBody: {
|
||||
model: 'gpt-5.4',
|
||||
messages: [{ role: 'user', content: 'hello codex' }],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
providerHeaders: {
|
||||
Originator: 'codex_cli_rs',
|
||||
},
|
||||
codexSessionCacheKey: 'gpt-5.4:user-123',
|
||||
} as any);
|
||||
|
||||
const secondRequest = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
modelName: 'gpt-5.4',
|
||||
stream: false,
|
||||
tokenValue: 'oauth-access-token',
|
||||
sitePlatform: 'codex',
|
||||
siteUrl: 'https://chatgpt.com/backend-api/codex',
|
||||
openaiBody: {
|
||||
model: 'gpt-5.4',
|
||||
messages: [{ role: 'user', content: 'hello again codex' }],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
providerHeaders: {
|
||||
Originator: 'codex_cli_rs',
|
||||
},
|
||||
codexSessionCacheKey: 'gpt-5.4:user-123',
|
||||
} as any);
|
||||
|
||||
expect(firstRequest.headers.Session_id).toBe(secondRequest.headers.Session_id);
|
||||
expect(firstRequest.headers.Conversation_id).toBe(secondRequest.headers.Conversation_id);
|
||||
expect(firstRequest.body.prompt_cache_key).toBe(secondRequest.body.prompt_cache_key);
|
||||
});
|
||||
|
||||
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'responses',
|
||||
@@ -395,7 +444,7 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
|
||||
expect(request.path).toBe('/v1internal:streamGenerateContent?alt=sse');
|
||||
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(request.headers['User-Agent']).toContain('GeminiCLI/');
|
||||
expect(request.headers['User-Agent']).toBe('GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)');
|
||||
expect(request.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/');
|
||||
expect(request.body.project).toBe('project-demo');
|
||||
expect(request.body.model).toBe('gemini-2.5-pro');
|
||||
@@ -441,8 +490,9 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
|
||||
expect(request.path).toBe('/v1internal:generateContent');
|
||||
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(request.headers['User-Agent']).toContain('google-api-nodejs-client');
|
||||
expect(request.headers['X-Goog-Api-Client']).toContain('google-cloud-sdk');
|
||||
expect(request.headers['User-Agent']).toBe('antigravity/1.19.6 darwin/arm64');
|
||||
expect(request.headers['X-Goog-Api-Client']).toBeUndefined();
|
||||
expect(request.headers['Client-Metadata']).toBeUndefined();
|
||||
expect(request.body).toEqual({
|
||||
project: 'project-demo',
|
||||
model: 'gemini-3-pro-preview',
|
||||
@@ -461,7 +511,43 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uses bearer auth for claude oauth upstream requests', () => {
|
||||
it('uses claude-code runtime headers for claude oauth upstream requests', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-opus-4-6',
|
||||
stream: true,
|
||||
tokenValue: 'oauth-access-token',
|
||||
oauthProvider: 'claude',
|
||||
sitePlatform: 'claude',
|
||||
siteUrl: 'https://api.anthropic.com',
|
||||
openaiBody: {
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [{ role: 'user', content: 'hello claude oauth' }],
|
||||
},
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(request.headers['x-api-key']).toBeUndefined();
|
||||
expect(request.headers['anthropic-version']).toBe('2023-06-01');
|
||||
expect(request.headers['Anthropic-Dangerous-Direct-Browser-Access']).toBe('true');
|
||||
expect(request.headers['X-App']).toBe('cli');
|
||||
expect(request.headers['X-Stainless-Retry-Count']).toBe('0');
|
||||
expect(request.headers['X-Stainless-Runtime-Version']).toBe('v24.3.0');
|
||||
expect(request.headers['X-Stainless-Package-Version']).toBe('0.74.0');
|
||||
expect(request.headers['X-Stainless-Runtime']).toBe('node');
|
||||
expect(request.headers['X-Stainless-Lang']).toBe('js');
|
||||
expect(request.headers['X-Stainless-Arch']).toBe('x64');
|
||||
expect(request.headers['X-Stainless-Os']).toBe('Windows');
|
||||
expect(request.headers['X-Stainless-Timeout']).toBe('600');
|
||||
expect(request.headers['User-Agent']).toBe('claude-cli/2.1.63 (external, cli)');
|
||||
expect(request.headers.Connection).toBe('keep-alive');
|
||||
expect(request.headers.Accept).toBe('text/event-stream');
|
||||
expect(request.headers['Accept-Encoding']).toBe('identity');
|
||||
});
|
||||
|
||||
it('uses claude-code beta headers and compressed non-stream responses for claude upstream requests', () => {
|
||||
const request = buildUpstreamEndpointRequest({
|
||||
endpoint: 'messages',
|
||||
modelName: 'claude-opus-4-6',
|
||||
@@ -477,9 +563,11 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
downstreamFormat: 'openai',
|
||||
});
|
||||
|
||||
expect(request.path).toBe('/v1/messages');
|
||||
expect(request.headers.Authorization).toBe('Bearer oauth-access-token');
|
||||
expect(request.headers['x-api-key']).toBeUndefined();
|
||||
expect(request.headers['anthropic-beta']).toContain('claude-code-20250219');
|
||||
expect(request.headers['anthropic-beta']).toContain('oauth-2025-04-20');
|
||||
expect(request.headers['anthropic-beta']).toContain('context-management-2025-06-27');
|
||||
expect(request.headers.Accept).toBe('application/json');
|
||||
expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
|
||||
});
|
||||
|
||||
it('converts system roles to developer in native codex responses bodies', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||
import {
|
||||
@@ -98,6 +99,12 @@ const BLOCKED_PASSTHROUGH_HEADERS = new Set([
|
||||
'sec-websocket-extensions',
|
||||
]);
|
||||
|
||||
const CODEX_CLIENT_VERSION = '0.101.0';
|
||||
const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464';
|
||||
const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64';
|
||||
const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)';
|
||||
const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05';
|
||||
|
||||
function shouldSkipPassthroughHeader(key: string): boolean {
|
||||
return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key);
|
||||
}
|
||||
@@ -168,6 +175,221 @@ function extractResponsesPassthroughHeaders(
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
function getInputHeader(
|
||||
headers: Record<string, unknown> | Record<string, string> | undefined,
|
||||
key: string,
|
||||
): string | null {
|
||||
if (!headers) return null;
|
||||
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
|
||||
if (candidateKey.toLowerCase() !== key.toLowerCase()) continue;
|
||||
return headerValueToString(candidateValue);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseGeminiCliUserAgentRuntime(userAgent: string | null): {
|
||||
version: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
} | null {
|
||||
if (!userAgent) return null;
|
||||
const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim());
|
||||
if (!match) return null;
|
||||
return {
|
||||
version: match[1] || '0.31.0',
|
||||
platform: match[2] || 'win32',
|
||||
arch: match[3] || 'x64',
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeminiCLIUserAgent(modelName: string, existingUserAgent?: string | null): string {
|
||||
const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null);
|
||||
const version = parsed?.version || '0.31.0';
|
||||
const platform = parsed?.platform || 'win32';
|
||||
const arch = parsed?.arch || 'x64';
|
||||
const effectiveModel = asTrimmedString(modelName) || 'unknown';
|
||||
return `GeminiCLI/${version}/${effectiveModel} (${platform}; ${arch})`;
|
||||
}
|
||||
|
||||
function uuidFromSeed(seed: string): string {
|
||||
const hash = createHash('sha1').update(seed).digest();
|
||||
const bytes = new Uint8Array(hash.subarray(0, 16));
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x50;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20, 32),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
function mergeClaudeBetaHeader(
|
||||
explicitValue: string | null,
|
||||
extraBetas: string[] = [],
|
||||
): string {
|
||||
const source = explicitValue || CLAUDE_DEFAULT_BETA_HEADER;
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const entry of source.split(',')) {
|
||||
const normalized = entry.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
merged.push(normalized);
|
||||
}
|
||||
if (!explicitValue) {
|
||||
for (const entry of extraBetas) {
|
||||
const normalized = entry.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
merged.push(normalized);
|
||||
}
|
||||
}
|
||||
return merged.join(',');
|
||||
}
|
||||
|
||||
function extractClaudeBetasFromBody(body: Record<string, unknown>): {
|
||||
body: Record<string, unknown>;
|
||||
betas: string[];
|
||||
} {
|
||||
const next = { ...body };
|
||||
const rawBetas = next.betas;
|
||||
delete next.betas;
|
||||
|
||||
if (typeof rawBetas === 'string') {
|
||||
return {
|
||||
body: next,
|
||||
betas: rawBetas.split(',').map((entry) => entry.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(rawBetas)) {
|
||||
return {
|
||||
body: next,
|
||||
betas: rawBetas
|
||||
.map((entry) => asTrimmedString(entry))
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
body: next,
|
||||
betas: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
continuityKey?: string | null;
|
||||
}): Record<string, string> {
|
||||
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
|
||||
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
|
||||
const sessionId = (
|
||||
getInputHeader(input.baseHeaders, 'session_id')
|
||||
|| getInputHeader(input.baseHeaders, 'session-id')
|
||||
|| (input.continuityKey ? uuidFromSeed(`metapi:codex:${input.continuityKey}`) : null)
|
||||
|| randomUUID()
|
||||
);
|
||||
|
||||
return {
|
||||
...input.baseHeaders,
|
||||
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
|
||||
Originator: originator,
|
||||
Version: CODEX_CLIENT_VERSION,
|
||||
Session_id: sessionId,
|
||||
Conversation_id: sessionId,
|
||||
'User-Agent': CODEX_DEFAULT_USER_AGENT,
|
||||
Accept: 'text/event-stream',
|
||||
Connection: 'Keep-Alive',
|
||||
};
|
||||
}
|
||||
|
||||
function buildGeminiCliRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
modelName: string;
|
||||
stream: boolean;
|
||||
}): Record<string, string> {
|
||||
const apiClient = (
|
||||
getInputHeader(input.providerHeaders, 'x-goog-api-client')
|
||||
|| getInputHeader(input.baseHeaders, 'x-goog-api-client')
|
||||
);
|
||||
const userAgent = buildGeminiCLIUserAgent(
|
||||
input.modelName,
|
||||
getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'),
|
||||
);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: input.baseHeaders.Authorization,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
};
|
||||
if (apiClient) {
|
||||
headers['X-Goog-Api-Client'] = apiClient;
|
||||
}
|
||||
if (input.stream) {
|
||||
headers.Accept = 'text/event-stream';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildAntigravityRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
stream: boolean;
|
||||
}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: input.baseHeaders.Authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: input.stream ? 'text/event-stream' : 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT,
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildClaudeRuntimeHeaders(input: {
|
||||
baseHeaders: Record<string, string>;
|
||||
claudeHeaders: Record<string, string>;
|
||||
anthropicVersion: string;
|
||||
stream: boolean;
|
||||
isClaudeOauthUpstream: boolean;
|
||||
tokenValue: string;
|
||||
extraBetas?: string[];
|
||||
}): Record<string, string> {
|
||||
const anthropicBeta = mergeClaudeBetaHeader(
|
||||
getInputHeader(input.claudeHeaders, 'anthropic-beta'),
|
||||
input.extraBetas,
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
...input.baseHeaders,
|
||||
...input.claudeHeaders,
|
||||
'anthropic-version': input.anthropicVersion,
|
||||
...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}),
|
||||
'Anthropic-Dangerous-Direct-Browser-Access': 'true',
|
||||
'X-App': 'cli',
|
||||
'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0',
|
||||
'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0',
|
||||
'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0',
|
||||
'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node',
|
||||
'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js',
|
||||
'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64',
|
||||
'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows',
|
||||
'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600',
|
||||
'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT,
|
||||
Connection: 'keep-alive',
|
||||
Accept: input.stream ? 'text/event-stream' : 'application/json',
|
||||
'Accept-Encoding': input.stream ? 'identity' : 'gzip, deflate, br, zstd',
|
||||
};
|
||||
if (input.isClaudeOauthUpstream) {
|
||||
headers.Authorization = `Bearer ${input.tokenValue}`;
|
||||
} else {
|
||||
headers['x-api-key'] = input.tokenValue;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function ensureStreamAcceptHeader(
|
||||
headers: Record<string, string>,
|
||||
stream: boolean,
|
||||
@@ -530,10 +752,18 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
responsesOriginalBody?: Record<string, unknown>;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
providerHeaders?: Record<string, string>;
|
||||
codexSessionCacheKey?: string | null;
|
||||
}): {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime?: {
|
||||
executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude';
|
||||
modelName?: string;
|
||||
stream?: boolean;
|
||||
oauthProjectId?: string | null;
|
||||
action?: 'generateContent' | 'streamGenerateContent' | 'countTokens';
|
||||
};
|
||||
} {
|
||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||
const isClaudeUpstream = sitePlatform === 'claude';
|
||||
@@ -614,6 +844,22 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
};
|
||||
|
||||
const openaiBody = stripGeminiUnsupportedFields(input.openaiBody);
|
||||
const runtime = {
|
||||
executor: (
|
||||
sitePlatform === 'codex'
|
||||
? 'codex'
|
||||
: sitePlatform === 'gemini-cli'
|
||||
? 'gemini-cli'
|
||||
: sitePlatform === 'antigravity'
|
||||
? 'antigravity'
|
||||
: sitePlatform === 'claude'
|
||||
? 'claude'
|
||||
: 'default'
|
||||
) as 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude',
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
|
||||
};
|
||||
|
||||
if (isInternalGeminiUpstream) {
|
||||
const projectId = asTrimmedString(input.oauthProjectId);
|
||||
@@ -631,7 +877,17 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
modelName: input.modelName,
|
||||
instructions,
|
||||
});
|
||||
const headers = ensureStreamAcceptHeader(commonHeaders, input.stream);
|
||||
const headers = isGeminiCliUpstream
|
||||
? buildGeminiCliRuntimeHeaders({
|
||||
baseHeaders: commonHeaders,
|
||||
providerHeaders: input.providerHeaders,
|
||||
modelName: input.modelName,
|
||||
stream: input.stream,
|
||||
})
|
||||
: buildAntigravityRuntimeHeaders({
|
||||
baseHeaders: commonHeaders,
|
||||
stream: input.stream,
|
||||
});
|
||||
return {
|
||||
path: resolveEndpointPath(input.endpoint),
|
||||
headers,
|
||||
@@ -640,6 +896,7 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
projectId: projectId || '',
|
||||
request: geminiRequest,
|
||||
}) as Record<string, unknown>,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -680,19 +937,20 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
|
||||
);
|
||||
|
||||
const headers = ensureStreamAcceptHeader({
|
||||
...commonHeaders,
|
||||
...claudeHeaders,
|
||||
'anthropic-version': anthropicVersion,
|
||||
...(isClaudeOauthUpstream
|
||||
? { Authorization: `Bearer ${input.tokenValue}` }
|
||||
: { 'x-api-key': input.tokenValue }),
|
||||
}, input.stream);
|
||||
const headers = buildClaudeRuntimeHeaders({
|
||||
baseHeaders: commonHeaders,
|
||||
claudeHeaders,
|
||||
anthropicVersion,
|
||||
stream: input.stream,
|
||||
isClaudeOauthUpstream,
|
||||
tokenValue: input.tokenValue,
|
||||
});
|
||||
|
||||
return {
|
||||
path: resolveEndpointPath('messages'),
|
||||
headers,
|
||||
body: sanitizedBody,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -720,15 +978,34 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
sitePlatform,
|
||||
);
|
||||
|
||||
const headers = ensureStreamAcceptHeader({
|
||||
...commonHeaders,
|
||||
...responsesHeaders,
|
||||
}, input.stream);
|
||||
const headers = sitePlatform === 'codex'
|
||||
? buildCodexRuntimeHeaders({
|
||||
baseHeaders: {
|
||||
...commonHeaders,
|
||||
...responsesHeaders,
|
||||
},
|
||||
providerHeaders: input.providerHeaders,
|
||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
|
||||
})
|
||||
: ensureStreamAcceptHeader({
|
||||
...commonHeaders,
|
||||
...responsesHeaders,
|
||||
}, input.stream);
|
||||
const codexSessionId = sitePlatform === 'codex'
|
||||
? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id'))
|
||||
: null;
|
||||
const runtimeBody = sitePlatform === 'codex' && codexSessionId
|
||||
? {
|
||||
...body,
|
||||
prompt_cache_key: codexSessionId,
|
||||
}
|
||||
: body;
|
||||
|
||||
return {
|
||||
path: resolveEndpointPath('responses'),
|
||||
headers,
|
||||
body,
|
||||
body: runtimeBody,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -741,6 +1018,63 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
model: input.modelName,
|
||||
stream: input.stream,
|
||||
},
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildClaudeCountTokensUpstreamRequest(input: {
|
||||
modelName: string;
|
||||
tokenValue: string;
|
||||
oauthProvider?: string;
|
||||
sitePlatform?: string;
|
||||
claudeBody: Record<string, unknown>;
|
||||
downstreamHeaders?: Record<string, unknown>;
|
||||
}): {
|
||||
path: string;
|
||||
headers: Record<string, string>;
|
||||
body: Record<string, unknown>;
|
||||
runtime: {
|
||||
executor: 'claude';
|
||||
modelName: string;
|
||||
stream: false;
|
||||
action: 'countTokens';
|
||||
};
|
||||
} {
|
||||
const sitePlatform = normalizePlatformName(input.sitePlatform);
|
||||
const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude';
|
||||
const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders);
|
||||
const anthropicVersion = claudeHeaders['anthropic-version'] || '2023-06-01';
|
||||
const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({
|
||||
...input.claudeBody,
|
||||
model: input.modelName,
|
||||
});
|
||||
const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas);
|
||||
delete sanitizedBody.max_tokens;
|
||||
delete sanitizedBody.maxTokens;
|
||||
delete sanitizedBody.stream;
|
||||
|
||||
const headers = buildClaudeRuntimeHeaders({
|
||||
baseHeaders: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
claudeHeaders,
|
||||
anthropicVersion,
|
||||
stream: false,
|
||||
isClaudeOauthUpstream,
|
||||
tokenValue: input.tokenValue,
|
||||
extraBetas: betas,
|
||||
});
|
||||
|
||||
return {
|
||||
path: '/v1/messages/count_tokens?beta=true',
|
||||
headers,
|
||||
body: sanitizedBody,
|
||||
runtime: {
|
||||
executor: 'claude',
|
||||
modelName: input.modelName,
|
||||
stream: false,
|
||||
action: 'countTokens',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildQuotaSnapshotFromOauthInfo,
|
||||
parseCodexQuotaResetHint,
|
||||
} from './quota.js';
|
||||
|
||||
function buildJwt(payload: Record<string, unknown>) {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value))
|
||||
.toString('base64url');
|
||||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
|
||||
}
|
||||
|
||||
describe('oauth quota snapshot helpers', () => {
|
||||
it('builds a codex quota snapshot from stored id_token claims and reset hint', () => {
|
||||
const snapshot = buildQuotaSnapshotFromOauthInfo({
|
||||
provider: 'codex',
|
||||
planType: 'plus',
|
||||
idToken: buildJwt({
|
||||
email: 'codex-user@example.com',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'chatgpt-account-123',
|
||||
chatgpt_plan_type: 'plus',
|
||||
chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z',
|
||||
chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
quota: {
|
||||
status: 'supported',
|
||||
source: 'reverse_engineered',
|
||||
lastSyncAt: '2026-03-18T01:00:00.000Z',
|
||||
lastLimitResetAt: '2026-03-18T05:00:00.000Z',
|
||||
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
|
||||
windows: {
|
||||
fiveHour: {
|
||||
supported: false,
|
||||
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
sevenDay: {
|
||||
supported: false,
|
||||
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
status: 'supported',
|
||||
source: 'reverse_engineered',
|
||||
lastSyncAt: '2026-03-18T01:00:00.000Z',
|
||||
lastLimitResetAt: '2026-03-18T05:00:00.000Z',
|
||||
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
|
||||
subscription: {
|
||||
planType: 'plus',
|
||||
activeStart: '2026-03-01T00:00:00.000Z',
|
||||
activeUntil: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
windows: {
|
||||
fiveHour: {
|
||||
supported: false,
|
||||
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
sevenDay: {
|
||||
supported: false,
|
||||
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns unsupported snapshots for non-codex providers', () => {
|
||||
const snapshot = buildQuotaSnapshotFromOauthInfo({
|
||||
provider: 'antigravity',
|
||||
planType: 'pro',
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
status: 'unsupported',
|
||||
source: 'official',
|
||||
providerMessage: 'current local CLIProxyAPI evidence does not expose official quota windows for antigravity oauth',
|
||||
windows: {
|
||||
fiveHour: {
|
||||
supported: false,
|
||||
message: 'official 5h quota window is unavailable for this provider',
|
||||
},
|
||||
sevenDay: {
|
||||
supported: false,
|
||||
message: 'official 7d quota window is unavailable for this provider',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses codex usage_limit_reached reset hints', () => {
|
||||
const reset = parseCodexQuotaResetHint(429, JSON.stringify({
|
||||
error: {
|
||||
type: 'usage_limit_reached',
|
||||
resets_at: 1773800400,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(reset).toEqual({
|
||||
resetAt: '2026-03-18T02:20:00.000Z',
|
||||
message: 'codex usage_limit_reached reset hint observed from upstream',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { mergeAccountExtraConfig } from '../accountExtraConfig.js';
|
||||
import { buildOauthInfo, getOauthInfoFromExtraConfig, type OauthInfo } from './oauthAccount.js';
|
||||
import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js';
|
||||
|
||||
type CodexJwtClaims = {
|
||||
'https://api.openai.com/auth'?: {
|
||||
chatgpt_plan_type?: unknown;
|
||||
chatgpt_subscription_active_start?: unknown;
|
||||
chatgpt_subscription_active_until?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function asIsoDateTime(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString();
|
||||
}
|
||||
|
||||
function parseCodexJwtClaims(idToken?: string): CodexJwtClaims | null {
|
||||
if (!idToken) return null;
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown;
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
||||
return payload as CodexJwtClaims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildUnsupportedWindow(message: string): OauthQuotaWindowSnapshot {
|
||||
return { supported: false, message };
|
||||
}
|
||||
|
||||
function buildCodexUnsupportedWindows(): OauthQuotaSnapshot['windows'] {
|
||||
return {
|
||||
fiveHour: buildUnsupportedWindow('official 5h quota window is not exposed by current codex oauth artifacts'),
|
||||
sevenDay: buildUnsupportedWindow('official 7d quota window is not exposed by current codex oauth artifacts'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProviderUnsupportedSnapshot(provider: string): OauthQuotaSnapshot {
|
||||
return {
|
||||
status: 'unsupported',
|
||||
source: 'official',
|
||||
providerMessage: `current local CLIProxyAPI evidence does not expose official quota windows for ${provider} oauth`,
|
||||
windows: {
|
||||
fiveHour: buildUnsupportedWindow('official 5h quota window is unavailable for this provider'),
|
||||
sevenDay: buildUnsupportedWindow('official 7d quota window is unavailable for this provider'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoredWindow(value: unknown): OauthQuotaWindowSnapshot | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const supported = typeof raw.supported === 'boolean' ? raw.supported : undefined;
|
||||
if (supported === undefined) return undefined;
|
||||
const pickNumber = (field: string) => {
|
||||
const item = raw[field];
|
||||
return typeof item === 'number' && Number.isFinite(item) ? item : undefined;
|
||||
};
|
||||
const normalized: OauthQuotaWindowSnapshot = {
|
||||
supported,
|
||||
};
|
||||
const limit = pickNumber('limit');
|
||||
const used = pickNumber('used');
|
||||
const remaining = pickNumber('remaining');
|
||||
const resetAt = asIsoDateTime(raw.resetAt);
|
||||
const message = asTrimmedString(raw.message);
|
||||
if (limit !== undefined) normalized.limit = limit;
|
||||
if (used !== undefined) normalized.used = used;
|
||||
if (remaining !== undefined) normalized.remaining = remaining;
|
||||
if (resetAt) normalized.resetAt = resetAt;
|
||||
if (message) normalized.message = message;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeStoredQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const status = raw.status === 'supported' || raw.status === 'unsupported' || raw.status === 'error'
|
||||
? raw.status
|
||||
: undefined;
|
||||
const source = raw.source === 'official' || raw.source === 'reverse_engineered'
|
||||
? raw.source
|
||||
: undefined;
|
||||
const windowsRaw = raw.windows;
|
||||
if (!status || !source || !windowsRaw || typeof windowsRaw !== 'object' || Array.isArray(windowsRaw)) {
|
||||
return undefined;
|
||||
}
|
||||
const windowsObject = windowsRaw as Record<string, unknown>;
|
||||
const fiveHour = normalizeStoredWindow(windowsObject.fiveHour);
|
||||
const sevenDay = normalizeStoredWindow(windowsObject.sevenDay);
|
||||
if (!fiveHour || !sevenDay) return undefined;
|
||||
|
||||
const subscriptionRaw = raw.subscription;
|
||||
const subscription = subscriptionRaw && typeof subscriptionRaw === 'object' && !Array.isArray(subscriptionRaw)
|
||||
? {
|
||||
planType: asTrimmedString((subscriptionRaw as Record<string, unknown>).planType),
|
||||
activeStart: asIsoDateTime((subscriptionRaw as Record<string, unknown>).activeStart),
|
||||
activeUntil: asIsoDateTime((subscriptionRaw as Record<string, unknown>).activeUntil),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
status,
|
||||
source,
|
||||
...(asIsoDateTime(raw.lastSyncAt) ? { lastSyncAt: asIsoDateTime(raw.lastSyncAt)! } : {}),
|
||||
...(asTrimmedString(raw.lastError) ? { lastError: asTrimmedString(raw.lastError)! } : {}),
|
||||
...(asTrimmedString(raw.providerMessage) ? { providerMessage: asTrimmedString(raw.providerMessage)! } : {}),
|
||||
...(subscription && (subscription.planType || subscription.activeStart || subscription.activeUntil)
|
||||
? { subscription }
|
||||
: {}),
|
||||
windows: { fiveHour, sevenDay },
|
||||
...(asIsoDateTime(raw.lastLimitResetAt) ? { lastLimitResetAt: asIsoDateTime(raw.lastLimitResetAt)! } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStoredCodexSnapshot(oauth: Pick<OauthInfo, 'planType' | 'idToken' | 'quota'>): OauthQuotaSnapshot {
|
||||
const claims = parseCodexJwtClaims(oauth.idToken);
|
||||
const authClaims = claims?.['https://api.openai.com/auth'];
|
||||
const storedQuota = normalizeStoredQuotaSnapshot(oauth.quota);
|
||||
const subscription = {
|
||||
planType: asTrimmedString(authClaims?.chatgpt_plan_type) || oauth.planType,
|
||||
activeStart: asIsoDateTime(authClaims?.chatgpt_subscription_active_start),
|
||||
activeUntil: asIsoDateTime(authClaims?.chatgpt_subscription_active_until),
|
||||
};
|
||||
|
||||
return {
|
||||
status: storedQuota?.status || 'supported',
|
||||
source: storedQuota?.source || 'reverse_engineered',
|
||||
...(storedQuota?.lastSyncAt ? { lastSyncAt: storedQuota.lastSyncAt } : {}),
|
||||
...(storedQuota?.lastError ? { lastError: storedQuota.lastError } : {}),
|
||||
providerMessage: storedQuota?.providerMessage || 'current codex oauth signals do not expose stable 5h/7d remaining values',
|
||||
...((subscription.planType || subscription.activeStart || subscription.activeUntil) ? { subscription } : {}),
|
||||
windows: storedQuota?.windows || buildCodexUnsupportedWindows(),
|
||||
...(storedQuota?.lastLimitResetAt ? { lastLimitResetAt: storedQuota.lastLimitResetAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQuotaSnapshotFromOauthInfo(oauth: Pick<OauthInfo, 'provider' | 'planType' | 'idToken' | 'quota'>): OauthQuotaSnapshot {
|
||||
if (oauth.provider === 'codex') {
|
||||
return buildStoredCodexSnapshot(oauth);
|
||||
}
|
||||
return buildProviderUnsupportedSnapshot(oauth.provider);
|
||||
}
|
||||
|
||||
export function parseCodexQuotaResetHint(
|
||||
statusCode: number,
|
||||
errorBody: string | null | undefined,
|
||||
nowMs = Date.now(),
|
||||
): { resetAt: string; message: string } | null {
|
||||
if (statusCode !== 429 || !errorBody) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(errorBody) as Record<string, any>;
|
||||
const error = parsed?.error;
|
||||
if (!error || typeof error !== 'object' || error.type !== 'usage_limit_reached') {
|
||||
return null;
|
||||
}
|
||||
if (typeof error.resets_at === 'number' && Number.isFinite(error.resets_at) && error.resets_at > 0) {
|
||||
return {
|
||||
resetAt: new Date(error.resets_at * 1000).toISOString(),
|
||||
message: 'codex usage_limit_reached reset hint observed from upstream',
|
||||
};
|
||||
}
|
||||
if (typeof error.resets_in_seconds === 'number' && Number.isFinite(error.resets_in_seconds) && error.resets_in_seconds > 0) {
|
||||
return {
|
||||
resetAt: new Date(nowMs + error.resets_in_seconds * 1000).toISOString(),
|
||||
message: 'codex usage_limit_reached reset hint observed from upstream',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnapshot) {
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
oauth: buildOauthInfo(account.extraConfig, {
|
||||
quota: snapshot,
|
||||
}),
|
||||
});
|
||||
await db.update(schema.accounts).set({
|
||||
extraConfig: nextExtraConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, accountId)).run();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function refreshOauthQuotaSnapshot(accountId: number): Promise<OauthQuotaSnapshot> {
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
|
||||
if (!account) {
|
||||
throw new Error('oauth account not found');
|
||||
}
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth) {
|
||||
throw new Error('account is not managed by oauth');
|
||||
}
|
||||
const baseSnapshot = buildQuotaSnapshotFromOauthInfo(oauth);
|
||||
const snapshot: OauthQuotaSnapshot = {
|
||||
...baseSnapshot,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
...(baseSnapshot.status === 'error' ? {} : { lastError: undefined }),
|
||||
};
|
||||
return persistQuotaSnapshot(accountId, snapshot);
|
||||
}
|
||||
|
||||
export async function recordOauthQuotaResetHint(input: {
|
||||
accountId: number;
|
||||
statusCode: number;
|
||||
errorText?: string | null;
|
||||
}): Promise<OauthQuotaSnapshot | null> {
|
||||
const resetHint = parseCodexQuotaResetHint(input.statusCode, input.errorText);
|
||||
if (!resetHint) return null;
|
||||
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get();
|
||||
if (!account) return null;
|
||||
const oauth = getOauthInfoFromExtraConfig(account.extraConfig);
|
||||
if (!oauth || oauth.provider !== 'codex') return null;
|
||||
|
||||
const baseSnapshot = buildQuotaSnapshotFromOauthInfo({
|
||||
...oauth,
|
||||
quota: {
|
||||
...normalizeStoredQuotaSnapshot(oauth.quota),
|
||||
status: 'supported',
|
||||
source: 'reverse_engineered',
|
||||
lastLimitResetAt: resetHint.resetAt,
|
||||
providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values',
|
||||
windows: normalizeStoredQuotaSnapshot(oauth.quota)?.windows || buildCodexUnsupportedWindows(),
|
||||
},
|
||||
});
|
||||
|
||||
return persistQuotaSnapshot(input.accountId, {
|
||||
...baseSnapshot,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
lastLimitResetAt: resetHint.resetAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type OauthQuotaWindowSnapshot = {
|
||||
supported: boolean;
|
||||
limit?: number | null;
|
||||
used?: number | null;
|
||||
remaining?: number | null;
|
||||
resetAt?: string | null;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type OauthQuotaSnapshot = {
|
||||
status: 'supported' | 'unsupported' | 'error';
|
||||
source: 'official' | 'reverse_engineered';
|
||||
lastSyncAt?: string;
|
||||
lastError?: string;
|
||||
providerMessage?: string;
|
||||
subscription?: {
|
||||
planType?: string;
|
||||
activeStart?: string;
|
||||
activeUntil?: string;
|
||||
};
|
||||
windows: {
|
||||
fiveHour: OauthQuotaWindowSnapshot;
|
||||
sevenDay: OauthQuotaWindowSnapshot;
|
||||
};
|
||||
lastLimitResetAt?: string;
|
||||
};
|
||||
@@ -524,6 +524,44 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves tool_reference blocks inside tool_result content arrays', () => {
|
||||
const result = sanitizeAnthropicMessagesBody({
|
||||
model: 'claude-opus-4-6',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool_1',
|
||||
content: [
|
||||
{ type: 'tool_reference', tool_name: 'lookup' },
|
||||
{ type: 'text', text: 'follow-up' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool_1',
|
||||
content: [
|
||||
{ type: 'tool_reference', tool_name: 'lookup' },
|
||||
{ type: 'text', text: 'follow-up' },
|
||||
],
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds a second message cache anchor around the 20-block window for long prompts', () => {
|
||||
const longContent = Array.from({ length: 25 }, (_, index) => ({
|
||||
type: 'text',
|
||||
|
||||
@@ -187,12 +187,39 @@ function normalizeTextCandidate(value: unknown): string {
|
||||
return asTrimmedString(value.text ?? value.content ?? value.output_text);
|
||||
}
|
||||
|
||||
function normalizeToolMessageContent(raw: unknown): string {
|
||||
function sanitizeToolReferenceBlock(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const toolName = asTrimmedString(item.tool_name ?? item.toolName ?? item.name);
|
||||
if (!toolName) return null;
|
||||
const next: Record<string, unknown> = {
|
||||
...item,
|
||||
type: 'tool_reference',
|
||||
tool_name: toolName,
|
||||
};
|
||||
delete next.toolName;
|
||||
if ('name' in next) delete next.name;
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeToolMessageContent(raw: unknown): string | Array<Record<string, unknown>> {
|
||||
if (raw === undefined || raw === null) return '';
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw);
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
const structuredBlocks = raw
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return toAnthropicTextBlock(item);
|
||||
if (!isRecord(item)) return null;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'tool_reference') return sanitizeToolReferenceBlock(item);
|
||||
if (type === 'text' || type === 'input_text' || type === 'output_text') {
|
||||
return toAnthropicTextBlock(normalizeTextCandidate(item));
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => item !== null);
|
||||
if (structuredBlocks.length > 0) return structuredBlocks;
|
||||
|
||||
const textParts = raw
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
@@ -329,7 +356,7 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
if (type === 'tool_result') {
|
||||
const toolUseId = asTrimmedString(item.tool_use_id ?? item.toolUseId);
|
||||
const content = normalizeToolMessageContent(item.content ?? item.result);
|
||||
if (!toolUseId || !content) return null;
|
||||
if (!toolUseId || (typeof content === 'string' ? !content : content.length <= 0)) return null;
|
||||
const next: Record<string, unknown> = {
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
@@ -355,6 +382,10 @@ function sanitizeAnthropicContentBlock(item: Record<string, unknown>): Record<st
|
||||
return next;
|
||||
}
|
||||
|
||||
if (type === 'tool_reference') {
|
||||
return sanitizeToolReferenceBlock(item);
|
||||
}
|
||||
|
||||
return { ...item };
|
||||
}
|
||||
|
||||
@@ -846,8 +877,11 @@ export function convertOpenAiBodyToAnthropicMessagesBody(
|
||||
asTrimmedString(toolCandidate.tool_call_id)
|
||||
|| asTrimmedString(toolCandidate.id)
|
||||
);
|
||||
const toolResultContent = normalizeToolMessageContent(toolCandidate.content).trim();
|
||||
if (toolUseId && toolResultContent) {
|
||||
const toolResultContent = normalizeToolMessageContent(toolCandidate.content);
|
||||
const hasToolResultContent = typeof toolResultContent === 'string'
|
||||
? toolResultContent.trim().length > 0
|
||||
: toolResultContent.length > 0;
|
||||
if (toolUseId && hasToolResultContent) {
|
||||
toolResultBlocks.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
|
||||
Reference in New Issue
Block a user