From 4e25ef0f4f0133640db0f4a8db443f2c61946779 Mon Sep 17 00:00:00 2001 From: Xichun123 Date: Fri, 10 Jul 2026 11:58:17 +0800 Subject: [PATCH 1/3] feat(vertex): add Vertex AI and GCS proxy backend --- package-lock.json | 656 +++++++++++++++++++++++++- package.json | 3 + scripts/setup-gcs-bucket.sh | 85 ++++ scripts/verify-vertex-e2e.mjs | 306 ++++++++++++ server/src/config.test.ts | 84 +++- server/src/config.ts | 81 +++- server/src/createServer.ts | 19 +- server/src/gcsFilesAdapter.test.ts | 365 ++++++++++++++ server/src/gcsFilesAdapter.ts | 356 ++++++++++++++ server/src/geminiProxy.ts | 493 ++++++++++++++++++- server/src/index.ts | 21 +- server/src/vertexAuth.test.ts | 108 +++++ server/src/vertexAuth.ts | 72 +++ server/src/vertexPathRewriter.test.ts | 74 +++ server/src/vertexPathRewriter.ts | 42 ++ server/src/vertexProxy.test.ts | 321 +++++++++++++ 16 files changed, 3048 insertions(+), 38 deletions(-) create mode 100755 scripts/setup-gcs-bucket.sh create mode 100755 scripts/verify-vertex-e2e.mjs create mode 100644 server/src/gcsFilesAdapter.test.ts create mode 100644 server/src/gcsFilesAdapter.ts create mode 100644 server/src/vertexAuth.test.ts create mode 100644 server/src/vertexAuth.ts create mode 100644 server/src/vertexPathRewriter.test.ts create mode 100644 server/src/vertexPathRewriter.ts create mode 100644 server/src/vertexProxy.test.ts diff --git a/package-lock.json b/package-lock.json index 92f180891..9b0dd8ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "1.10.0", "dependencies": { "@formkit/auto-animate": "^0.8.2", + "@google-cloud/storage": "^7.21.0", "@google/genai": "^1.50.1", "@modelcontextprotocol/sdk": "^1.29.0", "@viz-js/viz": "^3.25.0", "dompurify": "^3.4.11", + "google-auth-library": "^10.9.0", "highlight.js": "^11.9.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", @@ -2474,6 +2476,138 @@ "integrity": "sha512-DHHC01EJ1p70Q0z/ZFRBIY8NDnmfKccQoyoM84Tgb6omLMat6jivCdf272Y8k3nf4Lzdin/Y4R9q8uFtU0GbnA==", "license": "MIT" }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.21.0.tgz", + "integrity": "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/@google/genai": { "version": "1.50.1", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", @@ -2984,6 +3118,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4697,6 +4843,15 @@ } } }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4760,6 +4915,12 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4917,6 +5078,18 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4930,6 +5103,12 @@ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5379,6 +5558,18 @@ "node": ">=10.0.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -5517,6 +5708,18 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -5575,6 +5778,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5621,6 +5833,21 @@ "node": ">= 0.4" } }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -6190,6 +6417,18 @@ "node": ">=6" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -7139,6 +7378,15 @@ "robust-predicates": "^3.0.2" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7234,6 +7482,32 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -7299,7 +7573,6 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -7440,7 +7713,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7752,6 +8024,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -7947,6 +8228,45 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -8185,6 +8505,44 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz", + "integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formatly": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", @@ -8477,9 +8835,9 @@ } }, "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -8521,6 +8879,55 @@ "dev": true, "license": "ISC" }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/gtoken/node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gtoken/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -8589,7 +8996,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -8602,9 +9008,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -8912,6 +9318,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -8972,6 +9394,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -9584,7 +10032,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9644,6 +10091,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -17461,6 +17920,18 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -17911,7 +18382,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -18028,6 +18498,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -19660,6 +20145,20 @@ "node": ">= 4" } }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -20375,6 +20874,21 @@ "node": ">= 0.4" } }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -20586,6 +21100,27 @@ "node": ">=0.10.0" } }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -20726,6 +21261,67 @@ "node": ">= 6" } }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -20908,6 +21504,12 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -21931,6 +22533,12 @@ "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -21941,6 +22549,16 @@ "node": ">=20" } }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -22468,6 +23086,21 @@ "node": ">=18" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", @@ -22549,7 +23182,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 11d554a4c..7cf86b934 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "verify": "npm run format:check && npm run typecheck && npm run lint && npm run test && npm run knip && npm run build && npm run build:api", "test:code-execution": "node scripts/run-vitest.mjs run src/utils/file-upload/fileUploadPolicy.test.ts src/features/chat-streaming/processors.test.ts src/utils/chat/builder.test.ts src/hooks/live-api/useLiveMessageProcessing.test.tsx src/services/api/chatApi.test.ts src/services/api/generation/tokenApi.test.ts src/services/api/apiClient.test.ts src/services/api/liveApiAuth.test.ts src/services/api/generationConfig.test.ts", "verify:code-execution:api": "node scripts/verify-code-execution.mjs", + "verify:vertex-e2e": "node scripts/verify-vertex-e2e.mjs", "start:api": "node server/dist/server/src/index.js", "test": "node scripts/run-vitest.mjs run", "test:watch": "node scripts/run-vitest.mjs", @@ -32,10 +33,12 @@ }, "dependencies": { "@formkit/auto-animate": "^0.8.2", + "@google-cloud/storage": "^7.21.0", "@google/genai": "^1.50.1", "@modelcontextprotocol/sdk": "^1.29.0", "@viz-js/viz": "^3.25.0", "dompurify": "^3.4.11", + "google-auth-library": "^10.9.0", "highlight.js": "^11.9.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", diff --git a/scripts/setup-gcs-bucket.sh b/scripts/setup-gcs-bucket.sh new file mode 100755 index 000000000..f7f9e85c4 --- /dev/null +++ b/scripts/setup-gcs-bucket.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Set up the GCS bucket used by the Vertex Files adapter (Stage 2). +# +# Idempotent: safe to re-run. Creates the bucket if missing, grants the SA +# storage.objectUser on it, and applies a lifecycle rule that auto-deletes +# objects after GCS_LIFECYCLE_DAYS days. +# +# Required env: +# GCP_PROJECT_ID e.g. my-project-123 +# GCS_BUCKET must be globally unique, e.g. amc-webui-files- +# VERTEX_SA_EMAIL the SA used by the api server, e.g. +# amc-vertex@my-project-123.iam.gserviceaccount.com +# +# Optional env: +# GCS_LOCATION default us-central1 (choose a valid GCS bucket location) +# GCS_LIFECYCLE_DAYS default 30 (set to 0 to skip the lifecycle rule) +# +# Usage: +# GCP_PROJECT_ID=... GCS_BUCKET=... VERTEX_SA_EMAIL=... \ +# bash scripts/setup-gcs-bucket.sh + +set -euo pipefail + +: "${GCP_PROJECT_ID:?GCP_PROJECT_ID is required}" +: "${GCS_BUCKET:?GCS_BUCKET is required}" +: "${VERTEX_SA_EMAIL:?VERTEX_SA_EMAIL is required}" +GCS_LOCATION="${GCS_LOCATION:-us-central1}" +GCS_LIFECYCLE_DAYS="${GCS_LIFECYCLE_DAYS:-30}" + +if ! command -v gcloud >/dev/null 2>&1; then + echo "gcloud CLI is not installed or not on PATH." >&2 + exit 2 +fi + +echo "Project: ${GCP_PROJECT_ID}" +echo "Bucket: gs://${GCS_BUCKET}" +echo "Location: ${GCS_LOCATION}" +echo "SA: ${VERTEX_SA_EMAIL}" +echo "Lifecycle: ${GCS_LIFECYCLE_DAYS} days (0=skip)" +echo + +# Step 1 — create bucket (idempotent) +if gcloud storage buckets describe "gs://${GCS_BUCKET}" --project="${GCP_PROJECT_ID}" >/dev/null 2>&1; then + echo "[1/3] bucket already exists, skipping create" +else + echo "[1/3] creating bucket gs://${GCS_BUCKET} in ${GCS_LOCATION}" + gcloud storage buckets create "gs://${GCS_BUCKET}" \ + --project="${GCP_PROJECT_ID}" \ + --location="${GCS_LOCATION}" \ + --uniform-bucket-level-access +fi + +# Step 2 — grant SA objectUser (idempotent; gcloud is a no-op if already bound) +echo "[2/3] granting roles/storage.objectUser to ${VERTEX_SA_EMAIL}" +gcloud storage buckets add-iam-policy-binding "gs://${GCS_BUCKET}" \ + --project="${GCP_PROJECT_ID}" \ + --member="serviceAccount:${VERTEX_SA_EMAIL}" \ + --role="roles/storage.objectUser" \ + --condition=None \ + >/dev/null + +# Step 3 — lifecycle (optional) +if [ "${GCS_LIFECYCLE_DAYS}" -gt 0 ]; then + echo "[3/3] applying ${GCS_LIFECYCLE_DAYS}-day delete lifecycle" + lifecycle_file="$(mktemp -t gcs-lifecycle.XXXXXX.json)" + trap 'rm -f "${lifecycle_file}"' EXIT + cat >"${lifecycle_file}" </dev/null +else + echo "[3/3] lifecycle rule skipped (GCS_LIFECYCLE_DAYS=0)" +fi + +echo +echo "Done. Set GCS_BUCKET=${GCS_BUCKET} in .env and restart the api container." diff --git a/scripts/verify-vertex-e2e.mjs b/scripts/verify-vertex-e2e.mjs new file mode 100755 index 000000000..b1876bf8a --- /dev/null +++ b/scripts/verify-vertex-e2e.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node +/** + * Vertex backend end-to-end smoke test against a real GCP project. + * + * Required env: + * GCP_PROJECT_ID project hosting the api server's Vertex usage + * GCS_BUCKET bucket the api server is writing to + * GOOGLE_APPLICATION_CREDENTIALS SA JSON path (script reads GCS directly to verify) + * + * Optional env: + * API_BASE_URL default http://localhost:8080 (Docker web service) + * GCS_OBJECT_PREFIX default amc-files/ (must match the api server) + * VERTEX_TEXT_MODEL default gemini-2.5-flash + * VERTEX_IMAGE_MODEL default imagen-4.0-generate-001 + * SKIP_IMAGEN=1 skip the Imagen step (saves credits) + * NO_CLEANUP=1 leave the uploaded GCS object behind for inspection + * + * Usage: + * docker compose up -d + * node scripts/verify-vertex-e2e.mjs + */ +import { Storage } from '@google-cloud/storage'; +import { crc32, deflateSync } from 'node:zlib'; + +const API_BASE_URL = (process.env.API_BASE_URL ?? 'http://localhost:8080').replace(/\/$/, ''); +const GCP_PROJECT_ID = required('GCP_PROJECT_ID'); +const GCS_BUCKET = required('GCS_BUCKET'); +const GCS_OBJECT_PREFIX = (process.env.GCS_OBJECT_PREFIX ?? 'amc-files/').replace(/^\/+/, ''); +const TEXT_MODEL = process.env.VERTEX_TEXT_MODEL ?? 'gemini-2.5-flash'; +const IMAGE_MODEL = process.env.VERTEX_IMAGE_MODEL ?? 'imagen-4.0-generate-001'; +const SKIP_IMAGEN = process.env.SKIP_IMAGEN === '1'; +const NO_CLEANUP = process.env.NO_CLEANUP === '1'; + +function buildPngChunk(type, data) { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const typeBytes = Buffer.from(type, 'ascii'); + const crcValue = crc32(Buffer.concat([typeBytes, data])); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crcValue >>> 0, 0); + return Buffer.concat([length, typeBytes, data, crcBuf]); +} + +function buildSolidColorPng(size, [r, g, b]) { + const rowBytes = Buffer.alloc(1 + size * 3); + rowBytes[0] = 0; + for (let x = 0; x < size; x++) { + rowBytes[1 + x * 3] = r; + rowBytes[2 + x * 3] = g; + rowBytes[3 + x * 3] = b; + } + const raw = Buffer.alloc(rowBytes.length * size); + for (let y = 0; y < size; y++) { + rowBytes.copy(raw, y * rowBytes.length); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr.writeUInt8(8, 8); + ihdr.writeUInt8(2, 9); + ihdr.writeUInt8(0, 10); + ihdr.writeUInt8(0, 11); + ihdr.writeUInt8(0, 12); + const idat = deflateSync(raw); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + buildPngChunk('IHDR', ihdr), + buildPngChunk('IDAT', idat), + buildPngChunk('IEND', Buffer.alloc(0)), + ]); +} + +function required(name) { + const value = process.env[name]?.trim(); + if (!value) { + console.error(`Missing required env var: ${name}`); + process.exit(2); + } + return value; +} + +function assert(condition, message) { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +async function http(path, init = {}) { + const url = path.startsWith('http') ? path : `${API_BASE_URL}${path}`; + const response = await fetch(url, init); + return { response, url }; +} + +async function expectJsonOk({ response, url }, context) { + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`${context}: ${response.status} ${response.statusText}\nURL: ${url}\nBody: ${body.slice(0, 500)}`); + } + return await response.json(); +} + +async function checkHealth() { + const result = await http('/health'); + const body = await expectJsonOk(result, 'GET /health'); + assert(body.status === 'ok', `/health did not return status=ok (got ${JSON.stringify(body)})`); + console.log(` ✓ /health → ok (uptime ${body.uptimeSeconds}s)`); +} + +async function checkTextGenerate() { + const result = await http(`/api/gemini/v1beta/models/${TEXT_MODEL}:generateContent`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: 'Reply with the single word: ping' }] }], + generationConfig: { temperature: 0, maxOutputTokens: 256 }, + }), + }); + const body = await expectJsonOk(result, `generateContent ${TEXT_MODEL}`); + const text = body.candidates?.[0]?.content?.parts?.map((p) => p.text).join('') ?? ''; + assert(text.toLowerCase().includes('ping'), `text generate did not contain "ping": got ${JSON.stringify(text)}`); + console.log(` ✓ generateContent ${TEXT_MODEL} → "${text.trim().slice(0, 40)}"`); +} + +async function checkFileUpload() { + const pngBytes = buildSolidColorPng(32, [220, 30, 30]); + const half = Math.floor(pngBytes.byteLength / 2); + const chunk1 = pngBytes.subarray(0, half); + const chunk2 = pngBytes.subarray(half); + + const initiate = await http('/api/gemini/upload/v1beta/files', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-goog-upload-protocol': 'resumable', + 'x-goog-upload-command': 'start', + 'x-goog-upload-header-content-length': String(pngBytes.byteLength), + 'x-goog-upload-header-content-type': 'image/png', + }, + body: JSON.stringify({ + file: { displayName: 'e2e-red.png', mimeType: 'image/png', sizeBytes: String(pngBytes.byteLength) }, + }), + }); + if (!initiate.response.ok) { + const body = await initiate.response.text().catch(() => ''); + throw new Error(`initiate failed: ${initiate.response.status}\n${body}`); + } + const uploadUrl = initiate.response.headers.get('x-goog-upload-url'); + assert(uploadUrl, 'initiate did not return x-goog-upload-url'); + // Mimic the frontend's buildProxiedUploadUrl: the server returns the chunk path under the + // upstream host, the frontend (or this script) routes it back through the /api/gemini proxy. + const chunkPath = `/api/gemini${new URL(uploadUrl).pathname}`; + console.log(` ✓ initiate → ${chunkPath}`); + + const chunk1Res = await http(chunkPath, { + method: 'POST', + headers: { 'x-goog-upload-offset': '0', 'x-goog-upload-command': 'upload' }, + body: chunk1, + }); + assert(chunk1Res.response.status === 200, `chunk 1 status ${chunk1Res.response.status}`); + assert( + chunk1Res.response.headers.get('x-goog-upload-status') === 'active', + 'chunk 1 should be active, got ' + chunk1Res.response.headers.get('x-goog-upload-status'), + ); + + const chunk2Res = await http(chunkPath, { + method: 'POST', + headers: { 'x-goog-upload-offset': String(half), 'x-goog-upload-command': 'upload, finalize' }, + body: chunk2, + }); + const finalBody = await expectJsonOk(chunk2Res, 'chunk finalize'); + const file = finalBody.file; + assert(file?.name?.startsWith('files/'), `unexpected file.name: ${file?.name}`); + assert(file?.state === 'ACTIVE', `unexpected state: ${file?.state}`); + assert(file?.uri?.includes('/v1beta/files/'), `unexpected uri: ${file?.uri}`); + + const fileId = file.name.slice('files/'.length); + console.log(` ✓ resumable upload finalized → ${file.name} (${pngBytes.byteLength} bytes, 2 chunks)`); + return { file, fileId }; +} + +async function checkGcsObjectExists(fileId, storage) { + const objectPath = `${GCS_OBJECT_PREFIX}${fileId}`; + const gcsFile = storage.bucket(GCS_BUCKET).file(objectPath); + const [exists] = await gcsFile.exists(); + assert(exists, `GCS object gs://${GCS_BUCKET}/${objectPath} not found`); + const [meta] = await gcsFile.getMetadata(); + assert(meta.contentType === 'image/png', `unexpected contentType: ${meta.contentType}`); + console.log(` ✓ gs://${GCS_BUCKET}/${objectPath} exists (${meta.size} bytes, ${meta.contentType})`); +} + +async function checkFileMetadataRefresh(fileName) { + const result = await http(`/api/gemini/v1beta/${fileName}`); + const body = await expectJsonOk(result, `GET /v1beta/${fileName}`); + assert(body.state === 'ACTIVE', `metadata state ${body.state}`); + assert(body.name === fileName, `metadata name mismatch ${body.name}`); + console.log(` ✓ metadata refresh → ACTIVE, uri=${body.uri.slice(0, 60)}…`); +} + +async function checkMultimodalGenerate(file) { + const result = await http(`/api/gemini/v1beta/models/${TEXT_MODEL}:generateContent`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [ + { fileData: { mimeType: 'image/png', fileUri: file.uri } }, + { text: 'What single color dominates this image? Reply with one word.' }, + ], + }, + ], + generationConfig: { temperature: 0, maxOutputTokens: 256 }, + }), + }); + const body = await expectJsonOk(result, `multimodal generateContent`); + const text = + body.candidates?.[0]?.content?.parts + ?.map((p) => p.text) + .filter(Boolean) + .join('') ?? ''; + assert(text.length > 0, `multimodal response had no text: ${JSON.stringify(body).slice(0, 300)}`); + console.log(` ✓ multimodal generateContent → "${text.trim().slice(0, 40)}"`); + if (!text.toLowerCase().includes('red')) { + console.warn(` ! model did not say "red" (got "${text.trim()}") — image fetched but content may be misclassified`); + } +} + +async function checkImagen() { + const result = await http(`/api/gemini/v1beta/models/${IMAGE_MODEL}:predict`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + instances: [{ prompt: 'a flat red square on white background' }], + parameters: { sampleCount: 1 }, + }), + }); + const body = await expectJsonOk(result, `Imagen ${IMAGE_MODEL}:predict`); + const predictions = body.predictions ?? []; + assert(predictions.length > 0, 'Imagen returned no predictions'); + const first = predictions[0]; + const imageBytes = first.bytesBase64Encoded ?? first.imageBytes ?? first.image?.bytesBase64Encoded; + assert(typeof imageBytes === 'string' && imageBytes.length > 100, 'Imagen response missing base64 bytes'); + console.log(` ✓ Imagen ${IMAGE_MODEL} → 1 image (${Math.round((imageBytes.length * 3) / 4)} bytes)`); +} + +async function cleanupGcsObject(fileId, storage) { + const objectPath = `${GCS_OBJECT_PREFIX}${fileId}`; + await storage.bucket(GCS_BUCKET).file(objectPath).delete({ ignoreNotFound: true }); + console.log(` ✓ deleted gs://${GCS_BUCKET}/${objectPath}`); +} + +async function main() { + console.log(`Vertex e2e smoke test`); + console.log(` api: ${API_BASE_URL}`); + console.log(` project: ${GCP_PROJECT_ID}`); + console.log(` bucket: gs://${GCS_BUCKET}/${GCS_OBJECT_PREFIX}`); + console.log(` models: text=${TEXT_MODEL}, image=${IMAGE_MODEL}${SKIP_IMAGEN ? ' (skipped)' : ''}`); + console.log(); + + const storage = new Storage({ projectId: GCP_PROJECT_ID }); + + console.log('[1/6] health'); + await checkHealth(); + + console.log('[2/6] text generate'); + await checkTextGenerate(); + + console.log('[3/6] file upload (resumable, 2 chunks)'); + const { file, fileId } = await checkFileUpload(); + + try { + console.log('[4/6] direct GCS object check'); + await checkGcsObjectExists(fileId, storage); + + console.log('[5/6] metadata refresh + multimodal generate'); + await checkFileMetadataRefresh(file.name); + await checkMultimodalGenerate(file); + + if (!SKIP_IMAGEN) { + console.log('[6/6] Imagen predict'); + await checkImagen(); + } else { + console.log('[6/6] Imagen predict (skipped via SKIP_IMAGEN=1)'); + } + } finally { + if (!NO_CLEANUP) { + console.log('cleanup'); + await cleanupGcsObject(fileId, storage).catch((error) => { + console.warn(` ! cleanup failed: ${error.message}`); + }); + } else { + console.log(`cleanup (skipped via NO_CLEANUP=1) — gs://${GCS_BUCKET}/${GCS_OBJECT_PREFIX}${fileId}`); + } + } + + console.log(); + console.log('All Vertex e2e checks passed.'); +} + +main().catch((error) => { + console.error(); + console.error('Vertex e2e FAILED:'); + console.error(error instanceof Error ? (error.stack ?? error.message) : error); + process.exitCode = 1; +}); diff --git a/server/src/config.test.ts b/server/src/config.test.ts index cc83d9f66..3fb1994f0 100644 --- a/server/src/config.test.ts +++ b/server/src/config.test.ts @@ -3,13 +3,95 @@ import { describe, expect, it } from 'vitest'; import { loadConfig } from './config'; describe('loadConfig', () => { - it('keeps MCP stdio and private HTTP disabled unless explicitly enabled', () => { + it('defaults to AI Studio with MCP transports disabled', () => { const config = loadConfig({}); + expect(config.backendFlavor).toBe('aistudio'); + expect(config.vertex).toBeUndefined(); + expect(config.gcs).toBeUndefined(); expect(config.enableMcpStdio).toBe(false); expect(config.enableMcpPrivateHttp).toBe(false); }); + it('keeps AI Studio for unrecognized GEMINI_BACKEND values', () => { + expect(loadConfig({ GEMINI_BACKEND: 'studio' }).backendFlavor).toBe('aistudio'); + expect(loadConfig({ GEMINI_BACKEND: '' }).backendFlavor).toBe('aistudio'); + }); + + it('parses GEMINI_BACKEND=vertex case-insensitively', () => { + expect(loadConfig({ GEMINI_BACKEND: 'VERTEX', GCP_PROJECT_ID: 'p' }).backendFlavor).toBe('vertex'); + }); + + it('requires GCP_PROJECT_ID in Vertex mode', () => { + expect(() => loadConfig({ GEMINI_BACKEND: 'vertex' })).toThrow(/GCP_PROJECT_ID is required/); + }); + + it('reads Vertex config and defaults the location', () => { + expect(loadConfig({ GEMINI_BACKEND: 'vertex', GCP_PROJECT_ID: 'my-proj' }).vertex).toEqual({ + projectId: 'my-proj', + location: 'us-central1', + }); + + expect( + loadConfig({ + GEMINI_BACKEND: 'vertex', + GCP_PROJECT_ID: 'my-proj', + GCP_LOCATION: 'europe-west4', + }).vertex, + ).toEqual({ projectId: 'my-proj', location: 'europe-west4' }); + }); + + it('omits GCS config when GCS_BUCKET is unset', () => { + const config = loadConfig({ GEMINI_BACKEND: 'vertex', GCP_PROJECT_ID: 'p' }); + expect(config.gcs).toBeUndefined(); + }); + + it('reads GCS config with upload-safe defaults', () => { + const config = loadConfig({ + GEMINI_BACKEND: 'vertex', + GCP_PROJECT_ID: 'p', + GCS_BUCKET: 'my-bucket', + }); + + expect(config.gcs).toEqual({ + bucketName: 'my-bucket', + objectPrefix: 'amc-files/', + maxFileBytes: 2 * 1024 * 1024 * 1024, + }); + expect(config.gcs?.maxFileBytes).toBeGreaterThanOrEqual(400 * 1024 * 1024); + }); + + it('normalizes GCS object prefixes and parses the file size limit', () => { + const config = loadConfig({ + GEMINI_BACKEND: 'vertex', + GCP_PROJECT_ID: 'p', + GCS_BUCKET: 'b', + GCS_OBJECT_PREFIX: '/tenant/files', + GCS_MAX_FILE_BYTES: '524288', + }); + + expect(config.gcs).toEqual({ + bucketName: 'b', + objectPrefix: 'tenant/files/', + maxFileBytes: 524288, + }); + }); + + it('falls back to the default GCS limit on invalid input', () => { + const config = loadConfig({ + GEMINI_BACKEND: 'vertex', + GCP_PROJECT_ID: 'p', + GCS_BUCKET: 'b', + GCS_MAX_FILE_BYTES: 'not-a-number', + }); + + expect(config.gcs?.maxFileBytes).toBe(2 * 1024 * 1024 * 1024); + }); + + it('ignores GCS settings in AI Studio mode', () => { + expect(loadConfig({ GCS_BUCKET: 'ignored' }).gcs).toBeUndefined(); + }); + it('parses MCP transport enablement flags from the environment', () => { const config = loadConfig({ ENABLE_MCP_STDIO: 'true', diff --git a/server/src/config.ts b/server/src/config.ts index 2590877cf..c6191787c 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,18 +1,37 @@ +export type GeminiBackendFlavor = 'aistudio' | 'vertex'; + export interface ApiServerConfig { port: number; + backendFlavor: GeminiBackendFlavor; geminiApiBase: string; geminiApiKey?: string; + vertex?: VertexBackendConfig; + gcs?: GcsConfig; allowedOrigins: string[]; enableMcpStdio: boolean; enableMcpPrivateHttp: boolean; } +export interface VertexBackendConfig { + projectId: string; + location: string; +} + +export interface GcsConfig { + bucketName: string; + objectPrefix: string; + maxFileBytes: number; +} + interface EnvLike { [key: string]: string | undefined; } const DEFAULT_PORT = 3001; const DEFAULT_GEMINI_API_BASE = 'https://generativelanguage.googleapis.com'; +const DEFAULT_VERTEX_LOCATION = 'us-central1'; +const DEFAULT_GCS_OBJECT_PREFIX = 'amc-files/'; +const DEFAULT_GCS_MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024; function parsePort(port: string | undefined): number { if (!port) { @@ -42,13 +61,73 @@ function parseBooleanFlag(value: string | undefined): boolean { return ['1', 'true', 'yes', 'on'].includes(value?.trim().toLowerCase() ?? ''); } -export function loadConfig(env: EnvLike = process.env): ApiServerConfig { +function parseBackendFlavor(value: string | undefined): GeminiBackendFlavor { + return value?.trim().toLowerCase() === 'vertex' ? 'vertex' : 'aistudio'; +} + +function loadVertexConfig(env: EnvLike): VertexBackendConfig { + const projectId = env.GCP_PROJECT_ID?.trim(); + if (!projectId) { + throw new Error('GCP_PROJECT_ID is required when GEMINI_BACKEND=vertex.'); + } + return { + projectId, + location: env.GCP_LOCATION?.trim() || DEFAULT_VERTEX_LOCATION, + }; +} + +function normalizeObjectPrefix(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) { + return DEFAULT_GCS_OBJECT_PREFIX; + } + + const withoutLeadingSlash = trimmed.replace(/^\/+/, ''); + return withoutLeadingSlash.endsWith('/') ? withoutLeadingSlash : `${withoutLeadingSlash}/`; +} + +function parseMaxFileBytes(value: string | undefined): number { + if (!value) { + return DEFAULT_GCS_MAX_FILE_BYTES; + } + + const parsed = Number.parseInt(value.trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_GCS_MAX_FILE_BYTES; +} + +function loadGcsConfig(env: EnvLike): GcsConfig | undefined { + const bucketName = env.GCS_BUCKET?.trim(); + if (!bucketName) { + return undefined; + } + + return { + bucketName, + objectPrefix: normalizeObjectPrefix(env.GCS_OBJECT_PREFIX), + maxFileBytes: parseMaxFileBytes(env.GCS_MAX_FILE_BYTES), + }; +} + +export function loadConfig(env: EnvLike = process.env): ApiServerConfig { + const backendFlavor = parseBackendFlavor(env.GEMINI_BACKEND); + const baseConfig = { port: parsePort(env.PORT), + backendFlavor, geminiApiBase: env.GEMINI_API_BASE?.trim() || DEFAULT_GEMINI_API_BASE, geminiApiKey: env.GEMINI_API_KEY?.trim() || undefined, allowedOrigins: parseAllowedOrigins(env.ALLOWED_ORIGINS), enableMcpStdio: parseBooleanFlag(env.ENABLE_MCP_STDIO), enableMcpPrivateHttp: parseBooleanFlag(env.ENABLE_MCP_PRIVATE_HTTP), }; + + if (backendFlavor === 'vertex') { + return { + ...baseConfig, + vertex: loadVertexConfig(env), + gcs: loadGcsConfig(env), + }; + } + + return baseConfig; } diff --git a/server/src/createServer.ts b/server/src/createServer.ts index 168d202aa..4a9b62e3b 100644 --- a/server/src/createServer.ts +++ b/server/src/createServer.ts @@ -7,26 +7,32 @@ import { readMacOsClipboardPng, } from './clipboardImage.js'; import { getCorsHeaders, sendJson } from './cors.js'; -import { GEMINI_PROXY_PREFIX, proxyGeminiRequest, type GeminiProxyConfig } from './geminiProxy.js'; +import type { GcsFilesAdapter } from './gcsFilesAdapter.js'; +import { GEMINI_PROXY_PREFIX, proxyGeminiRequest } from './geminiProxy.js'; import { IMAGE_PROXY_PATH, proxyExternalImage } from './imageProxy.js'; import { createMcpClientBridge } from './mcpClient.js'; import { handleMcpRequest } from './mcpRoutes.js'; import type { McpClientBridge } from './mcpTypes.js'; +import type { VertexAccessTokenProvider } from './vertexAuth.js'; export { readMacOsClipboardPng } from './clipboardImage.js'; interface CreateServerDependencies { fetchImpl?: typeof fetch; readLocalClipboardImage?: () => Promise; + vertexAuth?: VertexAccessTokenProvider; + gcsFilesAdapter?: GcsFilesAdapter; mcpClient?: McpClientBridge; } type CreateServerConfig = Pick & - Partial>; + Partial< + Pick + >; -interface ResolvedServerConfig - extends Omit, GeminiProxyConfig { +interface ResolvedServerConfig extends CreateServerConfig { allowedOrigins: string[]; + backendFlavor: ApiServerConfig['backendFlavor']; enableMcpStdio: boolean; enableMcpPrivateHttp: boolean; } @@ -35,12 +41,15 @@ export function createServer(config: CreateServerConfig, dependencies: CreateSer const resolvedConfig: ResolvedServerConfig = { ...config, allowedOrigins: config.allowedOrigins ?? [], + backendFlavor: config.backendFlavor ?? 'aistudio', enableMcpStdio: config.enableMcpStdio ?? false, enableMcpPrivateHttp: config.enableMcpPrivateHttp ?? false, }; const fetchImpl = dependencies.fetchImpl ?? fetch; const readLocalClipboardImage = dependencies.readLocalClipboardImage ?? readMacOsClipboardPng; + const vertexAuth = dependencies.vertexAuth; + const gcsFilesAdapter = dependencies.gcsFilesAdapter; const mcpClient = dependencies.mcpClient ?? createMcpClientBridge(); return http.createServer(async (request, response) => { @@ -101,7 +110,7 @@ export function createServer(config: CreateServerConfig, dependencies: CreateSer } if (path === GEMINI_PROXY_PREFIX || path.startsWith(`${GEMINI_PROXY_PREFIX}/`)) { - await proxyGeminiRequest(request, response, resolvedConfig, fetchImpl); + await proxyGeminiRequest(request, response, resolvedConfig, { fetchImpl, vertexAuth, gcsFilesAdapter }); return; } diff --git a/server/src/gcsFilesAdapter.test.ts b/server/src/gcsFilesAdapter.test.ts new file mode 100644 index 000000000..794aa247b --- /dev/null +++ b/server/src/gcsFilesAdapter.test.ts @@ -0,0 +1,365 @@ +import { Buffer } from 'node:buffer'; +import { Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { createGcsFilesAdapter, type StorageLike } from './gcsFilesAdapter'; +import type { GcsConfig } from './config'; + +interface FakeFileState { + data: Buffer; + contentType: string; + metadata: Record; + timeCreated: string; + updated: string; +} + +interface FakeStorage extends StorageLike { + files: Map; +} + +function createFakeStorage(): FakeStorage { + const files = new Map(); + const now = '2026-05-18T12:00:00.000Z'; + + const storage: FakeStorage = { + files, + bucket: (bucketName: string) => ({ + file: (path: string) => { + const key = `${bucketName}/${path}`; + return { + save: async (data: Buffer, options) => { + files.set(key, { + data, + contentType: options.metadata?.contentType ?? options.contentType ?? 'application/octet-stream', + metadata: options.metadata?.metadata ?? {}, + timeCreated: now, + updated: now, + }); + }, + createWriteStream: (options) => { + const chunks: Buffer[] = []; + return new Writable({ + write: (chunk, _encoding, callback) => { + chunks.push(Buffer.from(chunk)); + callback(); + }, + final: (callback) => { + files.set(key, { + data: Buffer.concat(chunks), + contentType: options.metadata?.contentType ?? options.contentType ?? 'application/octet-stream', + metadata: options.metadata?.metadata ?? {}, + timeCreated: now, + updated: now, + }); + callback(); + }, + }); + }, + getMetadata: async () => { + const existing = files.get(key); + if (!existing) { + throw new Error(`File not found: ${key}`); + } + return [ + { + size: existing.data.byteLength, + contentType: existing.contentType, + metadata: existing.metadata, + timeCreated: existing.timeCreated, + updated: existing.updated, + }, + ]; + }, + exists: async () => [files.has(key)], + }; + }, + }), + }; + + return storage; +} + +const baseConfig: GcsConfig = { + bucketName: 'my-bucket', + objectPrefix: 'amc-files/', + maxFileBytes: 1024 * 1024, +}; + +describe('createGcsFilesAdapter', () => { + it('uploads a single-chunk file and returns AI-Studio shaped File metadata', async () => { + const storage = createFakeStorage(); + const fixedDate = new Date('2026-05-18T12:00:00.000Z'); + const adapter = createGcsFilesAdapter({ + storage, + config: baseConfig, + now: () => fixedDate, + randomId: () => 'abc123', + }); + + const init = adapter.initiateUpload({ displayName: 'cat.png', mimeType: 'image/png', sizeBytes: 5 }); + expect(init.sessionId).toBe('abc123'); + expect(init.uploadUrl).toBe('https://generativelanguage.googleapis.com/__gcs-upload-chunk__/abc123'); + + const data = Buffer.from('hello'); + const result = await adapter.uploadChunk({ + sessionId: 'abc123', + offset: 0, + command: 'upload, finalize', + chunk: data, + }); + + expect(result.status).toBe('final'); + expect(result.file).toEqual({ + name: 'files/abc123', + displayName: 'cat.png', + mimeType: 'image/png', + sizeBytes: '5', + createTime: '2026-05-18T12:00:00.000Z', + updateTime: '2026-05-18T12:00:00.000Z', + expirationTime: '2027-05-18T12:00:00.000Z', + state: 'ACTIVE', + uri: 'https://generativelanguage.googleapis.com/v1beta/files/abc123', + }); + + const stored = storage.files.get('my-bucket/amc-files/abc123'); + expect(stored?.data.equals(data)).toBe(true); + expect(stored?.contentType).toBe('image/png'); + expect(stored?.metadata['amc-display-name']).toBe('cat.png'); + }); + + it('aggregates multiple resumable chunks before writing to GCS', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: baseConfig, + now: () => new Date('2026-01-01T00:00:00.000Z'), + randomId: () => 'sess-1', + }); + adapter.initiateUpload({ displayName: 'doc.bin', mimeType: 'application/octet-stream', sizeBytes: 6 }); + + const first = await adapter.uploadChunk({ + sessionId: 'sess-1', + offset: 0, + command: 'upload', + chunk: Buffer.from('foo'), + }); + expect(first.status).toBe('active'); + expect(first.file).toBeUndefined(); + expect(storage.files.size).toBe(0); + + const second = await adapter.uploadChunk({ + sessionId: 'sess-1', + offset: 3, + command: 'upload, finalize', + chunk: Buffer.from('bar'), + }); + + expect(second.status).toBe('final'); + expect(second.file?.sizeBytes).toBe('6'); + expect(storage.files.get('my-bucket/amc-files/sess-1')?.data.toString('utf8')).toBe('foobar'); + }); + + it('rejects unexpected chunk offsets', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: baseConfig, + randomId: () => 'sess-x', + }); + adapter.initiateUpload({ displayName: 'a', mimeType: 'text/plain', sizeBytes: 4 }); + + await expect( + adapter.uploadChunk({ sessionId: 'sess-x', offset: 1, command: 'upload', chunk: Buffer.from('a') }), + ).rejects.toThrow(/Unexpected upload offset/); + }); + + it('rejects finalize when accumulated size differs from declared size', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: baseConfig, + randomId: () => 'sess-y', + }); + adapter.initiateUpload({ displayName: 'a', mimeType: 'text/plain', sizeBytes: 10 }); + + await expect( + adapter.uploadChunk({ + sessionId: 'sess-y', + offset: 0, + command: 'upload, finalize', + chunk: Buffer.from('short'), + }), + ).rejects.toThrow(/File size mismatch/); + }); + + it('rejects oversize uploads at initiate time', () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: { ...baseConfig, maxFileBytes: 100 }, + randomId: () => 'sess-z', + }); + + expect(() => + adapter.initiateUpload({ displayName: 'big.bin', mimeType: 'application/octet-stream', sizeBytes: 200 }), + ).toThrow(/exceeds GCS_MAX_FILE_BYTES/); + }); + + it('allows a declared 400MB video upload session when the configured limit permits it', () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: { ...baseConfig, maxFileBytes: 2 * 1024 * 1024 * 1024 }, + randomId: () => 'video-400mb', + }); + + const init = adapter.initiateUpload({ + displayName: 'large.mp4', + mimeType: 'video/mp4', + sizeBytes: 400 * 1024 * 1024, + }); + + expect(init.sessionId).toBe('video-400mb'); + expect(init.uploadUrl).toBe('https://generativelanguage.googleapis.com/__gcs-upload-chunk__/video-400mb'); + }); + + it('returns null for unknown file IDs in getFileMetadata', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ storage, config: baseConfig }); + + expect(await adapter.getFileMetadata('does-not-exist')).toBeNull(); + }); + + it('rejects malformed file IDs in getFileMetadata', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ storage, config: baseConfig }); + + expect(await adapter.getFileMetadata('../escape')).toBeNull(); + expect(await adapter.getFileMetadata('with/slashes')).toBeNull(); + }); + + it('builds metadata from GCS object after upload', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: baseConfig, + now: () => new Date('2026-05-18T12:00:00.000Z'), + randomId: () => 'fixed-id', + }); + adapter.initiateUpload({ displayName: 'img.png', mimeType: 'image/png', sizeBytes: 3 }); + await adapter.uploadChunk({ + sessionId: 'fixed-id', + offset: 0, + command: 'upload, finalize', + chunk: Buffer.from('abc'), + }); + + const meta = await adapter.getFileMetadata('fixed-id'); + expect(meta).toMatchObject({ + name: 'files/fixed-id', + displayName: 'img.png', + mimeType: 'image/png', + sizeBytes: '3', + state: 'ACTIVE', + uri: 'https://generativelanguage.googleapis.com/v1beta/files/fixed-id', + }); + }); + + it('rewrites AI Studio file URIs to gs:// in JSON bodies', () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ storage, config: baseConfig }); + const body = Buffer.from( + JSON.stringify({ + contents: [ + { + role: 'user', + parts: [ + { + fileData: { + mimeType: 'image/png', + fileUri: 'https://generativelanguage.googleapis.com/v1beta/files/abc-123', + }, + }, + { text: 'describe this' }, + ], + }, + ], + }), + ); + + const rewritten = adapter.rewriteFileUriInJsonBody(body); + expect(rewritten.toString('utf8')).toContain('"fileUri":"gs://my-bucket/amc-files/abc-123"'); + expect(rewritten.toString('utf8')).not.toContain('generativelanguage.googleapis.com'); + }); + + it('returns the original buffer when no AI Studio URI is present', () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ storage, config: baseConfig }); + const body = Buffer.from('{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}'); + + expect(adapter.rewriteFileUriInJsonBody(body)).toBe(body); + }); + + it('uses the configured object prefix in both GCS path and rewritten URI', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: { ...baseConfig, objectPrefix: 'tenant-x/files/' }, + randomId: () => 'item-1', + }); + adapter.initiateUpload({ displayName: 'a', mimeType: 'text/plain', sizeBytes: 1 }); + await adapter.uploadChunk({ + sessionId: 'item-1', + offset: 0, + command: 'upload, finalize', + chunk: Buffer.from('a'), + }); + + expect(storage.files.has('my-bucket/tenant-x/files/item-1')).toBe(true); + + const rewritten = adapter.rewriteFileUriInJsonBody( + Buffer.from('"https://generativelanguage.googleapis.com/v1beta/files/item-1"'), + ); + expect(rewritten.toString('utf8')).toBe('"gs://my-bucket/tenant-x/files/item-1"'); + }); + + it('cleans up the upload session after finalize', async () => { + const storage = createFakeStorage(); + const adapter = createGcsFilesAdapter({ + storage, + config: baseConfig, + randomId: () => 'cleanup-test', + }); + adapter.initiateUpload({ displayName: 'a', mimeType: 'text/plain', sizeBytes: 1 }); + await adapter.uploadChunk({ + sessionId: 'cleanup-test', + offset: 0, + command: 'upload, finalize', + chunk: Buffer.from('a'), + }); + + await expect( + adapter.uploadChunk({ + sessionId: 'cleanup-test', + offset: 0, + command: 'upload', + chunk: Buffer.from('a'), + }), + ).rejects.toThrow(/Upload session .* not found/); + }); + + it('returns 0 size when GCS metadata size is missing', async () => { + const noopStorage: StorageLike = { + bucket: () => ({ + file: () => ({ + save: vi.fn(), + exists: async () => [true], + getMetadata: async () => [{ contentType: 'image/png' }], + }), + }), + }; + + const adapter = createGcsFilesAdapter({ storage: noopStorage, config: baseConfig }); + const meta = await adapter.getFileMetadata('any-id'); + expect(meta?.sizeBytes).toBe('0'); + }); +}); diff --git a/server/src/gcsFilesAdapter.ts b/server/src/gcsFilesAdapter.ts new file mode 100644 index 000000000..48d0cbd85 --- /dev/null +++ b/server/src/gcsFilesAdapter.ts @@ -0,0 +1,356 @@ +import { Buffer } from 'node:buffer'; +import { randomUUID } from 'node:crypto'; +import type { Writable } from 'node:stream'; +import type { GcsConfig } from './config.js'; + +const AISTUDIO_FILE_URI_HOST = 'https://generativelanguage.googleapis.com'; +const FILE_ID_PATTERN = /^[\w-]+$/; +const AISTUDIO_FILE_URI_PATTERN = /https:\/\/generativelanguage\.googleapis\.com\/v1beta\/files\/([\w-]+)/g; +const FILE_EXPIRATION_LEEWAY_MS = 365 * 24 * 60 * 60 * 1000; +const UPLOAD_SESSION_TTL_MS = 6 * 60 * 60 * 1000; + +export interface StorageFile { + save( + data: Buffer, + options: { + contentType?: string; + metadata?: { contentType?: string; metadata?: Record }; + }, + ): Promise; + createWriteStream?(options: { + contentType?: string; + metadata?: { contentType?: string; metadata?: Record }; + resumable?: boolean; + }): Writable; + getMetadata(): Promise< + [ + { + size?: string | number; + contentType?: string; + metadata?: Record; + timeCreated?: string; + updated?: string; + }, + ...unknown[], + ] + >; + exists(): Promise<[boolean]>; +} + +export interface StorageBucket { + file(path: string): StorageFile; +} + +export interface StorageLike { + bucket(name: string): StorageBucket; +} + +export interface AiStudioFile { + name: string; + displayName?: string; + mimeType: string; + sizeBytes: string; + createTime: string; + updateTime: string; + expirationTime: string; + state: 'ACTIVE'; + uri: string; +} + +interface InitiateUploadInput { + displayName: string; + mimeType: string; + sizeBytes: number; +} + +interface InitiateUploadResult { + sessionId: string; + uploadUrl: string; +} + +interface UploadChunkInput { + sessionId: string; + offset: number; + command: string; + chunk: Buffer; +} + +interface UploadChunkResult { + status: 'active' | 'final'; + file?: AiStudioFile; +} + +interface UploadSessionState { + id: string; + displayName: string; + mimeType: string; + totalSize: number; + receivedBytes: number; + chunks?: Buffer[]; + writeStream?: Writable; + cleanupTimer?: ReturnType; + streamError?: Error; +} + +interface CreateGcsFilesAdapterOptions { + storage: StorageLike; + config: GcsConfig; + now?: () => Date; + randomId?: () => string; +} + +export interface GcsFilesAdapter { + initiateUpload(input: InitiateUploadInput): InitiateUploadResult; + uploadChunk(input: UploadChunkInput): Promise; + getFileMetadata(fileId: string): Promise; + rewriteFileUriInJsonBody(body: Buffer): Buffer; + buildGcsUriForFileId(fileId: string): string; +} + +function buildAiStudioFileUri(fileId: string): string { + return `${AISTUDIO_FILE_URI_HOST}/v1beta/files/${fileId}`; +} + +function buildAiStudioFileName(fileId: string): string { + return `files/${fileId}`; +} + +function buildChunkUploadUrl(sessionId: string): string { + return `${AISTUDIO_FILE_URI_HOST}/__gcs-upload-chunk__/${sessionId}`; +} + +function buildGcsSaveOptions(session: Pick) { + return { + contentType: session.mimeType, + metadata: { + contentType: session.mimeType, + metadata: { + 'amc-display-name': session.displayName, + }, + }, + }; +} + +function cleanupSession(session: UploadSessionState): void { + if (session.cleanupTimer) { + clearTimeout(session.cleanupTimer); + session.cleanupTimer = undefined; + } + session.writeStream?.removeAllListeners('error'); +} + +function destroySessionStream(session: UploadSessionState, error: Error): void { + session.streamError = error; + session.writeStream?.destroy(); +} + +async function appendToSession(session: UploadSessionState, chunk: Buffer): Promise { + if (session.writeStream) { + await new Promise((resolve, reject) => { + session.writeStream?.write(chunk, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + return; + } + + session.chunks?.push(chunk); +} + +async function finalizeSessionUpload(session: UploadSessionState, file: StorageFile): Promise { + if (session.writeStream) { + await new Promise((resolve, reject) => { + session.writeStream?.end((error?: Error | null) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + return; + } + + await file.save(Buffer.concat(session.chunks ?? []), buildGcsSaveOptions(session)); +} + +export function createGcsFilesAdapter(options: CreateGcsFilesAdapterOptions): GcsFilesAdapter { + const { storage, config, now = () => new Date(), randomId = () => randomUUID().replace(/-/g, '') } = options; + const sessions = new Map(); + + const buildGcsUriForFileId = (fileId: string): string => `gs://${config.bucketName}/${config.objectPrefix}${fileId}`; + + return { + initiateUpload({ displayName, mimeType, sizeBytes }) { + if (!Number.isFinite(sizeBytes) || sizeBytes < 0) { + throw new Error(`Invalid file size ${sizeBytes}.`); + } + if (sizeBytes > config.maxFileBytes) { + throw new Error(`File size ${sizeBytes} exceeds GCS_MAX_FILE_BYTES ${config.maxFileBytes}.`); + } + + const sessionId = randomId(); + const file = storage.bucket(config.bucketName).file(`${config.objectPrefix}${sessionId}`); + const session: UploadSessionState = { + id: sessionId, + displayName, + mimeType, + totalSize: sizeBytes, + receivedBytes: 0, + }; + if (typeof file.createWriteStream === 'function') { + session.writeStream = file.createWriteStream({ ...buildGcsSaveOptions(session), resumable: true }); + } else { + session.chunks = []; + } + const cleanupTimer = setTimeout(() => { + sessions.delete(sessionId); + destroySessionStream(session, new Error(`Upload session ${sessionId} expired.`)); + cleanupSession(session); + }, UPLOAD_SESSION_TTL_MS); + session.writeStream?.on('error', (error) => { + session.streamError = error instanceof Error ? error : new Error(String(error)); + }); + cleanupTimer.unref?.(); + session.cleanupTimer = cleanupTimer; + sessions.set(sessionId, session); + + return { + sessionId, + uploadUrl: buildChunkUploadUrl(sessionId), + }; + }, + + async uploadChunk({ sessionId, offset, command, chunk }) { + const session = sessions.get(sessionId); + if (!session) { + throw new Error(`Upload session ${sessionId} not found.`); + } + + if (offset !== session.receivedBytes) { + throw new Error(`Unexpected upload offset ${offset}; expected ${session.receivedBytes}.`); + } + + if (session.receivedBytes + chunk.byteLength > config.maxFileBytes) { + sessions.delete(sessionId); + destroySessionStream(session, new Error(`Upload exceeds GCS_MAX_FILE_BYTES ${config.maxFileBytes}.`)); + cleanupSession(session); + throw new Error(`Upload exceeds GCS_MAX_FILE_BYTES ${config.maxFileBytes}.`); + } + + try { + await appendToSession(session, chunk); + } catch (error) { + sessions.delete(sessionId); + destroySessionStream(session, error instanceof Error ? error : new Error(String(error))); + cleanupSession(session); + throw error; + } + if (session.streamError) { + sessions.delete(sessionId); + cleanupSession(session); + throw session.streamError; + } + session.receivedBytes += chunk.byteLength; + + const shouldFinalize = command + .split(',') + .map((c) => c.trim().toLowerCase()) + .includes('finalize'); + + if (!shouldFinalize) { + return { status: 'active' }; + } + + if (session.receivedBytes !== session.totalSize) { + sessions.delete(sessionId); + destroySessionStream(session, new Error('File size mismatch on finalize.')); + cleanupSession(session); + throw new Error( + `File size mismatch on finalize: declared ${session.totalSize}, received ${session.receivedBytes}.`, + ); + } + + const objectPath = `${config.objectPrefix}${session.id}`; + const bucket = storage.bucket(config.bucketName); + const file = bucket.file(objectPath); + try { + await finalizeSessionUpload(session, file); + } finally { + sessions.delete(sessionId); + cleanupSession(session); + } + + const createdAt = now(); + const expirationTime = new Date(createdAt.getTime() + FILE_EXPIRATION_LEEWAY_MS); + const aiStudioFile: AiStudioFile = { + name: buildAiStudioFileName(session.id), + displayName: session.displayName, + mimeType: session.mimeType, + sizeBytes: String(session.totalSize), + createTime: createdAt.toISOString(), + updateTime: createdAt.toISOString(), + expirationTime: expirationTime.toISOString(), + state: 'ACTIVE', + uri: buildAiStudioFileUri(session.id), + }; + + return { status: 'final', file: aiStudioFile }; + }, + + async getFileMetadata(fileId) { + if (!FILE_ID_PATTERN.test(fileId)) { + return null; + } + + const objectPath = `${config.objectPrefix}${fileId}`; + const bucket = storage.bucket(config.bucketName); + const file = bucket.file(objectPath); + + const [exists] = await file.exists(); + if (!exists) { + return null; + } + + const [metadata] = await file.getMetadata(); + const rawSize = metadata.size; + const size = + typeof rawSize === 'number' ? rawSize : Number.parseInt(typeof rawSize === 'string' ? rawSize : '0', 10); + const createTime = metadata.timeCreated ?? now().toISOString(); + const updateTime = metadata.updated ?? createTime; + const rawDisplayName = metadata.metadata?.['amc-display-name']; + const displayName = typeof rawDisplayName === 'string' ? rawDisplayName : undefined; + const mimeType = metadata.contentType ?? 'application/octet-stream'; + const expirationTime = new Date(new Date(createTime).getTime() + FILE_EXPIRATION_LEEWAY_MS).toISOString(); + + return { + name: buildAiStudioFileName(fileId), + displayName, + mimeType, + sizeBytes: String(size), + createTime, + updateTime, + expirationTime, + state: 'ACTIVE', + uri: buildAiStudioFileUri(fileId), + }; + }, + + rewriteFileUriInJsonBody(body) { + const text = body.toString('utf8'); + if (!text.includes(AISTUDIO_FILE_URI_HOST)) { + return body; + } + + const rewritten = text.replace(AISTUDIO_FILE_URI_PATTERN, (_, id) => buildGcsUriForFileId(id)); + + return Buffer.from(rewritten, 'utf8'); + }, + + buildGcsUriForFileId, + }; +} diff --git a/server/src/geminiProxy.ts b/server/src/geminiProxy.ts index deedb28c9..5e666a16a 100644 --- a/server/src/geminiProxy.ts +++ b/server/src/geminiProxy.ts @@ -2,10 +2,20 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import type { ReadableStream as NodeReadableStream } from 'node:stream/web'; +import type { ApiServerConfig } from './config.js'; +import type { GcsFilesAdapter } from './gcsFilesAdapter.js'; import { getCorsHeaders, sendJson } from './cors.js'; +import type { VertexAccessTokenProvider } from './vertexAuth.js'; +import { rewriteToVertex } from './vertexPathRewriter.js'; export const GEMINI_PROXY_PREFIX = '/api/gemini'; +const MAX_REWRITE_BODY_BYTES = 50 * 1024 * 1024; +const MAX_INITIATE_BODY_BYTES = 64 * 1024; +const GCS_UPLOAD_INITIATE_PATH = '/upload/v1beta/files'; +const GCS_UPLOAD_CHUNK_PATTERN = /^\/__gcs-upload-chunk__\/([\w-]+)$/; +const GCS_FILE_METADATA_PATTERN = /^\/v\d+(?:beta\d*|alpha\d*)?\/files\/([\w-]+)$/; + const HOP_BY_HOP_HEADERS = new Set([ 'connection', 'keep-alive', @@ -26,10 +36,29 @@ const STRIPPED_PROXY_REQUEST_HEADERS = new Set([ ]); const STRIPPED_PROXY_RESPONSE_HEADERS = new Set([...HOP_BY_HOP_HEADERS, 'content-encoding', 'content-length']); -export interface GeminiProxyConfig { - geminiApiBase: string; - geminiApiKey?: string; +type GeminiProxyConfig = Pick & { allowedOrigins: string[]; +}; + +interface GeminiProxyDependencies { + fetchImpl: typeof fetch; + vertexAuth?: VertexAccessTokenProvider; + gcsFilesAdapter?: GcsFilesAdapter; +} + +type ProxyAuth = { kind: 'apiKey'; apiKey: string } | { kind: 'bearer'; accessToken: string }; + +class RequestBodyTooLargeError extends Error { + constructor(limit: number) { + super(`Request body exceeds ${limit} bytes.`); + this.name = 'RequestBodyTooLargeError'; + } +} + +interface InitiateUploadMetadata { + displayName: string; + mimeType: string; + sizeBytes: number; } function getConnectionManagedHeaders(value: string | null | undefined): Set { @@ -59,7 +88,7 @@ function resolveRequestApiKey(request: IncomingMessage, serverApiKey?: string): return browserApiKey?.trim() ?? ''; } -function buildProxyHeaders(request: IncomingMessage, apiKey: string): Headers { +function buildProxyHeaders(request: IncomingMessage, auth: ProxyAuth): Headers { const headers = new Headers(); const connectionManagedHeaders = getConnectionManagedHeaders( Array.isArray(request.headers.connection) ? request.headers.connection.join(',') : request.headers.connection, @@ -83,7 +112,13 @@ function buildProxyHeaders(request: IncomingMessage, apiKey: string): Headers { headers.set(normalizedName, value); } - headers.set('x-goog-api-key', apiKey); + if (auth.kind === 'apiKey') { + headers.set('x-goog-api-key', auth.apiKey); + } else { + headers.delete('x-goog-api-key'); + headers.set('authorization', `Bearer ${auth.accessToken}`); + } + return headers; } @@ -108,23 +143,414 @@ function buildProxyResponseHeaders( return responseHeaders; } +async function readBufferedBody(request: IncomingMessage, maxBytes: number): Promise { + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let received = 0; + let aborted = false; + + const onData = (chunk: Buffer) => { + if (aborted) { + return; + } + + received += chunk.byteLength; + if (received > maxBytes) { + aborted = true; + request.off('data', onData); + request.off('end', onEnd); + request.off('error', onError); + reject(new RequestBodyTooLargeError(maxBytes)); + return; + } + + chunks.push(chunk); + }; + + const onEnd = () => { + if (!aborted) { + resolve(Buffer.concat(chunks)); + } + }; + + const onError = (error: unknown) => { + if (!aborted) { + aborted = true; + reject(error instanceof Error ? error : new Error(String(error))); + } + }; + + request.on('data', onData); + request.on('end', onEnd); + request.on('error', onError); + }); +} + +function stripVertexUnsupportedToolConfig(body: Buffer): Buffer { + let parsed: unknown; + try { + parsed = JSON.parse(body.toString('utf8')); + } catch { + return body; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return body; + } + + const root = parsed as Record; + const toolConfig = root.toolConfig; + if (!toolConfig || typeof toolConfig !== 'object' || Array.isArray(toolConfig)) { + return body; + } + + const sanitizedToolConfig = { ...(toolConfig as Record) }; + if (!Object.prototype.hasOwnProperty.call(sanitizedToolConfig, 'includeServerSideToolInvocations')) { + return body; + } + + delete sanitizedToolConfig.includeServerSideToolInvocations; + if (Object.keys(sanitizedToolConfig).length > 0) { + root.toolConfig = sanitizedToolConfig; + } else { + delete root.toolConfig; + } + + return Buffer.from(JSON.stringify(root), 'utf8'); +} + +function isYouTubeUri(value: unknown): boolean { + if (typeof value !== 'string') { + return false; + } + + try { + const hostname = new URL(value).hostname.toLowerCase(); + return ( + hostname === 'youtu.be' || + hostname === 'youtube.com' || + hostname.endsWith('.youtube.com') || + hostname === 'youtube-nocookie.com' || + hostname.endsWith('.youtube-nocookie.com') + ); + } catch { + return false; + } +} + +const sanitizeVertexRequestValue = (value: unknown): boolean => { + if (!value || typeof value !== 'object') { + return false; + } + + if (Array.isArray(value)) { + return value.reduce((changed, item) => sanitizeVertexRequestValue(item) || changed, false); + } + + const record = value as Record; + let changed = false; + const functionResponse = record.functionResponse; + + if (functionResponse && typeof functionResponse === 'object' && !Array.isArray(functionResponse)) { + const responseRecord = functionResponse as Record; + if (Object.prototype.hasOwnProperty.call(responseRecord, 'id')) { + delete responseRecord.id; + changed = true; + } + } + + const fileData = record.fileData; + if (fileData && typeof fileData === 'object' && !Array.isArray(fileData)) { + const fileDataRecord = fileData as Record; + if (!fileDataRecord.mimeType && isYouTubeUri(fileDataRecord.fileUri)) { + fileDataRecord.mimeType = 'video/mp4'; + changed = true; + } + } + + for (const child of Object.values(record)) { + changed = sanitizeVertexRequestValue(child) || changed; + } + + return changed; +}; + +function rewriteVertexRequestBody(body: Buffer, gcsFilesAdapter: GcsFilesAdapter | undefined): Buffer { + const toolConfigSanitizedBody = stripVertexUnsupportedToolConfig(body); + + let parsed: unknown; + try { + parsed = JSON.parse(toolConfigSanitizedBody.toString('utf8')); + } catch { + return gcsFilesAdapter + ? gcsFilesAdapter.rewriteFileUriInJsonBody(toolConfigSanitizedBody) + : toolConfigSanitizedBody; + } + + const vertexSanitizedBody = sanitizeVertexRequestValue(parsed) + ? Buffer.from(JSON.stringify(parsed), 'utf8') + : toolConfigSanitizedBody; + + return gcsFilesAdapter ? gcsFilesAdapter.rewriteFileUriInJsonBody(vertexSanitizedBody) : vertexSanitizedBody; +} + +function parseInitiateUploadMetadata( + body: Buffer, + fallbackMetadata: { mimeType?: string; sizeBytes?: string } = {}, +): InitiateUploadMetadata | null { + let parsed: unknown; + try { + parsed = JSON.parse(body.toString('utf8')); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const fileWrapper = (parsed as { file?: unknown }).file; + const fileRecord = (typeof fileWrapper === 'object' && fileWrapper !== null ? fileWrapper : parsed) as { + displayName?: unknown; + display_name?: unknown; + mimeType?: unknown; + mime_type?: unknown; + sizeBytes?: unknown; + size_bytes?: unknown; + }; + + const displayName = typeof fileRecord.displayName === 'string' ? fileRecord.displayName : fileRecord.display_name; + const mimeType = + typeof fileRecord.mimeType === 'string' + ? fileRecord.mimeType + : typeof fileRecord.mime_type === 'string' + ? fileRecord.mime_type + : fallbackMetadata.mimeType; + const rawSize = + typeof fileRecord.sizeBytes !== 'undefined' + ? fileRecord.sizeBytes + : typeof fileRecord.size_bytes !== 'undefined' + ? fileRecord.size_bytes + : fallbackMetadata.sizeBytes; + const sizeBytes = + typeof rawSize === 'number' ? rawSize : Number.parseInt(typeof rawSize === 'string' ? rawSize : '', 10); + + if (typeof displayName !== 'string' || typeof mimeType !== 'string' || !Number.isFinite(sizeBytes)) { + return null; + } + + return { displayName, mimeType, sizeBytes }; +} + +function readHeader(request: IncomingMessage, name: string): string | undefined { + const value = request.headers[name.toLowerCase()]; + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +async function handleGcsFilesRequest( + request: IncomingMessage, + response: ServerResponse, + upstreamPath: string, + adapter: GcsFilesAdapter, + allowedOrigins: string[], +): Promise { + const method = request.method || 'GET'; + const corsHeaders = getCorsHeaders(request, allowedOrigins); + const uploadCorsHeaders = { + ...corsHeaders, + 'access-control-expose-headers': 'x-goog-upload-url, x-goog-upload-status', + }; + + if ( + method === 'POST' && + (upstreamPath === GCS_UPLOAD_INITIATE_PATH || upstreamPath === `${GCS_UPLOAD_INITIATE_PATH}/`) + ) { + let bodyBuffer: Buffer; + try { + bodyBuffer = await readBufferedBody(request, MAX_INITIATE_BODY_BYTES); + } catch (error) { + const status = error instanceof RequestBodyTooLargeError ? 413 : 400; + sendJson(request, response, status, { error: 'Failed to read initiate body.' }, allowedOrigins); + return true; + } + + const metadata = parseInitiateUploadMetadata(bodyBuffer, { + mimeType: readHeader(request, 'x-goog-upload-header-content-type'), + sizeBytes: readHeader(request, 'x-goog-upload-header-content-length'), + }); + if (!metadata) { + sendJson(request, response, 400, { error: 'Invalid file metadata in upload initiate request.' }, allowedOrigins); + return true; + } + + let initiated: ReturnType; + try { + initiated = adapter.initiateUpload(metadata); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown initiate error'; + sendJson(request, response, 413, { error: message }, allowedOrigins); + return true; + } + + response.writeHead(200, { + ...uploadCorsHeaders, + 'x-goog-upload-url': initiated.uploadUrl, + 'x-goog-upload-status': 'active', + 'content-type': 'application/json; charset=utf-8', + }); + response.end('{}'); + return true; + } + + const chunkMatch = GCS_UPLOAD_CHUNK_PATTERN.exec(upstreamPath); + if (method === 'POST' && chunkMatch) { + const sessionId = chunkMatch[1]; + const offsetHeader = readHeader(request, 'x-goog-upload-offset'); + const commandHeader = readHeader(request, 'x-goog-upload-command'); + const offset = Number.parseInt(offsetHeader ?? '', 10); + if (!Number.isFinite(offset) || offset < 0 || !commandHeader) { + sendJson(request, response, 400, { error: 'Missing or invalid upload offset/command headers.' }, allowedOrigins); + return true; + } + + let chunk: Buffer; + try { + chunk = await readBufferedBody(request, MAX_REWRITE_BODY_BYTES); + } catch (error) { + const status = error instanceof RequestBodyTooLargeError ? 413 : 400; + sendJson(request, response, status, { error: 'Upload chunk exceeded size limit.' }, allowedOrigins); + return true; + } + + let result: Awaited>; + try { + result = await adapter.uploadChunk({ sessionId, offset, command: commandHeader, chunk }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown chunk upload error'; + sendJson(request, response, 400, { error: message }, allowedOrigins); + return true; + } + + if (result.file) { + response.writeHead(200, { + ...uploadCorsHeaders, + 'x-goog-upload-status': 'final', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify({ file: result.file })); + return true; + } + + response.writeHead(200, { ...uploadCorsHeaders, 'x-goog-upload-status': 'active' }); + response.end(); + return true; + } + + const getMatch = GCS_FILE_METADATA_PATTERN.exec(upstreamPath); + if (method === 'GET' && getMatch) { + const fileId = getMatch[1]; + const metadata = await adapter.getFileMetadata(fileId); + if (!metadata) { + sendJson(request, response, 404, { error: 'File not found.' }, allowedOrigins); + return true; + } + sendJson(request, response, 200, metadata as unknown as Record, allowedOrigins); + return true; + } + + return false; +} + export async function proxyGeminiRequest( request: IncomingMessage, response: ServerResponse, config: GeminiProxyConfig, - fetchImpl: typeof fetch, + dependencies: GeminiProxyDependencies, ): Promise { - const apiKeyForProxy = resolveRequestApiKey(request, config.geminiApiKey); + const { fetchImpl, vertexAuth, gcsFilesAdapter } = dependencies; + const requestUrl = new URL(request.url || '/', 'http://localhost'); + const upstreamPath = requestUrl.pathname.slice(GEMINI_PROXY_PREFIX.length) || '/'; - if (!apiKeyForProxy) { - sendJson(request, response, 500, { error: 'GEMINI_API_KEY is not configured.' }, config.allowedOrigins); - return; + if (config.backendFlavor === 'vertex') { + const looksLikeFilesRoute = + upstreamPath === GCS_UPLOAD_INITIATE_PATH || + upstreamPath === `${GCS_UPLOAD_INITIATE_PATH}/` || + GCS_UPLOAD_CHUNK_PATTERN.test(upstreamPath) || + GCS_FILE_METADATA_PATTERN.test(upstreamPath); + + if (looksLikeFilesRoute) { + if (!gcsFilesAdapter) { + sendJson( + request, + response, + 503, + { error: 'GCS Files adapter is not configured; set GCS_BUCKET to enable Files API in vertex mode.' }, + config.allowedOrigins, + ); + return; + } + + const handled = await handleGcsFilesRequest( + request, + response, + upstreamPath, + gcsFilesAdapter, + config.allowedOrigins, + ); + if (handled) { + return; + } + } + } + + let upstreamUrl: string; + let auth: ProxyAuth; + let isModelInvocation = false; + + if (config.backendFlavor === 'vertex') { + if (!config.vertex) { + sendJson(request, response, 500, { error: 'Vertex backend config is missing.' }, config.allowedOrigins); + return; + } + if (!vertexAuth) { + sendJson(request, response, 500, { error: 'Vertex auth provider is not configured.' }, config.allowedOrigins); + return; + } + + let accessToken: string; + try { + accessToken = await vertexAuth.getAccessToken(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown auth error'; + sendJson( + request, + response, + 500, + { error: `Vertex access token retrieval failed: ${message}` }, + config.allowedOrigins, + ); + return; + } + + const rewritten = rewriteToVertex(upstreamPath, requestUrl.search, config.vertex); + upstreamUrl = rewritten.url; + isModelInvocation = rewritten.isModelInvocation; + auth = { kind: 'bearer', accessToken }; + } else { + const apiKeyForProxy = resolveRequestApiKey(request, config.geminiApiKey); + if (!apiKeyForProxy) { + sendJson(request, response, 500, { error: 'GEMINI_API_KEY is not configured.' }, config.allowedOrigins); + return; + } + + const targetBase = config.geminiApiBase.replace(/\/$/, ''); + upstreamUrl = `${targetBase}${upstreamPath}${requestUrl.search}`; + auth = { kind: 'apiKey', apiKey: apiKeyForProxy }; } - const requestUrl = new URL(request.url || '/', 'http://localhost'); - const upstreamPath = requestUrl.pathname.slice(GEMINI_PROXY_PREFIX.length) || '/'; - const targetBase = config.geminiApiBase.replace(/\/$/, ''); - const upstreamUrl = `${targetBase}${upstreamPath}${requestUrl.search}`; const method = request.method || 'GET'; const hasBody = !['GET', 'HEAD'].includes(method); const abortController = new AbortController(); @@ -136,14 +562,41 @@ export async function proxyGeminiRequest( const requestInit: RequestInit & { duplex?: 'half' } = { method, - headers: buildProxyHeaders(request, apiKeyForProxy), + headers: buildProxyHeaders(request, auth), signal: abortController.signal, // redirect: 'manual' so a public GEMINI_API_BASE cannot 302 into a private network host // after the input URL passed validation. redirect: 'manual', }; - if (hasBody) { + const shouldRewriteBody = hasBody && config.backendFlavor === 'vertex' && isModelInvocation; + + if (shouldRewriteBody) { + let bodyBuffer: Buffer; + try { + bodyBuffer = await readBufferedBody(request, MAX_REWRITE_BODY_BYTES); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + sendJson( + request, + response, + 413, + { error: `Request body exceeds ${MAX_REWRITE_BODY_BYTES} bytes; cannot rewrite file URIs.` }, + config.allowedOrigins, + ); + return; + } + const message = error instanceof Error ? error.message : 'Failed to read request body.'; + sendJson(request, response, 400, { error: message }, config.allowedOrigins); + return; + } + + const rewrittenBody = rewriteVertexRequestBody(bodyBuffer, gcsFilesAdapter); + const rewrittenBodyView = new Uint8Array(rewrittenBody.buffer, rewrittenBody.byteOffset, rewrittenBody.byteLength); + requestInit.body = rewrittenBodyView as unknown as BodyInit; + requestInit.headers = new Headers(requestInit.headers); + (requestInit.headers as Headers).set('content-length', String(rewrittenBody.byteLength)); + } else if (hasBody) { requestInit.body = request as unknown as BodyInit; requestInit.duplex = 'half'; } @@ -175,7 +628,13 @@ export async function proxyGeminiRequest( request.off('aborted', abortUpstream); response.off('close', abortUpstream); console.error('[gemini] upstream returned redirect:', upstreamResponse.status); - sendJson(request, response, 502, { error: 'Gemini upstream returned an unexpected redirect.' }, config.allowedOrigins); + sendJson( + request, + response, + 502, + { error: 'Gemini upstream returned an unexpected redirect.' }, + config.allowedOrigins, + ); return; } diff --git a/server/src/index.ts b/server/src/index.ts index 1725a0a7f..ae6aa2276 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,9 +1,26 @@ +import { Storage } from '@google-cloud/storage'; import { loadConfig } from './config.js'; import { createServer } from './createServer.js'; +import { createGcsFilesAdapter } from './gcsFilesAdapter.js'; +import { createVertexAuth } from './vertexAuth.js'; const config = loadConfig(); -const server = createServer(config); +const vertexAuth = config.backendFlavor === 'vertex' ? createVertexAuth() : undefined; +const gcsFilesAdapter = + config.backendFlavor === 'vertex' && config.gcs + ? createGcsFilesAdapter({ + storage: new Storage({ projectId: config.vertex?.projectId }), + config: config.gcs, + }) + : undefined; +const server = createServer(config, { vertexAuth, gcsFilesAdapter }); server.listen(config.port, '0.0.0.0', () => { - console.log(`API server listening on port ${config.port}`); + const features = [ + `backend: ${config.backendFlavor}`, + config.backendFlavor === 'vertex' && config.gcs ? `gcs-files: ${config.gcs.bucketName}` : null, + ] + .filter(Boolean) + .join(', '); + console.log(`API server listening on port ${config.port} (${features})`); }); diff --git a/server/src/vertexAuth.test.ts b/server/src/vertexAuth.test.ts new file mode 100644 index 000000000..d1f706e04 --- /dev/null +++ b/server/src/vertexAuth.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createVertexAuth } from './vertexAuth'; + +const makeAuth = (tokens: Array<{ token: string; expiry?: number | null }>) => { + let i = 0; + return { + async getAccessToken() { + return { token: tokens[i]?.token }; + }, + async getClient() { + const current = tokens[i] ?? tokens[tokens.length - 1]; + i += 1; + return { credentials: { expiry_date: current?.expiry ?? null } }; + }, + }; +}; + +describe('createVertexAuth', () => { + it('returns the access token from google-auth-library', async () => { + const provider = createVertexAuth({ + auth: makeAuth([{ token: 'token-1', expiry: Date.now() + 60 * 60 * 1000 }]), + }); + + expect(await provider.getAccessToken()).toBe('token-1'); + }); + + it('caches the token until the refresh leeway approaches expiry', async () => { + const now = vi.fn(() => 1_000_000); + const auth = makeAuth([ + { token: 'token-cache', expiry: 1_000_000 + 60 * 60 * 1000 }, + { token: 'token-refreshed', expiry: 1_000_000 + 120 * 60 * 1000 }, + ]); + const spy = vi.spyOn(auth, 'getAccessToken'); + + const provider = createVertexAuth({ auth, now }); + expect(await provider.getAccessToken()).toBe('token-cache'); + expect(await provider.getAccessToken()).toBe('token-cache'); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('refreshes when the cached token is within the refresh leeway', async () => { + let nowMs = 1_000_000; + const tokens = [ + { token: 'stale', expiry: nowMs + 60_000 }, + { token: 'fresh', expiry: nowMs + 60 * 60 * 1000 }, + ]; + const auth = makeAuth(tokens); + const provider = createVertexAuth({ auth, now: () => nowMs }); + + expect(await provider.getAccessToken()).toBe('stale'); + + nowMs += 30_000; + expect(await provider.getAccessToken()).toBe('fresh'); + }); + + it('throws when google-auth-library returns no token', async () => { + const provider = createVertexAuth({ + auth: { + async getAccessToken() { + return { token: null }; + }, + async getClient() { + return { credentials: {} }; + }, + }, + }); + + await expect(provider.getAccessToken()).rejects.toThrow(/empty access token/); + }); + + it('accepts the bare-string token shape that real GoogleAuth.getAccessToken returns', async () => { + const provider = createVertexAuth({ + auth: { + async getAccessToken() { + return 'ya29.real-shape-token'; + }, + async getClient() { + return { credentials: { expiry_date: Date.now() + 60 * 60 * 1000 } }; + }, + }, + }); + + expect(await provider.getAccessToken()).toBe('ya29.real-shape-token'); + }); + + it('coalesces concurrent token fetches into a single inflight request', async () => { + let resolveFetch: ((value: { token: string }) => void) | undefined; + const auth = { + getAccessToken: vi.fn( + () => + new Promise<{ token: string }>((resolve) => { + resolveFetch = resolve; + }), + ), + getClient: vi.fn(async () => ({ credentials: { expiry_date: Date.now() + 60 * 60 * 1000 } })), + }; + + const provider = createVertexAuth({ auth }); + const a = provider.getAccessToken(); + const b = provider.getAccessToken(); + + expect(auth.getAccessToken).toHaveBeenCalledTimes(1); + resolveFetch?.({ token: 'concurrent' }); + + expect(await a).toBe('concurrent'); + expect(await b).toBe('concurrent'); + }); +}); diff --git a/server/src/vertexAuth.ts b/server/src/vertexAuth.ts new file mode 100644 index 000000000..e81ce193e --- /dev/null +++ b/server/src/vertexAuth.ts @@ -0,0 +1,72 @@ +import { GoogleAuth } from 'google-auth-library'; + +const TOKEN_REFRESH_LEEWAY_MS = 5 * 60 * 1000; +const DEFAULT_SCOPES = ['https://www.googleapis.com/auth/cloud-platform']; + +export interface VertexAccessTokenProvider { + getAccessToken(): Promise; +} + +interface CachedToken { + token: string; + expiresAtMs: number; +} + +interface GoogleAuthLike { + getAccessToken(): Promise; + getClient(): Promise<{ + credentials?: { expiry_date?: number | null }; + }>; +} + +interface CreateVertexAuthOptions { + auth?: GoogleAuthLike; + now?: () => number; +} + +export function createVertexAuth(options: CreateVertexAuthOptions = {}): VertexAccessTokenProvider { + const auth = options.auth ?? (new GoogleAuth({ scopes: DEFAULT_SCOPES }) as unknown as GoogleAuthLike); + const now = options.now ?? (() => Date.now()); + + let cached: CachedToken | null = null; + let inflight: Promise | null = null; + + const fetchFreshToken = async (): Promise => { + const tokenResult = await auth.getAccessToken(); + const token = typeof tokenResult === 'string' ? tokenResult : tokenResult?.token; + if (!token) { + throw new Error('Vertex auth returned an empty access token.'); + } + + const client = await auth.getClient(); + const expiryFromCredentials = client.credentials?.expiry_date; + const expiresAtMs = + typeof expiryFromCredentials === 'number' && expiryFromCredentials > 0 + ? expiryFromCredentials + : now() + 55 * 60 * 1000; + + return { token, expiresAtMs }; + }; + + return { + async getAccessToken(): Promise { + if (cached && cached.expiresAtMs - now() > TOKEN_REFRESH_LEEWAY_MS) { + return cached.token; + } + + if (!inflight) { + inflight = fetchFreshToken() + .then((next) => { + cached = next; + return next; + }) + .finally(() => { + inflight = null; + }); + } + + const result = await inflight; + return result.token; + }, + }; +} diff --git a/server/src/vertexPathRewriter.test.ts b/server/src/vertexPathRewriter.test.ts new file mode 100644 index 000000000..0f79a5c97 --- /dev/null +++ b/server/src/vertexPathRewriter.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { mapModelId, rewriteToVertex } from './vertexPathRewriter'; +import type { VertexBackendConfig } from './config'; + +const vertex: VertexBackendConfig = { projectId: 'my-proj', location: 'us-central1' }; + +describe('mapModelId', () => { + it('maps Imagen 4 AI Studio IDs to Vertex Imagen 3 IDs', () => { + expect(mapModelId('imagen-4.0-generate-001')).toBe('imagen-3.0-generate-002'); + expect(mapModelId('imagen-4.0-fast-generate-001')).toBe('imagen-3.0-fast-generate-001'); + expect(mapModelId('imagen-4.0-ultra-generate-001')).toBe('imagen-3.0-ultra-generate-002'); + }); + + it('passes through unmapped IDs unchanged', () => { + expect(mapModelId('gemini-2.5-flash')).toBe('gemini-2.5-flash'); + expect(mapModelId('gemini-2.5-pro')).toBe('gemini-2.5-pro'); + }); +}); + +describe('rewriteToVertex', () => { + it('rewrites generateContent invocations to the Vertex publisher path', () => { + const result = rewriteToVertex('/v1beta/models/gemini-2.5-flash:generateContent', '', vertex); + + expect(result.url).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/publishers/google/models/gemini-2.5-flash:generateContent', + ); + expect(result.isModelInvocation).toBe(true); + }); + + it('preserves the search string on streamGenerateContent', () => { + const result = rewriteToVertex('/v1beta/models/gemini-2.5-pro:streamGenerateContent', '?alt=sse', vertex); + + expect(result.url).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/publishers/google/models/gemini-2.5-pro:streamGenerateContent?alt=sse', + ); + }); + + it('maps Imagen IDs through the model ID map during rewrite', () => { + const result = rewriteToVertex('/v1beta/models/imagen-4.0-generate-001:predict', '', vertex); + + expect(result.url).toContain('publishers/google/models/imagen-3.0-generate-002:predict'); + }); + + it('routes non-model paths to the projects/locations prefix', () => { + const result = rewriteToVertex('/v1beta/models', '', vertex); + + expect(result.url).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/models', + ); + expect(result.isModelInvocation).toBe(false); + }); + + it('respects a non-default location in both host and path', () => { + const result = rewriteToVertex('/v1beta/models/gemini-2.5-flash:generateContent', '', { + projectId: 'p', + location: 'europe-west4', + }); + + expect(result.url).toBe( + 'https://europe-west4-aiplatform.googleapis.com/v1/projects/p/locations/europe-west4/publishers/google/models/gemini-2.5-flash:generateContent', + ); + }); + + it('uses the unprefixed aiplatform host when location is global', () => { + const result = rewriteToVertex('/v1beta/models/gemini-2.5-flash:generateContent', '', { + projectId: 'p', + location: 'global', + }); + + expect(result.url).toBe( + 'https://aiplatform.googleapis.com/v1/projects/p/locations/global/publishers/google/models/gemini-2.5-flash:generateContent', + ); + }); +}); diff --git a/server/src/vertexPathRewriter.ts b/server/src/vertexPathRewriter.ts new file mode 100644 index 000000000..4014433e0 --- /dev/null +++ b/server/src/vertexPathRewriter.ts @@ -0,0 +1,42 @@ +import type { VertexBackendConfig } from './config.js'; + +const VERTEX_HOST_SUFFIX = '-aiplatform.googleapis.com'; +const AISTUDIO_VERSION_PREFIX_PATTERN = /^\/v\d+(?:(?:alpha|beta)\d*|\.\d+)?\//; + +const MODEL_ID_MAP: Record = { + 'imagen-4.0-generate-001': 'imagen-3.0-generate-002', + 'imagen-4.0-fast-generate-001': 'imagen-3.0-fast-generate-001', + 'imagen-4.0-ultra-generate-001': 'imagen-3.0-ultra-generate-002', +}; + +export function mapModelId(aiStudioModelId: string): string { + return MODEL_ID_MAP[aiStudioModelId] ?? aiStudioModelId; +} + +interface RewriteResult { + url: string; + isModelInvocation: boolean; +} + +export function rewriteToVertex(upstreamPath: string, search: string, vertex: VertexBackendConfig): RewriteResult { + const host = vertex.location === 'global' ? 'aiplatform.googleapis.com' : `${vertex.location}${VERTEX_HOST_SUFFIX}`; + const normalizedPath = upstreamPath.startsWith('/') ? upstreamPath : `/${upstreamPath}`; + + const withoutVersion = normalizedPath.replace(AISTUDIO_VERSION_PREFIX_PATTERN, '/'); + + const modelInvocationMatch = withoutVersion.match(/^\/models\/([^/:]+)(:[^/]+)$/); + if (modelInvocationMatch) { + const [, modelId, methodSuffix] = modelInvocationMatch; + const mappedModel = mapModelId(modelId); + const vertexPath = `/v1/projects/${vertex.projectId}/locations/${vertex.location}/publishers/google/models/${mappedModel}${methodSuffix}`; + return { + url: `https://${host}${vertexPath}${search}`, + isModelInvocation: true, + }; + } + + return { + url: `https://${host}/v1/projects/${vertex.projectId}/locations/${vertex.location}${withoutVersion}${search}`, + isModelInvocation: false, + }; +} diff --git a/server/src/vertexProxy.test.ts b/server/src/vertexProxy.test.ts new file mode 100644 index 000000000..2e0e3ad47 --- /dev/null +++ b/server/src/vertexProxy.test.ts @@ -0,0 +1,321 @@ +// @vitest-environment node +import { Buffer } from 'node:buffer'; +import { Writable } from 'node:stream'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createHttpServerCleanup, startHttpServer } from '../test/httpServer'; +import { createServer } from './createServer'; +import { createGcsFilesAdapter, type StorageLike } from './gcsFilesAdapter'; + +function createInMemoryAdapter() { + const files = new Map }>(); + const storage: StorageLike = { + bucket: (bucketName: string) => ({ + file: (path: string) => { + const key = `${bucketName}/${path}`; + return { + save: async (data: Buffer, options) => { + files.set(key, { + data, + contentType: options.metadata?.contentType ?? options.contentType ?? 'application/octet-stream', + metadata: options.metadata?.metadata ?? {}, + }); + }, + createWriteStream: (options) => { + const chunks: Buffer[] = []; + return new Writable({ + write: (chunk, _encoding, callback) => { + chunks.push(Buffer.from(chunk)); + callback(); + }, + final: (callback) => { + files.set(key, { + data: Buffer.concat(chunks), + contentType: options.metadata?.contentType ?? options.contentType ?? 'application/octet-stream', + metadata: options.metadata?.metadata ?? {}, + }); + callback(); + }, + }); + }, + getMetadata: async () => { + const file = files.get(key); + if (!file) { + throw new Error('not found'); + } + return [ + { + size: file.data.byteLength, + contentType: file.contentType, + metadata: file.metadata, + timeCreated: '2026-05-18T00:00:00.000Z', + updated: '2026-05-18T00:00:00.000Z', + }, + ]; + }, + exists: async () => [files.has(key)], + }; + }, + }), + }; + + return createGcsFilesAdapter({ + storage, + config: { bucketName: 'test-bucket', objectPrefix: 'amc-files/', maxFileBytes: 1024 * 1024 }, + randomId: () => 'test-id', + now: () => new Date('2026-05-18T12:00:00.000Z'), + }); +} + +const serverCleanup = createHttpServerCleanup(); + +afterEach(serverCleanup.cleanup); + +const vertexConfig = { + geminiApiBase: 'https://generativelanguage.googleapis.com', + backendFlavor: 'vertex' as const, + vertex: { projectId: 'my-proj', location: 'us-central1' }, +}; + +describe('Vertex Gemini proxy', () => { + it('rewrites model requests and replaces API-key auth with a bearer token', async () => { + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => { + return new Response('vertex-ok', { status: 200 }); + }); + const vertexAuth = { getAccessToken: vi.fn(async () => 'access-token-123') }; + const app = createServer(vertexConfig, { fetchImpl, vertexAuth }); + const started = serverCleanup.track(await startHttpServer(app)); + + const response = await fetch( + `${started.baseUrl}/api/gemini/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse`, + { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-goog-api-key': 'browser-key' }, + body: JSON.stringify({ contents: [] }), + }, + ); + + expect(response.status).toBe(200); + expect(vertexAuth.getAccessToken).toHaveBeenCalledTimes(1); + + const [url, init] = fetchImpl.mock.calls[0]; + expect(String(url)).toBe( + 'https://us-central1-aiplatform.googleapis.com/v1/projects/my-proj/locations/us-central1/publishers/google/models/gemini-2.5-flash:streamGenerateContent?alt=sse', + ); + expect(init?.headers).toBeInstanceOf(Headers); + const headers = init?.headers as Headers; + expect(headers.get('authorization')).toBe('Bearer access-token-123'); + expect(headers.get('x-goog-api-key')).toBeNull(); + }); + + it('sanitizes AI Studio-only request fields before forwarding to Vertex', async () => { + const capturedBodies: string[] = []; + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body instanceof Uint8Array ? Buffer.from(init.body).toString('utf8') : ''; + capturedBodies.push(body); + return new Response('{}', { status: 200 }); + }); + const app = createServer(vertexConfig, { + fetchImpl, + vertexAuth: { getAccessToken: vi.fn(async () => 'token') }, + }); + const started = serverCleanup.track(await startHttpServer(app)); + + await fetch(`${started.baseUrl}/api/gemini/v1beta/models/gemini-3-flash-preview:generateContent`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'run_local_python', + response: { output: '42' }, + }, + }, + { fileData: { fileUri: 'https://www.youtube.com/watch?v=abc' } }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: 'AUTO' }, + includeServerSideToolInvocations: true, + }, + }), + }); + + expect(JSON.parse(capturedBodies[0])).toEqual({ + contents: [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'run_local_python', + response: { output: '42' }, + }, + }, + { + fileData: { + fileUri: 'https://www.youtube.com/watch?v=abc', + mimeType: 'video/mp4', + }, + }, + ], + }, + ], + toolConfig: { + functionCallingConfig: { mode: 'AUTO' }, + }, + }); + }); + + it('returns an actionable error when Vertex authentication fails', async () => { + const fetchImpl = vi.fn(); + const app = createServer(vertexConfig, { + fetchImpl, + vertexAuth: { + getAccessToken: vi.fn(async () => { + throw new Error('credential file missing'); + }), + }, + }); + const started = serverCleanup.track(await startHttpServer(app)); + + const response = await fetch(`${started.baseUrl}/api/gemini/v1beta/models/gemini-2.5-flash:generateContent`, { + method: 'POST', + body: '{}', + }); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(500); + expect(body.error).toMatch(/credential file missing/); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('returns 500 when the Vertex auth provider is not wired', async () => { + const app = createServer(vertexConfig, { fetchImpl: vi.fn() }); + const started = serverCleanup.track(await startHttpServer(app)); + + const response = await fetch(`${started.baseUrl}/api/gemini/v1beta/models/gemini-2.5-flash:generateContent`, { + method: 'POST', + body: '{}', + }); + const body = (await response.json()) as { error: string }; + + expect(response.status).toBe(500); + expect(body.error).toMatch(/Vertex auth provider/); + }); +}); + +describe('Vertex GCS Files adapter routing', () => { + it('returns 503 for Files API requests when GCS is not configured', async () => { + const app = createServer(vertexConfig, { + vertexAuth: { getAccessToken: vi.fn(async () => 'token') }, + }); + const started = serverCleanup.track(await startHttpServer(app)); + + const response = await fetch(`${started.baseUrl}/api/gemini/upload/v1beta/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ file: { displayName: 'a', mimeType: 'text/plain', sizeBytes: '1' } }), + }); + + expect(response.status).toBe(503); + }); + + it('adapts resumable upload, metadata, and generateContent file references', async () => { + const capturedBodies: string[] = []; + const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body instanceof Uint8Array ? Buffer.from(init.body).toString('utf8') : ''; + capturedBodies.push(body); + return new Response('{}', { status: 200 }); + }); + const app = createServer( + { ...vertexConfig, allowedOrigins: ['https://web.example'] }, + { + fetchImpl, + vertexAuth: { getAccessToken: vi.fn(async () => 'token') }, + gcsFilesAdapter: createInMemoryAdapter(), + }, + ); + const started = serverCleanup.track(await startHttpServer(app)); + + const initiate = await fetch(`${started.baseUrl}/api/gemini/upload/v1beta/files`, { + method: 'POST', + headers: { + origin: 'https://web.example', + 'content-type': 'application/json', + 'x-goog-upload-header-content-length': '6', + 'x-goog-upload-header-content-type': 'application/octet-stream', + }, + body: JSON.stringify({ file: { displayName: 'doc.bin' } }), + }); + expect(initiate.status).toBe(200); + expect(initiate.headers.get('access-control-expose-headers')).toContain('x-goog-upload-url'); + const uploadUrl = initiate.headers.get('x-goog-upload-url'); + expect(uploadUrl).toMatch(/__gcs-upload-chunk__\/test-id$/); + + const firstChunk = await fetch(`${started.baseUrl}/api/gemini/__gcs-upload-chunk__/test-id`, { + method: 'POST', + headers: { 'x-goog-upload-offset': '0', 'x-goog-upload-command': 'upload' }, + body: Buffer.from('foo'), + }); + expect(firstChunk.headers.get('x-goog-upload-status')).toBe('active'); + + const finalChunk = await fetch(`${started.baseUrl}/api/gemini/__gcs-upload-chunk__/test-id`, { + method: 'POST', + headers: { 'x-goog-upload-offset': '3', 'x-goog-upload-command': 'upload, finalize' }, + body: Buffer.from('bar'), + }); + const finalBody = (await finalChunk.json()) as { file: { name: string; uri: string; state: string } }; + expect(finalChunk.headers.get('x-goog-upload-status')).toBe('final'); + expect(finalBody.file).toMatchObject({ + name: 'files/test-id', + uri: 'https://generativelanguage.googleapis.com/v1beta/files/test-id', + state: 'ACTIVE', + }); + + const metadata = await fetch(`${started.baseUrl}/api/gemini/v1beta/files/test-id`); + expect(metadata.status).toBe(200); + expect(await metadata.json()).toMatchObject({ name: 'files/test-id', state: 'ACTIVE' }); + + const generateResponse = await fetch( + `${started.baseUrl}/api/gemini/v1beta/models/gemini-2.5-flash:generateContent`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + contents: [ + { + role: 'user', + parts: [{ fileData: { mimeType: 'application/octet-stream', fileUri: finalBody.file.uri } }], + }, + ], + }), + }, + ); + + expect(generateResponse.status).toBe(200); + expect(capturedBodies).toHaveLength(1); + expect(capturedBodies[0]).toContain('"fileUri":"gs://test-bucket/amc-files/test-id"'); + }); + + it('rejects malformed upload metadata', async () => { + const app = createServer(vertexConfig, { + vertexAuth: { getAccessToken: vi.fn(async () => 'token') }, + gcsFilesAdapter: createInMemoryAdapter(), + }); + const started = serverCleanup.track(await startHttpServer(app)); + + const response = await fetch(`${started.baseUrl}/api/gemini/upload/v1beta/files`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ file: { displayName: 'missing-metadata' } }), + }); + + expect(response.status).toBe(400); + }); +}); From 242008bd19f01c72a41054eb029ce7ba0c9ab3fb Mon Sep 17 00:00:00 2001 From: Xichun123 Date: Fri, 10 Jul 2026 11:58:34 +0800 Subject: [PATCH 2/3] feat(vertex): route frontend traffic through the server proxy --- .dockerignore | 2 + .env.aistudio.example | 15 ++ .env.byok.example | 15 ++ .env.example | 18 +++ .env.vertex.example | 23 +++ docker-compose.yml | 11 ++ docker/empty-secrets/.gitkeep | 0 docker/web-server.js | 7 +- package.json | 1 + public/runtime-config.js | 2 + scripts/switch-backend.sh | 171 ++++++++++++++++++++++ secrets/.gitignore | 4 + src/runtime/runtimeConfig.test.ts | 86 ++++++++++- src/runtime/runtimeConfig.ts | 25 +++- src/services/api/apiClient.test.ts | 54 +++++++ src/services/api/apiClient.ts | 24 ++- src/services/api/geminiApiBaseUrl.test.ts | 10 ++ src/services/api/geminiApiBaseUrl.ts | 9 +- src/stores/settingsStore.test.ts | 59 +++++++- src/stores/settingsStore.ts | 3 + src/utils/apiProxyUrl.ts | 15 +- 21 files changed, 544 insertions(+), 10 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.aistudio.example create mode 100644 .env.byok.example create mode 100644 .env.vertex.example create mode 100644 docker/empty-secrets/.gitkeep create mode 100755 scripts/switch-backend.sh create mode 100644 secrets/.gitignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..220a483e9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +# Never send local cloud credentials to the Docker build context. +secrets/ diff --git a/.env.aistudio.example b/.env.aistudio.example new file mode 100644 index 000000000..1c031f1f5 --- /dev/null +++ b/.env.aistudio.example @@ -0,0 +1,15 @@ +# AI Studio server-managed mode: the API key stays in the API container. +GEMINI_BACKEND=aistudio +GEMINI_API_KEY= +GEMINI_API_BASE=https://generativelanguage.googleapis.com +GOOGLE_APPLICATION_CREDENTIALS_DIR=./docker/empty-secrets +GOOGLE_APPLICATION_CREDENTIALS= + +WEB_PORT=127.0.0.1:8080 +RUNTIME_SERVER_MANAGED_API=true +RUNTIME_USE_CUSTOM_API_CONFIG=true +RUNTIME_USE_API_PROXY=true +RUNTIME_API_PROXY_URL=/api/gemini +RUNTIME_PYODIDE_BASE_URL= +RUNTIME_BACKEND_FLAVOR=aistudio +RUNTIME_ENFORCE_API_CONFIG=true diff --git a/.env.byok.example b/.env.byok.example new file mode 100644 index 000000000..3d0d8e603 --- /dev/null +++ b/.env.byok.example @@ -0,0 +1,15 @@ +# AI Studio BYOK mode: each browser supplies its own Gemini API key. +GEMINI_BACKEND=aistudio +GEMINI_API_KEY= +GEMINI_API_BASE=https://generativelanguage.googleapis.com +GOOGLE_APPLICATION_CREDENTIALS_DIR=./docker/empty-secrets +GOOGLE_APPLICATION_CREDENTIALS= + +WEB_PORT=127.0.0.1:8080 +RUNTIME_SERVER_MANAGED_API=false +RUNTIME_USE_CUSTOM_API_CONFIG=true +RUNTIME_USE_API_PROXY=true +RUNTIME_API_PROXY_URL=/api/gemini +RUNTIME_PYODIDE_BASE_URL= +RUNTIME_BACKEND_FLAVOR=aistudio +RUNTIME_ENFORCE_API_CONFIG=true diff --git a/.env.example b/.env.example index 29705305b..db47d1b36 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,21 @@ PORT=3001 ENABLE_MCP_STDIO=false ENABLE_MCP_PRIVATE_HTTP=false +# Vertex AI backend. Keep aistudio for the existing API-key flow. +# For Docker, put the Service Account JSON at ./secrets/sa.json, set +# GOOGLE_APPLICATION_CREDENTIALS_DIR=./secrets, and use the container path below. +# AI Studio/BYOK profiles should keep the default empty credentials directory. +GEMINI_BACKEND=aistudio +GCP_PROJECT_ID= +GCP_LOCATION=us-central1 +GOOGLE_APPLICATION_CREDENTIALS_DIR=./docker/empty-secrets +GOOGLE_APPLICATION_CREDENTIALS= + +# Optional Gemini Files API -> GCS adapter for Vertex mode. +GCS_BUCKET= +GCS_OBJECT_PREFIX=amc-files/ +GCS_MAX_FILE_BYTES=2147483648 + # Web container WEB_PORT=8080 @@ -21,3 +36,6 @@ RUNTIME_USE_CUSTOM_API_CONFIG=true RUNTIME_USE_API_PROXY=true RUNTIME_API_PROXY_URL=/api/gemini RUNTIME_PYODIDE_BASE_URL= +RUNTIME_BACKEND_FLAVOR=aistudio +# Set true only for deployment profiles that must override stale browser API settings. +RUNTIME_ENFORCE_API_CONFIG=false diff --git a/.env.vertex.example b/.env.vertex.example new file mode 100644 index 000000000..dba9ab097 --- /dev/null +++ b/.env.vertex.example @@ -0,0 +1,23 @@ +# Vertex AI: the API container authenticates with a Google Cloud Service Account. +GEMINI_BACKEND=vertex +GEMINI_API_KEY= +GEMINI_API_BASE=https://generativelanguage.googleapis.com + +GCP_PROJECT_ID=your-gcp-project-id +GCP_LOCATION=global +GOOGLE_APPLICATION_CREDENTIALS_DIR=./secrets +GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/sa.json + +# Optional: enables Gemini Files API compatibility through GCS. +GCS_BUCKET= +GCS_OBJECT_PREFIX=amc-files/ +GCS_MAX_FILE_BYTES=2147483648 + +WEB_PORT=127.0.0.1:8080 +RUNTIME_SERVER_MANAGED_API=true +RUNTIME_USE_CUSTOM_API_CONFIG=true +RUNTIME_USE_API_PROXY=true +RUNTIME_API_PROXY_URL=/api/gemini +RUNTIME_PYODIDE_BASE_URL= +RUNTIME_BACKEND_FLAVOR=vertex +RUNTIME_ENFORCE_API_CONFIG=true diff --git a/docker-compose.yml b/docker-compose.yml index 0d61c14d0..77b5c1047 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,17 @@ services: GEMINI_API_KEY: ${GEMINI_API_KEY:-} GEMINI_API_BASE: ${GEMINI_API_BASE:-https://generativelanguage.googleapis.com} ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-} + GEMINI_BACKEND: ${GEMINI_BACKEND:-aistudio} + GCP_PROJECT_ID: ${GCP_PROJECT_ID:-} + GCP_LOCATION: ${GCP_LOCATION:-us-central1} + GOOGLE_APPLICATION_CREDENTIALS: ${GOOGLE_APPLICATION_CREDENTIALS:-} + GCS_BUCKET: ${GCS_BUCKET:-} + GCS_OBJECT_PREFIX: ${GCS_OBJECT_PREFIX:-amc-files/} + GCS_MAX_FILE_BYTES: ${GCS_MAX_FILE_BYTES:-2147483648} ENABLE_MCP_STDIO: ${ENABLE_MCP_STDIO:-false} ENABLE_MCP_PRIVATE_HTTP: ${ENABLE_MCP_PRIVATE_HTTP:-false} + volumes: + - ${GOOGLE_APPLICATION_CREDENTIALS_DIR:-./docker/empty-secrets}:/run/secrets:ro expose: - '3001' restart: unless-stopped @@ -26,6 +35,8 @@ services: RUNTIME_USE_API_PROXY: ${RUNTIME_USE_API_PROXY:-true} RUNTIME_API_PROXY_URL: ${RUNTIME_API_PROXY_URL:-/api/gemini} RUNTIME_PYODIDE_BASE_URL: ${RUNTIME_PYODIDE_BASE_URL:-} + RUNTIME_BACKEND_FLAVOR: ${RUNTIME_BACKEND_FLAVOR:-aistudio} + RUNTIME_ENFORCE_API_CONFIG: ${RUNTIME_ENFORCE_API_CONFIG:-false} ports: - '${WEB_PORT:-8080}:80' restart: unless-stopped diff --git a/docker/empty-secrets/.gitkeep b/docker/empty-secrets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/docker/web-server.js b/docker/web-server.js index 1ef660c01..d7573db8f 100644 --- a/docker/web-server.js +++ b/docker/web-server.js @@ -1,7 +1,7 @@ // Minimal static + reverse-proxy server for the Docker web container (Dockerfile.web). // Serves the web container: // - serves static files from /usr/share/nginx/html with SPA fallback to index.html -// - proxies /api/* to http://api:3001 (path preserved, body + response streamed) +// - proxies /api/* and /health to http://api:3001 (path preserved, body + response streamed) // - generates /runtime-config.js at startup from RUNTIME_* env vars // - long-cache headers for /assets/*, no-store for /runtime-config.js // Uses only Node built-ins (http, fs, path) so no npm install is needed in the image. @@ -21,6 +21,7 @@ const jsonStringOrNull = (value) => { if (!trimmed) return 'null'; return JSON.stringify(trimmed); }; +const normalizeBackendFlavor = (value) => (trim(value).toLowerCase() === 'vertex' ? 'vertex' : 'aistudio'); function writeRuntimeConfig() { const config = { @@ -29,6 +30,8 @@ function writeRuntimeConfig() { useApiProxy: toBool(process.env.RUNTIME_USE_API_PROXY ?? 'true'), apiProxyUrl: JSON.parse(jsonStringOrNull(process.env.RUNTIME_API_PROXY_URL ?? '/api/gemini')), pyodideBaseUrl: JSON.parse(jsonStringOrNull(process.env.RUNTIME_PYODIDE_BASE_URL)), + backendFlavor: normalizeBackendFlavor(process.env.RUNTIME_BACKEND_FLAVOR), + enforceApiConfig: toBool(process.env.RUNTIME_ENFORCE_API_CONFIG), }; const content = `window.__AMC_RUNTIME_CONFIG__ = ${JSON.stringify({ ...(globalThis.__AMC_RUNTIME_CONFIG__ || {}), ...config }, null, 2)};`; fs.writeFileSync(path.join(ROOT, 'runtime-config.js'), content); @@ -146,7 +149,7 @@ function serveStatic(req, res) { const server = http.createServer((req, res) => { const pathname = new URL(req.url, 'http://localhost').pathname; - if (pathname === '/api' || pathname.startsWith('/api/')) { + if (pathname === '/health' || pathname === '/api' || pathname.startsWith('/api/')) { return proxyApi(req, res); } return serveStatic(req, res); diff --git a/package.json b/package.json index 7cf86b934..014734015 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:analyze": "vite build --mode analyze", "build:api": "tsc -p server/tsconfig.json", "build:docker": "npm run build && npm run build:api", + "backend:switch": "bash scripts/switch-backend.sh", "preview": "vite preview", "clean": "rm -rf dist server/dist coverage playwright-report test-results tmp-live-artifact-demo .playwright-visible-demo-profile .codex-dev-*", "lint": "eslint . --ext .ts,.tsx,.js,.mjs --max-warnings=0", diff --git a/public/runtime-config.js b/public/runtime-config.js index c78edd3c4..2dacfbc70 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -4,5 +4,7 @@ window.__AMC_RUNTIME_CONFIG__ = { useApiProxy: false, apiProxyUrl: '/api/gemini', pyodideBaseUrl: null, + backendFlavor: 'aistudio', + enforceApiConfig: false, ...(window.__AMC_RUNTIME_CONFIG__ || {}), }; diff --git a/scripts/switch-backend.sh b/scripts/switch-backend.sh new file mode 100755 index 000000000..930707f60 --- /dev/null +++ b/scripts/switch-backend.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/switch-backend.sh + +Profiles are read from: + .env.vertex.local + .env.aistudio.local + .env.byok.local + +Create one from the matching committed example, then fill in its private values: + cp .env.vertex.example .env.vertex.local + cp .env.aistudio.example .env.aistudio.local + cp .env.byok.example .env.byok.local + +The command validates the selected profile and recreates the Docker Compose +containers without rebuilding images. +EOF +} + +mode="${1:-}" +case "${mode}" in + vertex|aistudio|byok) ;; + *) + usage >&2 + exit 2 + ;; +esac + +for dependency in docker curl awk; do + if ! command -v "${dependency}" >/dev/null 2>&1; then + echo "Missing required command: ${dependency}" >&2 + exit 2 + fi +done +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose is not available." >&2 + exit 2 +fi + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +profile="${project_root}/.env.${mode}.local" + +if [[ ! -f "${profile}" ]]; then + echo "Missing backend profile: ${profile}" >&2 + echo "Run: cp .env.${mode}.example .env.${mode}.local" >&2 + exit 2 +fi + +read_env() { + local key="$1" + local value + value="$(awk -v key="${key}" ' + /^[[:space:]]*#/ { next } + { + line = $0 + sub(/^[[:space:]]*/, "", line) + if (index(line, key "=") == 1) { + value = substr(line, length(key) + 2) + } + } + END { print value } + ' "${profile}")" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ ${#value} -ge 2 ]]; then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]] || + [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + printf '%s' "${value}" +} + +expect_value() { + local key="$1" + local expected="$2" + local actual + actual="$(read_env "${key}")" + if [[ "${actual}" != "${expected}" ]]; then + echo "${profile}: expected ${key}=${expected}" >&2 + exit 2 + fi +} + +require_value() { + local key="$1" + if [[ -z "$(read_env "${key}")" ]]; then + echo "${profile}: ${key} must not be empty" >&2 + exit 2 + fi +} + +resolve_host_path() { + local value="$1" + if [[ "${value}" == /* ]]; then + printf '%s' "${value}" + else + printf '%s/%s' "${project_root}" "${value#./}" + fi +} + +expect_value RUNTIME_USE_CUSTOM_API_CONFIG true +expect_value RUNTIME_USE_API_PROXY true +expect_value RUNTIME_API_PROXY_URL /api/gemini +expect_value RUNTIME_ENFORCE_API_CONFIG true +require_value WEB_PORT +require_value GOOGLE_APPLICATION_CREDENTIALS_DIR + +case "${mode}" in + vertex) + expect_value GEMINI_BACKEND vertex + expect_value GEMINI_API_KEY '' + expect_value RUNTIME_BACKEND_FLAVOR vertex + expect_value RUNTIME_SERVER_MANAGED_API true + require_value GCP_PROJECT_ID + require_value GCP_LOCATION + require_value GOOGLE_APPLICATION_CREDENTIALS + + credentials_dir="$(resolve_host_path "$(read_env GOOGLE_APPLICATION_CREDENTIALS_DIR)")" + credentials_path="$(read_env GOOGLE_APPLICATION_CREDENTIALS)" + credentials_file="${credentials_dir}/${credentials_path##*/}" + if [[ ! -f "${credentials_file}" ]]; then + echo "${profile}: Service Account file not found: ${credentials_file}" >&2 + exit 2 + fi + ;; + aistudio) + expect_value GEMINI_BACKEND aistudio + expect_value GOOGLE_APPLICATION_CREDENTIALS_DIR ./docker/empty-secrets + expect_value GOOGLE_APPLICATION_CREDENTIALS '' + expect_value RUNTIME_BACKEND_FLAVOR aistudio + expect_value RUNTIME_SERVER_MANAGED_API true + require_value GEMINI_API_KEY + ;; + byok) + expect_value GEMINI_BACKEND aistudio + expect_value GEMINI_API_KEY '' + expect_value GOOGLE_APPLICATION_CREDENTIALS_DIR ./docker/empty-secrets + expect_value GOOGLE_APPLICATION_CREDENTIALS '' + expect_value RUNTIME_BACKEND_FLAVOR aistudio + expect_value RUNTIME_SERVER_MANAGED_API false + ;; +esac + +cd "${project_root}" +docker compose --env-file "${profile}" config --quiet +docker compose --env-file "${profile}" up -d --force-recreate + +web_port="$(read_env WEB_PORT)" +published_port="${web_port##*:}" +health_url="http://127.0.0.1:${published_port}/health" +for ((attempt = 1; attempt <= 60; attempt += 1)); do + if curl --fail --silent --show-error "${health_url}" >/dev/null 2>&1; then + break + fi + if [[ "${attempt}" -eq 60 ]]; then + echo "Backend switched, but health check failed: ${health_url}" >&2 + docker compose --env-file "${profile}" logs --tail=40 api web >&2 + exit 1 + fi + sleep 1 +done + +echo +echo "Backend switched to ${mode} using ${profile}." +echo "Health check passed: ${health_url}" +echo "Refresh every open AMC WebUI tab; accept the update prompt or hard-reload if needed." +docker compose --env-file "${profile}" ps diff --git a/secrets/.gitignore b/secrets/.gitignore new file mode 100644 index 000000000..df9d06373 --- /dev/null +++ b/secrets/.gitignore @@ -0,0 +1,4 @@ +# Place local credentials such as sa.json in this directory. +# Docker Compose mounts it read-only at /run/secrets. +* +!.gitignore diff --git a/src/runtime/runtimeConfig.test.ts b/src/runtime/runtimeConfig.test.ts index 99d904ce0..52b3ae0f6 100644 --- a/src/runtime/runtimeConfig.test.ts +++ b/src/runtime/runtimeConfig.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; -import { getPyodideBaseUrl, getRuntimeConfigAppSettingsOverrides } from './runtimeConfig'; +import { + getBackendFlavor, + getPyodideBaseUrl, + getRuntimeConfigAppSettingsOverrides, + isRuntimeApiConfigEnforced, +} from './runtimeConfig'; const setRuntimeConfig = (config: Record) => { (window as Window & { __AMC_RUNTIME_CONFIG__?: Record }).__AMC_RUNTIME_CONFIG__ = config; @@ -79,19 +84,98 @@ describe('runtimeConfig', () => { expect(runtimeConfigSource).toContain('pyodideBaseUrl: null'); }); + it('defaults the backend flavor to AI Studio', () => { + const runtimeConfigSource = fs.readFileSync(path.resolve(__dirname, '../../public/runtime-config.js'), 'utf8'); + + expect(runtimeConfigSource).toContain("backendFlavor: 'aistudio'"); + expect(runtimeConfigSource).toContain('enforceApiConfig: false'); + expect(getBackendFlavor()).toBe('aistudio'); + }); + + it('reads the Vertex backend flavor case-insensitively', () => { + setRuntimeConfig({ backendFlavor: 'vertex' }); + expect(getBackendFlavor()).toBe('vertex'); + + setRuntimeConfig({ backendFlavor: 'VERTEX' }); + expect(getBackendFlavor()).toBe('vertex'); + + setRuntimeConfig({ backendFlavor: 'unsupported' }); + expect(getBackendFlavor()).toBe('aistudio'); + }); + + it('enforces runtime API config only for Vertex or explicit deployment profiles', () => { + expect(isRuntimeApiConfigEnforced()).toBe(false); + + setRuntimeConfig({ backendFlavor: 'aistudio', enforceApiConfig: 'true' }); + expect(isRuntimeApiConfigEnforced()).toBe(true); + + setRuntimeConfig({ backendFlavor: 'vertex', enforceApiConfig: false }); + expect(isRuntimeApiConfigEnforced()).toBe(true); + }); + + it('forces server-managed proxy routing in Vertex mode', () => { + setRuntimeConfig({ + backendFlavor: 'vertex', + serverManagedApi: false, + useCustomApiConfig: false, + useApiProxy: false, + apiProxyUrl: ' ', + }); + + expect(getRuntimeConfigAppSettingsOverrides()).toEqual({ + serverManagedApi: true, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini', + }); + }); + + it('uses the configured proxy URL in Vertex mode', () => { + setRuntimeConfig({ + backendFlavor: 'vertex', + apiProxyUrl: 'https://api.example.com/api/gemini', + }); + + expect(getRuntimeConfigAppSettingsOverrides()).toMatchObject({ + apiProxyUrl: 'https://api.example.com/api/gemini', + }); + }); + it('defaults Docker runtime config to BYOK instead of server-managed credentials', () => { const projectRoot = path.resolve(__dirname, '../..'); const webServerSource = fs.readFileSync(path.join(projectRoot, 'docker/web-server.js'), 'utf8'); const composeSource = fs.readFileSync(path.join(projectRoot, 'docker-compose.yml'), 'utf8'); const envExampleSource = fs.readFileSync(path.join(projectRoot, '.env.example'), 'utf8'); + const vertexProfileSource = fs.readFileSync(path.join(projectRoot, '.env.vertex.example'), 'utf8'); + const aiStudioProfileSource = fs.readFileSync(path.join(projectRoot, '.env.aistudio.example'), 'utf8'); + const byokProfileSource = fs.readFileSync(path.join(projectRoot, '.env.byok.example'), 'utf8'); + const switchScriptSource = fs.readFileSync(path.join(projectRoot, 'scripts/switch-backend.sh'), 'utf8'); expect(webServerSource).toContain('serverManagedApi: toBool(process.env.RUNTIME_SERVER_MANAGED_API)'); expect(webServerSource).toContain('RUNTIME_PYODIDE_BASE_URL'); + expect(webServerSource).toContain('RUNTIME_BACKEND_FLAVOR'); + expect(webServerSource).toContain('RUNTIME_ENFORCE_API_CONFIG'); + expect(webServerSource).toContain("pathname === '/health'"); expect(composeSource).toContain('RUNTIME_SERVER_MANAGED_API:-false'); expect(composeSource).toContain('RUNTIME_PYODIDE_BASE_URL'); + expect(composeSource).toContain('RUNTIME_BACKEND_FLAVOR:-aistudio'); + expect(composeSource).toContain('RUNTIME_ENFORCE_API_CONFIG:-false'); + expect(composeSource).toContain('GOOGLE_APPLICATION_CREDENTIALS_DIR:-./docker/empty-secrets'); expect(envExampleSource).toContain('GEMINI_API_KEY='); expect(envExampleSource).toContain('RUNTIME_SERVER_MANAGED_API=false'); expect(envExampleSource).toContain('RUNTIME_PYODIDE_BASE_URL='); + expect(envExampleSource).toContain('RUNTIME_BACKEND_FLAVOR=aistudio'); + expect(envExampleSource).toContain('RUNTIME_ENFORCE_API_CONFIG=false'); + expect(envExampleSource).toContain('GEMINI_BACKEND=aistudio'); expect(envExampleSource).not.toContain('/api/live-token'); + expect(vertexProfileSource).toContain('GEMINI_BACKEND=vertex'); + expect(vertexProfileSource).toContain('GOOGLE_APPLICATION_CREDENTIALS_DIR=./secrets'); + expect(vertexProfileSource).toContain('RUNTIME_ENFORCE_API_CONFIG=true'); + expect(aiStudioProfileSource).toContain('RUNTIME_SERVER_MANAGED_API=true'); + expect(aiStudioProfileSource).toContain('GOOGLE_APPLICATION_CREDENTIALS_DIR=./docker/empty-secrets'); + expect(byokProfileSource).toContain('RUNTIME_SERVER_MANAGED_API=false'); + expect(byokProfileSource).toContain('RUNTIME_ENFORCE_API_CONFIG=true'); + expect(byokProfileSource).toContain('GEMINI_API_KEY=\n'); + expect(switchScriptSource).toContain('up -d --force-recreate'); }); }); diff --git a/src/runtime/runtimeConfig.ts b/src/runtime/runtimeConfig.ts index e1f7a44a3..81116fa1e 100644 --- a/src/runtime/runtimeConfig.ts +++ b/src/runtime/runtimeConfig.ts @@ -1,11 +1,15 @@ import type { AppSettings } from '@/types'; +type BackendFlavor = 'aistudio' | 'vertex'; + type RuntimeConfigKey = | 'serverManagedApi' | 'useCustomApiConfig' | 'useApiProxy' | 'apiProxyUrl' - | 'pyodideBaseUrl'; + | 'pyodideBaseUrl' + | 'backendFlavor' + | 'enforceApiConfig'; type RuntimeConfigShape = Partial>; @@ -54,6 +58,15 @@ export function getPyodideBaseUrl(): string | null { return readNullableString(getRuntimeConfig()?.pyodideBaseUrl) ?? null; } +export function getBackendFlavor(): BackendFlavor { + const value = getRuntimeConfig()?.backendFlavor; + return typeof value === 'string' && value.trim().toLowerCase() === 'vertex' ? 'vertex' : 'aistudio'; +} + +export function isRuntimeApiConfigEnforced(): boolean { + return getBackendFlavor() === 'vertex' || readBooleanValue(getRuntimeConfig()?.enforceApiConfig) === true; +} + export function getRuntimeConfigAppSettingsOverrides(): Partial< Pick > { @@ -87,5 +100,15 @@ export function getRuntimeConfigAppSettingsOverrides(): Partial< overrides.apiProxyUrl = apiProxyUrl; } + if (getBackendFlavor() === 'vertex') { + return { + ...overrides, + serverManagedApi: true, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: typeof overrides.apiProxyUrl === 'string' ? overrides.apiProxyUrl : '/api/gemini', + }; + } + return overrides; } diff --git a/src/services/api/apiClient.test.ts b/src/services/api/apiClient.test.ts index 88db00497..0ef903c27 100644 --- a/src/services/api/apiClient.test.ts +++ b/src/services/api/apiClient.test.ts @@ -3,6 +3,11 @@ import { GoogleGenAI } from '@google/genai'; import { getClient, getConfiguredApiClient, getConfiguredApiClientContext } from './apiClient'; import { dbService } from '@/services/db/dbService'; +const { mockGetRuntimeConfigAppSettingsOverrides, mockIsRuntimeApiConfigEnforced } = vi.hoisted(() => ({ + mockGetRuntimeConfigAppSettingsOverrides: vi.fn(() => ({})), + mockIsRuntimeApiConfigEnforced: vi.fn(() => false), +})); + type MockGoogleGenAIConfig = { apiKey: string; httpOptions?: { @@ -33,6 +38,11 @@ vi.mock('@/services/logService', async () => { return createLogServiceMockModule(); }); +vi.mock('@/runtime/runtimeConfig', () => ({ + getRuntimeConfigAppSettingsOverrides: mockGetRuntimeConfigAppSettingsOverrides, + isRuntimeApiConfigEnforced: mockIsRuntimeApiConfigEnforced, +})); + // ── getClient ── describe('getClient', () => { @@ -100,6 +110,8 @@ describe('getClient', () => { describe('getConfiguredApiClient', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetRuntimeConfigAppSettingsOverrides.mockReturnValue({}); + mockIsRuntimeApiConfigEnforced.mockReturnValue(false); }); it('uses proxy when both useCustomApiConfig and useApiProxy are true', async () => { @@ -132,11 +144,35 @@ describe('getConfiguredApiClient', () => { const callArgs = vi.mocked(GoogleGenAI).mock.calls[0][0] as MockGoogleGenAIConfig; expect(callArgs.httpOptions?.baseUrl).toBeUndefined(); }); + + it('forces an enforced runtime proxy over stale stored settings', async () => { + mockIsRuntimeApiConfigEnforced.mockReturnValue(true); + mockGetRuntimeConfigAppSettingsOverrides.mockReturnValue({ + serverManagedApi: true, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini', + }); + vi.mocked(dbService.getAppSettings).mockResolvedValue({ + useCustomApiConfig: false, + useApiProxy: false, + apiProxyUrl: 'https://generativelanguage.googleapis.com', + } as StoredAppSettings); + + await getConfiguredApiClient('key'); + + expect(GoogleGenAI).toHaveBeenCalledWith({ + apiKey: 'key', + httpOptions: { baseUrl: 'http://localhost/api/gemini' }, + }); + }); }); describe('getConfiguredApiClientContext', () => { beforeEach(() => { vi.clearAllMocks(); + mockGetRuntimeConfigAppSettingsOverrides.mockReturnValue({}); + mockIsRuntimeApiConfigEnforced.mockReturnValue(false); }); it('builds the client and routing URLs from one settings read', async () => { @@ -158,4 +194,22 @@ describe('getConfiguredApiClientContext', () => { proxyBaseUrl: 'https://proxy.example.com/gemini', }); }); + + it('routes enforced-profile uploads through the same-origin proxy', async () => { + mockIsRuntimeApiConfigEnforced.mockReturnValue(true); + mockGetRuntimeConfigAppSettingsOverrides.mockReturnValue({ + serverManagedApi: true, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini', + }); + vi.mocked(dbService.getAppSettings).mockResolvedValue(undefined); + + const context = await getConfiguredApiClientContext('key'); + + expect(context).toMatchObject({ + apiBaseUrl: 'http://localhost/api/gemini', + proxyBaseUrl: '/api/gemini', + }); + }); }); diff --git a/src/services/api/apiClient.ts b/src/services/api/apiClient.ts index 6a7ae0909..9de17769b 100644 --- a/src/services/api/apiClient.ts +++ b/src/services/api/apiClient.ts @@ -1,6 +1,8 @@ import type { GoogleGenAI } from '@google/genai'; +import type { AppSettings } from '@/types'; import { dbService } from '@/services/db/dbService'; import { logService } from '@/services/logService'; +import { getRuntimeConfigAppSettingsOverrides, isRuntimeApiConfigEnforced } from '@/runtime/runtimeConfig'; import { getGeminiApiBaseUrlForSettings, getGeminiProxyBaseUrlForSettings, @@ -14,8 +16,10 @@ type ClientConfig = { httpOptions?: GeminiClientHttpOptions; }; +type GeminiApiRoutingSettings = Pick; + type ConfiguredApiRouting = { - settings: Awaited>; + settings: GeminiApiRoutingSettings | null; apiProxyUrl: string | null; }; @@ -82,8 +86,24 @@ export const getClient = async ( } }; +const applyEnforcedRuntimeRouting = ( + settings: GeminiApiRoutingSettings | null | undefined, +): GeminiApiRoutingSettings | null => { + if (!isRuntimeApiConfigEnforced()) { + return settings ?? null; + } + + const runtimeOverrides = getRuntimeConfigAppSettingsOverrides(); + return { + useCustomApiConfig: runtimeOverrides.useCustomApiConfig ?? settings?.useCustomApiConfig ?? false, + useApiProxy: runtimeOverrides.useApiProxy ?? settings?.useApiProxy ?? false, + apiProxyUrl: + runtimeOverrides.apiProxyUrl !== undefined ? runtimeOverrides.apiProxyUrl : (settings?.apiProxyUrl ?? null), + }; +}; + const loadConfiguredApiRouting = async (): Promise => { - const settings = await dbService.getAppSettings(); + const settings = applyEnforcedRuntimeRouting(await dbService.getAppSettings()); const shouldUseProxy = !!(settings?.useCustomApiConfig && settings?.useApiProxy); const apiProxyUrl = settings ? resolveConfiguredGeminiBaseUrl(settings) : null; diff --git a/src/services/api/geminiApiBaseUrl.test.ts b/src/services/api/geminiApiBaseUrl.test.ts index 4f877e6d7..88174da45 100644 --- a/src/services/api/geminiApiBaseUrl.test.ts +++ b/src/services/api/geminiApiBaseUrl.test.ts @@ -36,6 +36,16 @@ describe('geminiApiBaseUrl', () => { ).toBe('https://proxy.example.com/gemini'); }); + it('resolves same-origin proxy paths to absolute URLs for SDK use', () => { + expect( + getGeminiApiBaseUrlForSettings({ + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini/v1beta', + }), + ).toBe('http://localhost/api/gemini'); + }); + it('returns a normalized proxy base URL only when proxy mode is active', () => { expect(getGeminiProxyBaseUrlForSettings(disabledProxySettings)).toBeNull(); expect( diff --git a/src/services/api/geminiApiBaseUrl.ts b/src/services/api/geminiApiBaseUrl.ts index 8d966cbf9..3065750b6 100644 --- a/src/services/api/geminiApiBaseUrl.ts +++ b/src/services/api/geminiApiBaseUrl.ts @@ -1,6 +1,8 @@ import type { AppSettings } from '@/types'; import { DEFAULT_GEMINI_API_BASE_URL, normalizeGeminiApiBaseUrl } from '@/utils/apiProxyUrl'; +const GEMINI_API_VERSION_SUFFIX = /\/v\d+(?:(?:alpha|beta)\d*|\.\d+)?$/i; + type GeminiApiBaseUrlSettings = Pick; export const resolveConfiguredGeminiBaseUrl = (appSettings: GeminiApiBaseUrlSettings): string | null => { @@ -10,6 +12,9 @@ export const resolveConfiguredGeminiBaseUrl = (appSettings: GeminiApiBaseUrlSett const isAbsoluteHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url.trim()); +const normalizeConfiguredProxyBaseUrl = (baseUrl: string): string => + baseUrl.trim().replace(/\/+$/, '').replace(GEMINI_API_VERSION_SUFFIX, ''); + export const getGeminiApiBaseUrlForSettings = (settings?: GeminiApiBaseUrlSettings | null): string => { const configuredBaseUrl = settings ? resolveConfiguredGeminiBaseUrl(settings) : null; return normalizeGeminiApiBaseUrl(configuredBaseUrl ?? DEFAULT_GEMINI_API_BASE_URL); @@ -17,12 +22,12 @@ export const getGeminiApiBaseUrlForSettings = (settings?: GeminiApiBaseUrlSettin export const getGeminiProxyBaseUrlForSettings = (settings?: GeminiApiBaseUrlSettings | null): string | null => { const configuredBaseUrl = settings ? resolveConfiguredGeminiBaseUrl(settings) : null; - return configuredBaseUrl ? normalizeGeminiApiBaseUrl(configuredBaseUrl) : null; + return configuredBaseUrl ? normalizeConfiguredProxyBaseUrl(configuredBaseUrl) : null; }; export const resolveLiveClientBaseUrl = (appSettings: GeminiApiBaseUrlSettings): string | null => { const configuredBaseUrl = resolveConfiguredGeminiBaseUrl(appSettings); - if (!configuredBaseUrl) { + if (!configuredBaseUrl || !isAbsoluteHttpUrl(configuredBaseUrl)) { return null; } diff --git a/src/stores/settingsStore.test.ts b/src/stores/settingsStore.test.ts index a52f47b8a..c9be3cf1a 100644 --- a/src/stores/settingsStore.test.ts +++ b/src/stores/settingsStore.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockGetRuntimeConfigAppSettingsOverrides } = vi.hoisted(() => ({ +const { mockGetRuntimeConfigAppSettingsOverrides, mockIsRuntimeApiConfigEnforced } = vi.hoisted(() => ({ mockGetRuntimeConfigAppSettingsOverrides: vi.fn(() => ({})), + mockIsRuntimeApiConfigEnforced: vi.fn(() => false), })); vi.mock('@/services/db/dbService', async () => { @@ -18,6 +19,7 @@ vi.mock('@/services/logService', async () => { vi.mock('@/runtime/runtimeConfig', () => ({ getRuntimeConfigAppSettingsOverrides: mockGetRuntimeConfigAppSettingsOverrides, + isRuntimeApiConfigEnforced: mockIsRuntimeApiConfigEnforced, })); import { DEFAULT_APP_SETTINGS } from '@/constants/settingsDefaults'; @@ -32,6 +34,7 @@ describe('settingsStore', () => { beforeEach(() => { vi.clearAllMocks(); mockGetRuntimeConfigAppSettingsOverrides.mockReturnValue({}); + mockIsRuntimeApiConfigEnforced.mockReturnValue(false); useSettingsStore.setState({ appSettings: DEFAULT_APP_SETTINGS, currentTheme: createTheme(), @@ -268,6 +271,60 @@ describe('settingsStore', () => { expect(appSettings.serverManagedApi).toBe(true); }); + it('overrides stale stored proxy settings when the runtime profile is enforced', async () => { + mockIsRuntimeApiConfigEnforced.mockReturnValue(true); + mockGetRuntimeConfigAppSettingsOverrides.mockReturnValue({ + serverManagedApi: true, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini', + }); + vi.mocked(dbService.getAppSettings).mockResolvedValue( + createStoredSettingsSnapshot({ + serverManagedApi: false, + useCustomApiConfig: false, + useApiProxy: false, + apiProxyUrl: 'https://old-ai-studio-proxy.example.com/v1beta', + }), + ); + + await useSettingsStore.getState().loadSettings(); + + expect(useSettingsStore.getState().appSettings).toMatchObject({ + serverManagedApi: true, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini', + }); + }); + + it('clears stale server-managed mode for an enforced BYOK profile', async () => { + mockIsRuntimeApiConfigEnforced.mockReturnValue(true); + mockGetRuntimeConfigAppSettingsOverrides.mockReturnValue({ + serverManagedApi: false, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini', + }); + vi.mocked(dbService.getAppSettings).mockResolvedValue( + createStoredSettingsSnapshot({ + serverManagedApi: true, + useCustomApiConfig: false, + useApiProxy: false, + apiProxyUrl: null, + }), + ); + + await useSettingsStore.getState().loadSettings(); + + expect(useSettingsStore.getState().appSettings).toMatchObject({ + serverManagedApi: false, + useCustomApiConfig: true, + useApiProxy: true, + apiProxyUrl: '/api/gemini', + }); + }); + it('handles DB errors gracefully', async () => { vi.mocked(dbService.getAppSettings).mockRejectedValue(new Error('DB fail')); await useSettingsStore.getState().loadSettings(); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 2c8fb68bb..4c98528ac 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -8,6 +8,7 @@ import { logService } from '@/services/logService'; import { resolveSupportedModelId, sanitizeModelOptions } from '@/utils/modelSorting'; import { dbService } from '@/services/db/dbService'; import { normalizeLiveArtifactsSystemPrompts } from '@/utils/liveArtifactsPromptSettings'; +import { getRuntimeConfigAppSettingsOverrides, isRuntimeApiConfigEnforced } from '@/runtime/runtimeConfig'; import { type ConcreteThemeId } from '@/utils/themeMode'; import { resolveUpdaterOrValue, type UpdaterOrValue } from './stateUpdaters'; import { CHAT_SYNC_CHANNEL_NAME } from './chatSyncChannel'; @@ -64,6 +65,7 @@ function sanitizeAppSettings(settings: AppSettings): AppSettings { sanitizedOpenAICompatibleModels.length > 0 ? sanitizedOpenAICompatibleModels : defaultSettings.openaiCompatibleModels; + const enforcedRuntimeOverrides = isRuntimeApiConfigEnforced() ? getRuntimeConfigAppSettingsOverrides() : {}; return { ...settings, @@ -95,6 +97,7 @@ function sanitizeAppSettings(settings: AppSettings): AppSettings { liveArtifactsSystemPrompts: normalizeLiveArtifactsSystemPrompts(settings), liveTranslateTargetLanguageCode: settings.liveTranslateTargetLanguageCode ?? defaultSettings.liveTranslateTargetLanguageCode, liveTranslateEchoTargetLanguage: settings.liveTranslateEchoTargetLanguage ?? defaultSettings.liveTranslateEchoTargetLanguage, + ...enforcedRuntimeOverrides, }; } diff --git a/src/utils/apiProxyUrl.ts b/src/utils/apiProxyUrl.ts index d92b8ff30..706755345 100644 --- a/src/utils/apiProxyUrl.ts +++ b/src/utils/apiProxyUrl.ts @@ -5,8 +5,21 @@ const DEFAULT_GEMINI_API_VERSION = 'v1beta'; export const DEFAULT_GEMINI_PROXY_URL = 'https://api-proxy.de/gemini'; export const DEFAULT_OPENAI_COMPATIBLE_BASE_URL = 'https://api.openai.com/v1'; +const resolveRuntimeRelativeBaseUrl = (baseUrl: string): string => { + try { + return new URL(baseUrl, window.location.origin).toString(); + } catch { + return baseUrl; + } +}; + export const normalizeGeminiApiBaseUrl = (baseUrl: string): string => { - const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, ''); + const rawBaseUrl = baseUrl.trim(); + const absoluteBaseUrl = + rawBaseUrl.startsWith('/') && typeof window !== 'undefined' + ? resolveRuntimeRelativeBaseUrl(rawBaseUrl) + : rawBaseUrl; + const trimmedBaseUrl = absoluteBaseUrl.replace(/\/+$/, ''); return trimmedBaseUrl.replace(GEMINI_API_VERSION_SUFFIX, ''); }; From cbfcbc5065ec0bf38b8163e6ce6f17934cd41910 Mon Sep 17 00:00:00 2001 From: Xichun123 Date: Fri, 10 Jul 2026 11:58:41 +0800 Subject: [PATCH 3/3] docs(vertex): document GCP and GCS deployment --- README.en.md | 114 +++++++++++++++++++++++++++++++++++++++++++++------ README.md | 114 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 202 insertions(+), 26 deletions(-) diff --git a/README.en.md b/README.en.md index bcdfa10c9..4ae22debe 100644 --- a/README.en.md +++ b/README.en.md @@ -243,19 +243,29 @@ Notes: ### Runtime Configuration and Environment Variables -| Variable | Purpose | Public | Docker default | -| :------------------------------ | :----------------------------------------------------------------------------------------------- | :-------------------- | :------------------------------------------ | -| `GEMINI_API_KEY` | Optional server-managed Gemini API key; when set, it takes precedence over browser settings keys | Server only | Empty | -| `PORT` | Port used by the API service | Server only | `3001` | -| `GEMINI_API_BASE` | Upstream Gemini API base URL | Server only | `https://generativelanguage.googleapis.com` | -| `ALLOWED_ORIGINS` | Comma-separated CORS allowlist for cross-origin deployments | Server only | Empty | -| `ENABLE_MCP_STDIO` | Enables `stdio` MCP server calls | Server only | `false` | -| `ENABLE_MCP_PRIVATE_HTTP` | Allows the API service to call private or local HTTP MCP URLs | Server only | `false` | -| `RUNTIME_SERVER_MANAGED_API` | Enables server-managed API mode by default in the frontend | Public runtime config | `false` | -| `RUNTIME_USE_CUSTOM_API_CONFIG` | Enables custom API configuration by default | Public runtime config | `true` | -| `RUNTIME_USE_API_PROXY` | Enables API proxy mode by default | Public runtime config | `true` | -| `RUNTIME_API_PROXY_URL` | Default Gemini proxy URL for the frontend | Public runtime config | `/api/gemini` | -| `RUNTIME_PYODIDE_BASE_URL` | Optional Pyodide runtime asset URL; when blank, same-origin `/pyodide/` is used | Public runtime config | Empty | +| Variable | Purpose | Public | Docker default | +| :----------------------------------- | :------------------------------------------------------------------------------ | :-------------------- | :------------------------------------------ | +| `GEMINI_API_KEY` | Optional server-managed Gemini API key for AI Studio mode | Server only | Empty | +| `PORT` | Port used by the API service | Server only | `3001` | +| `GEMINI_API_BASE` | AI Studio upstream API base URL | Server only | `https://generativelanguage.googleapis.com` | +| `GEMINI_BACKEND` | Upstream backend: `aistudio` or `vertex` | Server only | `aistudio` | +| `GCP_PROJECT_ID` | Google Cloud project used by Vertex AI | Server only | Empty | +| `GCP_LOCATION` | Vertex AI location, such as `global` or `us-central1` | Server only | `us-central1` | +| `GOOGLE_APPLICATION_CREDENTIALS_DIR` | Host directory mounted at `/run/secrets`; use `./secrets` only for Vertex mode | Docker host config | `./docker/empty-secrets` | +| `GOOGLE_APPLICATION_CREDENTIALS` | Service Account JSON path inside the API container | Server only | Empty | +| `GCS_BUCKET` | Optional GCS bucket backing Gemini Files API requests in Vertex mode | Server only | Empty | +| `GCS_OBJECT_PREFIX` | Object prefix used for uploaded files | Server only | `amc-files/` | +| `GCS_MAX_FILE_BYTES` | Maximum file size accepted by the GCS adapter | Server only | `2147483648` | +| `ALLOWED_ORIGINS` | Comma-separated CORS allowlist for cross-origin deployments | Server only | Empty | +| `ENABLE_MCP_STDIO` | Enables `stdio` MCP server calls | Server only | `false` | +| `ENABLE_MCP_PRIVATE_HTTP` | Allows the API service to call private or local HTTP MCP URLs | Server only | `false` | +| `RUNTIME_SERVER_MANAGED_API` | Enables server-managed API mode by default in the frontend | Public runtime config | `false` | +| `RUNTIME_USE_CUSTOM_API_CONFIG` | Enables custom API configuration by default | Public runtime config | `true` | +| `RUNTIME_USE_API_PROXY` | Enables API proxy mode by default | Public runtime config | `true` | +| `RUNTIME_API_PROXY_URL` | Default Gemini proxy URL for the frontend | Public runtime config | `/api/gemini` | +| `RUNTIME_PYODIDE_BASE_URL` | Optional Pyodide runtime asset URL; when blank, same-origin `/pyodide/` is used | Public runtime config | Empty | +| `RUNTIME_BACKEND_FLAVOR` | Frontend backend behavior: `aistudio` or `vertex` | Public runtime config | `aistudio` | +| `RUNTIME_ENFORCE_API_CONFIG` | Makes runtime API routing override stale browser settings | Public runtime config | `false` | The `RUNTIME_*` values are written into `runtime-config.js` at container startup and are readable by the browser. Only put public configuration there. The public/runtime-config.js template is used for static builds and keeps custom API configuration and proxy mode disabled by default; Docker overwrites it through `docker/web-server.js` at container startup using the defaults above. @@ -269,6 +279,84 @@ If you want server-managed credentials for regular Gemini requests, set `GEMINI_ OpenAI Compatible mode currently does not read `RUNTIME_API_PROXY_URL`, `RUNTIME_USE_API_PROXY`, or `RUNTIME_SERVER_MANAGED_API`. It sends `chat/completions` requests directly to the OpenAI-compatible Base URL configured in Settings, using its separate key set. If you want that mode to pass through your own gateway, point the Base URL at that gateway directly. +#### Switching backend profiles + +The repository includes three Docker environment examples: + +```text +.env.vertex.example +.env.aistudio.example +.env.byok.example +``` + +Create private local copies and fill in only the required secrets: + +```bash +cp .env.vertex.example .env.vertex.local +cp .env.aistudio.example .env.aistudio.local +cp .env.byok.example .env.byok.local +``` + +The `.local` files are ignored by Git. Switch modes without rebuilding images: + +```bash +npm run backend:switch -- vertex +npm run backend:switch -- aistudio +npm run backend:switch -- byok +``` + +- `vertex` uses the Service Account under `GOOGLE_APPLICATION_CREDENTIALS_DIR` and server-managed Vertex authentication. +- `aistudio` requires `GEMINI_API_KEY` in `.env.aistudio.local`; the browser does not receive that key. +- `byok` requires `GEMINI_API_KEY` to remain empty; enter the key in the browser settings instead. + +The switch command validates the selected profile, recreates the containers, and waits for `/health`. Refresh every open AMC WebUI tab after switching; accept the in-app update prompt or use a hard reload when a new frontend image was built. All three profiles set `RUNTIME_ENFORCE_API_CONFIG=true`, so stale IndexedDB settings cannot keep the previous credential mode active after reload. AI Studio and BYOK profiles mount `./docker/empty-secrets` instead of the real Service Account directory. + +#### Vertex AI and GCS + +Vertex mode authenticates in the API service with Google Application Default Credentials, rewrites Gemini model requests to Vertex AI publisher endpoints, and removes the browser API-key requirement. To use it with Docker Compose: + +```env +GEMINI_BACKEND=vertex +GCP_PROJECT_ID=your-gcp-project-id +GCP_LOCATION=global +GOOGLE_APPLICATION_CREDENTIALS_DIR=./secrets +GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/sa.json + +# Optional: enables Gemini Files API compatibility through GCS. +GCS_BUCKET=your-gcs-bucket +GCS_OBJECT_PREFIX=amc-files/ +GCS_MAX_FILE_BYTES=2147483648 + +RUNTIME_BACKEND_FLAVOR=vertex +RUNTIME_ENFORCE_API_CONFIG=true +RUNTIME_API_PROXY_URL=/api/gemini +``` + +1. Put the Service Account JSON at `./secrets/sa.json` and set `GOOGLE_APPLICATION_CREDENTIALS_DIR=./secrets`. Docker Compose mounts that directory read-only at `/run/secrets`. +2. Grant the Service Account `roles/aiplatform.user`. If `GCS_BUCKET` is configured, also grant `roles/storage.objectUser` on that bucket. +3. To create/configure the bucket, run: + +```bash +GCP_PROJECT_ID=... GCS_BUCKET=... GCS_LOCATION=us-central1 VERTEX_SA_EMAIL=... \ + bash scripts/setup-gcs-bucket.sh +``` + +When `RUNTIME_BACKEND_FLAVOR=vertex`, the frontend enforces server-managed `/api/gemini` routing even if the browser has stale AI Studio settings. The optional GCS adapter accepts the existing resumable Files API flow, writes uploads under `gs:///`, and rewrites file references before forwarding model requests. Frontend chunks are 8 MB; the API rejects buffered chunks over 50 MB and enforces `GCS_MAX_FILE_BYTES` per file. + +Vertex mode covers HTTP model requests and the GCS-backed Files adapter. Live API still connects directly from the browser and requires a browser-available Gemini API key; it is not proxied through Vertex mode. + +After the containers are running, a real-project smoke test is available: + +```bash +GCP_PROJECT_ID=... \ +GCS_BUCKET=... \ +GOOGLE_APPLICATION_CREDENTIALS=./secrets/sa.json \ +SKIP_IMAGEN=1 \ +npm run verify:vertex-e2e +``` + +Remove `SKIP_IMAGEN=1` to include an Imagen request. If `WEB_PORT` is not `8080`, set `API_BASE_URL=http://localhost:`. The script verifies health, text generation, resumable upload, the GCS object, metadata refresh, multimodal generation, and optional Imagen generation, then deletes its test object unless `NO_CLEANUP=1` is set. + ### Option 3: Cloudflare Pages + Standalone API You can deploy the frontend to Cloudflare Pages and run `server/` as a separate Node service on a VM, container platform, or serverless container runtime. diff --git a/README.md b/README.md index 45ae48cc3..bf363293d 100644 --- a/README.md +++ b/README.md @@ -249,19 +249,29 @@ docker compose up -d --build 部署时请区分两类配置: -| 变量名 | 用途 | 公开性 | Docker 默认值 | -| :------------------------------ | :------------------------------------------------------------ | :----------------- | :------------------------------------------ | -| `GEMINI_API_KEY` | 可选的服务端托管 Gemini API Key(配置后优先于浏览器设置 key) | **仅服务端** | 空 | -| `PORT` | `api` 服务监听端口 | 仅服务端 | `3001` | -| `GEMINI_API_BASE` | Gemini 上游地址(代理目标) | 仅服务端 | `https://generativelanguage.googleapis.com` | -| `ALLOWED_ORIGINS` | 逗号分隔 CORS 白名单(跨域部署时使用) | 仅服务端 | 空 | -| `ENABLE_MCP_STDIO` | 启用 `stdio` MCP 服务调用能力 | 仅服务端 | `false` | -| `ENABLE_MCP_PRIVATE_HTTP` | 允许 API 服务访问内网/本机 HTTP MCP 地址 | 仅服务端 | `false` | -| `RUNTIME_SERVER_MANAGED_API` | 前端默认启用服务端托管 API | **公开运行时配置** | `false` | -| `RUNTIME_USE_CUSTOM_API_CONFIG` | 前端默认启用“自定义 API 配置” | 公开运行时配置 | `true` | -| `RUNTIME_USE_API_PROXY` | 前端默认启用 API 代理 | 公开运行时配置 | `true` | -| `RUNTIME_API_PROXY_URL` | 前端默认 Gemini 代理地址 | 公开运行时配置 | `/api/gemini` | -| `RUNTIME_PYODIDE_BASE_URL` | 可选 Pyodide 运行时资源地址;留空时使用同源 `/pyodide/` | 公开运行时配置 | 空 | +| 变量名 | 用途 | 公开性 | Docker 默认值 | +| :----------------------------------- | :----------------------------------------------------------------- | :----------------- | :------------------------------------------ | +| `GEMINI_API_KEY` | AI Studio 模式下可选的服务端托管 Gemini API Key | **仅服务端** | 空 | +| `PORT` | `api` 服务监听端口 | 仅服务端 | `3001` | +| `GEMINI_API_BASE` | AI Studio 上游地址(代理目标) | 仅服务端 | `https://generativelanguage.googleapis.com` | +| `GEMINI_BACKEND` | 上游后端类型:`aistudio` 或 `vertex` | 仅服务端 | `aistudio` | +| `GCP_PROJECT_ID` | Vertex AI 所属 Google Cloud 项目 ID | 仅服务端 | 空 | +| `GCP_LOCATION` | Vertex AI 区域,例如 `global` 或 `us-central1` | 仅服务端 | `us-central1` | +| `GOOGLE_APPLICATION_CREDENTIALS_DIR` | 挂载到 `/run/secrets` 的宿主机目录;仅 Vertex 模式使用 `./secrets` | Docker 宿主机配置 | `./docker/empty-secrets` | +| `GOOGLE_APPLICATION_CREDENTIALS` | Service Account JSON 在 API 容器内的路径 | **仅服务端** | 空 | +| `GCS_BUCKET` | Vertex 模式下为 Gemini Files API 提供存储的可选 GCS Bucket | 仅服务端 | 空 | +| `GCS_OBJECT_PREFIX` | 上传文件使用的 GCS 对象前缀 | 仅服务端 | `amc-files/` | +| `GCS_MAX_FILE_BYTES` | GCS 适配器允许的单文件大小上限 | 仅服务端 | `2147483648` | +| `ALLOWED_ORIGINS` | 逗号分隔 CORS 白名单(跨域部署时使用) | 仅服务端 | 空 | +| `ENABLE_MCP_STDIO` | 启用 `stdio` MCP 服务调用能力 | 仅服务端 | `false` | +| `ENABLE_MCP_PRIVATE_HTTP` | 允许 API 服务访问内网/本机 HTTP MCP 地址 | 仅服务端 | `false` | +| `RUNTIME_SERVER_MANAGED_API` | 前端默认启用服务端托管 API | **公开运行时配置** | `false` | +| `RUNTIME_USE_CUSTOM_API_CONFIG` | 前端默认启用“自定义 API 配置” | 公开运行时配置 | `true` | +| `RUNTIME_USE_API_PROXY` | 前端默认启用 API 代理 | 公开运行时配置 | `true` | +| `RUNTIME_API_PROXY_URL` | 前端默认 Gemini 代理地址 | 公开运行时配置 | `/api/gemini` | +| `RUNTIME_PYODIDE_BASE_URL` | 可选 Pyodide 运行时资源地址;留空时使用同源 `/pyodide/` | 公开运行时配置 | 空 | +| `RUNTIME_BACKEND_FLAVOR` | 前端运行时使用的后端类型:`aistudio` 或 `vertex` | 公开运行时配置 | `aistudio` | +| `RUNTIME_ENFORCE_API_CONFIG` | 让运行时 API 路由覆盖浏览器中残留的旧设置 | 公开运行时配置 | `false` | 说明: @@ -276,6 +286,84 @@ docker compose up -d --build - 浏览器本地 key 适合自用/可信部署。它不会因为“保存在本地”而变成服务器密钥,同一浏览器上下文中的脚本、扩展、XSS 或设备风险仍可能读取它。 - 前端在部署时默认只依赖后端端点:`/api/gemini/*`;Live API 从浏览器直连官方 Live 服务。 +#### 快速切换后端配置 + +仓库提供三份 Docker 环境配置示例: + +```text +.env.vertex.example +.env.aistudio.example +.env.byok.example +``` + +先创建不会提交到 Git 的本地配置,再填写各模式需要的私密值: + +```bash +cp .env.vertex.example .env.vertex.local +cp .env.aistudio.example .env.aistudio.local +cp .env.byok.example .env.byok.local +``` + +无需重新构建镜像即可切换: + +```bash +npm run backend:switch -- vertex +npm run backend:switch -- aistudio +npm run backend:switch -- byok +``` + +- `vertex` 使用 `GOOGLE_APPLICATION_CREDENTIALS_DIR` 下的 Service Account,由服务端完成 Vertex 鉴权。 +- `aistudio` 要求在 `.env.aistudio.local` 设置 `GEMINI_API_KEY`,该 Key 不会发送给浏览器。 +- `byok` 要求服务端 `GEMINI_API_KEY` 保持为空,用户在浏览器设置中填写自己的 Key。 + +切换命令会校验配置、重建容器并等待 `/health`。切换后请刷新所有已打开的 AMC WebUI 标签页;如果刚构建了新的前端镜像,请接受应用内更新提示或执行强制刷新。三种 Profile 都会设置 `RUNTIME_ENFORCE_API_CONFIG=true`,刷新后可避免 IndexedDB 中的旧设置继续使用上一种凭据模式。AI Studio 和 BYOK 模式会挂载 `./docker/empty-secrets`,而不是实际的 Service Account 目录。 + +#### Vertex AI 与 GCS + +Vertex 模式在 API 服务中通过 Google Application Default Credentials 鉴权,将 Gemini 模型请求改写到 Vertex AI publisher endpoint,并让浏览器不再需要 Vertex API Key。Docker Compose 最小配置示例: + +```env +GEMINI_BACKEND=vertex +GCP_PROJECT_ID=your-gcp-project-id +GCP_LOCATION=global +GOOGLE_APPLICATION_CREDENTIALS_DIR=./secrets +GOOGLE_APPLICATION_CREDENTIALS=/run/secrets/sa.json + +# 可选:通过 GCS 兼容 Gemini Files API。 +GCS_BUCKET=your-gcs-bucket +GCS_OBJECT_PREFIX=amc-files/ +GCS_MAX_FILE_BYTES=2147483648 + +RUNTIME_BACKEND_FLAVOR=vertex +RUNTIME_ENFORCE_API_CONFIG=true +RUNTIME_API_PROXY_URL=/api/gemini +``` + +1. 将 Service Account JSON 放到 `./secrets/sa.json`,并设置 `GOOGLE_APPLICATION_CREDENTIALS_DIR=./secrets`。Docker Compose 会把该目录只读挂载到 `/run/secrets`。 +2. 为 Service Account 授予 `roles/aiplatform.user`;配置 `GCS_BUCKET` 时,还需在该 Bucket 上授予 `roles/storage.objectUser`。 +3. 如需创建并配置 Bucket,可运行: + +```bash +GCP_PROJECT_ID=... GCS_BUCKET=... GCS_LOCATION=us-central1 VERTEX_SA_EMAIL=... \ + bash scripts/setup-gcs-bucket.sh +``` + +设置 `RUNTIME_BACKEND_FLAVOR=vertex` 后,即使浏览器保留了旧的 AI Studio 设置,前端也会强制使用服务端托管的 `/api/gemini` 路由。可选 GCS 适配器会接收现有的可恢复 Files API 上传流程,把对象写入 `gs:///`,并在请求模型前改写文件引用。前端分片大小为 8 MB;API 对单个缓冲分片设置 50 MB 上限,并通过 `GCS_MAX_FILE_BYTES` 限制单文件大小。 + +Vertex 模式覆盖 HTTP 模型请求与 GCS Files 适配。Live API 仍由浏览器直连,并需要浏览器中可用的 Gemini API Key;它不会通过 Vertex 模式代理。 + +容器启动后,可以使用真实 GCP 项目执行端到端检查: + +```bash +GCP_PROJECT_ID=... \ +GCS_BUCKET=... \ +GOOGLE_APPLICATION_CREDENTIALS=./secrets/sa.json \ +SKIP_IMAGEN=1 \ +npm run verify:vertex-e2e +``` + +移除 `SKIP_IMAGEN=1` 可加入 Imagen 请求。如果 `WEB_PORT` 不是 `8080`,请设置 `API_BASE_URL=http://localhost:`。脚本会检查健康状态、文本生成、可恢复上传、GCS 对象、元数据刷新、多模态生成和可选 Imagen 生成;除非设置 `NO_CLEANUP=1`,测试对象会在结束时删除。 + ### 方式三:Cloudflare Pages(静态前端)+ 独立 API 服务 可将前端部署在 Cloudflare Pages,同时将 `server/` 独立部署到任意 Node 运行环境(VM、容器平台、Serverless 容器等):