diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..5163de48a --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# ===== Connection Mode ===== +# Choose ONE of the following modes: + +# Mode 1: Direct API Key (for Google AI Studio) +# Create your own API KEY at https://aistudio.google.com/apikey +#REACT_APP_GEMINI_API_KEY='' + +# Mode 2: Proxy Mode (for Vertex AI - recommended) +REACT_APP_PROXY_URL=ws://localhost:8080/api/ws + +# ===== Vertex AI Configuration (for backend proxy) ===== +GOOGLE_PROJECT_ID=your-project-id +GOOGLE_LOCATION=us-central1 +GEMINI_MODEL_ID=gemini-live-2.5-flash-preview-native-audio +# Use absolute path or path relative to where server is run +GOOGLE_KEY_FILE=/path/to/your/service-account-key.json + +# ===== Server Configuration ===== +PROXY_PORT=8080 +PROXY_HOST=0.0.0.0 diff --git a/.gitignore b/.gitignore index 4d29575de..e123c96da 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # dependencies /node_modules +/server/node_modules /.pnp .pnp.js @@ -13,11 +14,24 @@ # misc .DS_Store +.env .env.local .env.development.local .env.test.local .env.production.local +# IDE +.idea/ +.vscode/ + +# Google Cloud credentials +*.json +!package.json +!package-lock.json +!tsconfig.json +!pnpm-lock.yaml + npm-debug.log* yarn-debug.log* yarn-error.log* +pnpm-debug.log* diff --git a/server/package.json b/server/package.json new file mode 100644 index 000000000..d3ef475cc --- /dev/null +++ b/server/package.json @@ -0,0 +1,20 @@ +{ + "name": "live-api-proxy-server", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "bun --env-file=../.env run --watch src/index.ts", + "start": "bun run src/index.ts" + }, + "dependencies": { + "@fastify/cors": "^11.1.0", + "@fastify/websocket": "^11.1.0", + "@google/genai": "^1.0.0", + "fastify": "^5.2.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "pino-pretty": "^13.1.3", + "typescript": "^5.6.3" + } +} diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml new file mode 100644 index 000000000..e6593fb46 --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,1049 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fastify/cors': + specifier: ^11.1.0 + version: 11.2.0 + '@fastify/websocket': + specifier: ^11.1.0 + version: 11.2.0 + '@google/genai': + specifier: ^1.0.0 + version: 1.33.0 + fastify: + specifier: ^5.2.1 + version: 5.6.2 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.3 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@fastify/ajv-compiler@4.0.5': + resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + + '@fastify/cors@11.2.0': + resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.0.3': + resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==} + + '@fastify/forwarded@3.0.1': + resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.0': + resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + + '@fastify/websocket@11.2.0': + resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} + + '@google/genai@1.33.0': + resolution: {integrity: sha512-ThUjFZ1N0DU88peFjnQkb8K198EWaW2RmmnDShFQ+O+xkIH9itjpRe358x3L/b4X/A7dimkvq63oz49Vbh7Cog==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.24.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@types/node@22.19.3': + resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.1.0: + resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + duplexify@4.1.3: + resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-copy@4.0.1: + resolution: {integrity: sha512-+uUOQlhsaswsizHFmEFAQhB3lSiQ+lisxl50N6ZP0wywlZeWsIESxSi9ftPEps8UGfiBzyYP7x27zA674WUvXw==} + + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stringify@6.1.1: + resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastify-plugin@5.1.0: + resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==} + + fastify@5.6.2: + resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + find-my-way@9.3.0: + resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==} + engines: {node: '>=20'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + + help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@10.1.0: + resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==} + hasBin: true + + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex2@5.0.0: + resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + + toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@fastify/ajv-compiler@4.0.5': + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + + '@fastify/cors@11.2.0': + dependencies: + fastify-plugin: 5.1.0 + toad-cache: 3.7.0 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.0.3': + dependencies: + fast-json-stringify: 6.1.1 + + '@fastify/forwarded@3.0.1': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.0': + dependencies: + '@fastify/forwarded': 3.0.1 + ipaddr.js: 2.3.0 + + '@fastify/websocket@11.2.0': + dependencies: + duplexify: 4.1.3 + fastify-plugin: 5.1.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@google/genai@1.33.0': + dependencies: + google-auth-library: 10.5.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@pinojs/redact@0.4.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@types/node@22.19.3': + dependencies: + undici-types: 6.21.0 + + abstract-logging@2.0.1: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + atomic-sleep@1.0.0: {} + + avvio@9.1.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + buffer-equal-constant-time@1.0.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + dateformat@4.6.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dequal@2.0.3: {} + + duplexify@4.1.3: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.3 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + extend@3.0.2: {} + + fast-copy@4.0.1: {} + + fast-decode-uri-component@1.0.1: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stringify@6.1.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-uri: 3.1.0 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fastify-plugin@5.1.0: {} + + fastify@5.6.2: + dependencies: + '@fastify/ajv-compiler': 4.0.5 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.0.3 + '@fastify/proxy-addr': 5.1.0 + abstract-logging: 2.0.1 + avvio: 9.1.0 + fast-json-stringify: 6.1.1 + find-my-way: 9.3.0 + light-my-request: 6.6.0 + pino: 10.1.0 + process-warning: 5.0.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.3 + toad-cache: 3.7.0 + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + find-my-way@9.3.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.0.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + help-me@5.0.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + inherits@2.0.4: {} + + ipaddr.js@2.3.0: {} + + is-fullwidth-code-point@3.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + joycon@3.1.1: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + + json-schema-traverse@1.0.0: {} + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + + lru-cache@10.4.3: {} + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + ms@2.1.3: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + on-exit-leak-free@2.1.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-pretty@13.1.3: + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.0.1 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.3 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.0 + strip-json-comments: 5.0.3 + + pino-std-serializers@7.0.0: {} + + pino@10.1.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + + process-warning@4.0.1: {} + + process-warning@5.0.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + quick-format-unescaped@4.0.4: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + require-from-string@2.0.2: {} + + ret@0.5.0: {} + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + + safe-buffer@5.2.1: {} + + safe-regex2@5.0.0: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + + secure-json-parse@4.1.0: {} + + semver@7.7.3: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + + split2@4.2.0: {} + + stream-shift@1.0.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@5.0.3: {} + + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + + toad-cache@3.7.0: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + util-deprecate@1.0.2: {} + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + ws@8.18.3: {} diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 000000000..40670ba27 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,59 @@ +/** + * Environment configuration validation + */ + +import type { GeminiConfig, ServerConfig } from './types.js'; + +/** + * Get required environment variable or throw error + */ +function getEnvOrThrow(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +/** + * Get optional environment variable with default + */ +function getEnvOrDefault(key: string, defaultValue: string): string { + return process.env[key] || defaultValue; +} + +/** + * Load and validate Gemini configuration from environment + */ +export function loadGeminiConfig(): GeminiConfig { + return { + projectId: getEnvOrThrow('GOOGLE_PROJECT_ID'), + location: getEnvOrDefault('GOOGLE_LOCATION', 'us-central1'), + //GEMINI_MODEL_ID=gemini-live-2.5-flash-preview-native-audio-09-2025 + // modelId: getEnvOrDefault('GEMINI_MODEL_ID', 'gemini-2.0-flash-live-001'), + modelId: getEnvOrDefault('GEMINI_MODEL_ID', 'gemini-live-2.5-flash-preview-native-audio-09-2025'), + keyFilePath: getEnvOrThrow('GOOGLE_KEY_FILE'), + }; +} + +/** + * Load server configuration from environment + */ +export function loadServerConfig(): ServerConfig { + return { + port: parseInt(getEnvOrDefault('PROXY_PORT', '8080'), 10), + host: getEnvOrDefault('PROXY_HOST', '0.0.0.0'), + }; +} + +/** + * Validate that the key file exists + */ +export async function validateKeyFile(keyFilePath: string): Promise { + const fs = await import('fs/promises'); + try { + await fs.access(keyFilePath); + } catch { + throw new Error(`Service account key file not found: ${keyFilePath}`); + } +} diff --git a/server/src/gemini-proxy.ts b/server/src/gemini-proxy.ts new file mode 100644 index 000000000..dafcf0f95 --- /dev/null +++ b/server/src/gemini-proxy.ts @@ -0,0 +1,558 @@ +/** + * Gemini Live API WebSocket Proxy Handler + * + * This module handles WebSocket connections from frontend clients + * and proxies them to the Gemini Live API using Vertex AI authentication. + */ + +import { + type FunctionDeclaration, + GoogleGenAI, + type LiveConnectConfig, + type LiveServerMessage, + type Part, + type Session, + Type, +} from '@google/genai'; +import type {WebSocket} from 'ws'; +import type { + ClientContentMessage, + ClientMessage, + GeminiConfig, + RealtimeInputMessage, + SetupMessage, + ToolResponseMessage, +} from './types.js'; +import { product_data } from './product.js'; + +/** + * Tool declarations for Gemini Live API + * + * These tools are automatically injected into the Gemini session and can be + * called by the AI model during conversation. The model decides when to use + * these tools based on user queries. + * + * Supported tools: + * - add/subtract: Basic calculator functions + * - search_products: Product search with multi-keyword AND logic + * - cx360: Customer 360 information retrieval + */ +const calculatorTools: FunctionDeclaration[] = [ + { + name: 'add', + description: 'Add two numbers together. Use this when the user asks to add or sum numbers.', + parameters: { + type: Type.OBJECT, + properties: { + a: { + type: Type.NUMBER, + description: 'The first number', + }, + b: { + type: Type.NUMBER, + description: 'The second number', + }, + }, + required: ['a', 'b'], + }, + }, + { + name: 'search_products', + description: 'Search for products based on a keyword. Returns a list of matching products with details.', + parameters: { + type: Type.OBJECT, + properties: { + keyword: { + type: Type.STRING, + description: 'The search keyword', + }, + }, + required: ['keyword'], + } + + }, + { + name: 'cx360', + description: "Get comprehensive customer information including profile, purchase history, " + + "segments, preferences, and engagement notes. Returns natural language description " + + "that's easy for AI to understand and use for recommendations.", + parameters: { + type: Type.OBJECT, + properties: { + email: { + type: Type.STRING, + description: 'email', + }, + }, + required: ['email'], + } + }, + { + name: 'subtract', + description: 'Subtract the second number from the first. Use this when the user asks to subtract numbers.', + parameters: { + type: Type.OBJECT, + properties: { + a: { + type: Type.NUMBER, + description: 'The first number (minuend)', + }, + b: { + type: Type.NUMBER, + description: 'The second number to subtract (subtrahend)', + }, + }, + required: ['a', 'b'], + }, + }, +]; + + +/** + * Search products by multiple keywords with AND logic + * + * Searches across product name, description, category, keywords, and features. + * All keywords must match for a product to be included in results (AND logic). + * + * @param keyword - Space-separated keywords to search for + * @returns Object containing array of matching products + * + * @example + * search_products('running shoes') + * // Returns products containing both "running" AND "shoes" + * + * search_products('waterproof hiking') + * // Returns products containing both "waterproof" AND "hiking" + */ +function search_products(keyword: string) { + // Split keywords by spaces and normalize to lowercase + const searchKeywords = keyword + .toLowerCase() + .split(/\s+/) + .filter(k => k.length > 0); + + // Return empty result if no valid keywords + if (searchKeywords.length === 0) { + return { product: [] }; + } + + // Use real product data from product.ts (12 products) + const products = product_data.products; + + // Filter products using AND logic (all keywords must match) + const product = products.filter(p => { + // Build searchable text from all product fields + const searchableText = [ + p.name.toLowerCase(), + p.description.toLowerCase(), + p.category.toLowerCase(), + ...(p.keywords || []).map(k => k.toLowerCase()), + ...(p.features || []).map(f => f.toLowerCase()) + ].join(' '); + + // All keywords must be present (AND logic) + return searchKeywords.every(keyword => + searchableText.includes(keyword) + ); + }); + + return { product }; +} + +function callCx360Tool(email: string) { + const info = `The "Pristine Explorer" +Name: Miles Chen +Age: 30 +Gender: Male +Occupation: UX Designer +Location: Seattle, WA (Urban resident with easy access to nature) +Income Level: Middle Income (Budget-conscious but willing to pay for durability) +Bio & Personality +Miles is a tech-savvy professional who spends his weekdays in a clean, organized office and his weekends chasing the perfect sunset shot. While he loves the experience of the outdoors—the fresh air, the views, and the exercise—he dislikes the mess associated with it. You will never catch him rolling in the mud or sleeping on the bare ground. He is meticulous, detail-oriented, and values aesthetics just as much as functionality. +Interests & Hobbies +Landscape Photography: He carries expensive camera gear and is terrified of getting dust or grit inside his lenses. +Light Hiking & Trekking: Prefers well-maintained trails over bushwhacking. +Urban Cycling: Commutes to work occasionally but hates arriving sweaty or splashed with road grime. +Tech & Gadgets: Loves integrating technology into his outdoor activities (drones, GPS watches). +Financial Outlook (Moderate Economic Basis) +Miles is financially stable but not wealthy. He researches purchases extensively before buying. He cannot afford to replace gear constantly, so he looks for "investment pieces"—mid-to-high-range products that promise longevity and versatility. He is susceptible to value bundles or financing options (Buy Now, Pay Later) for more expensive items like high-end jackets or tents. +The "Clean Freak" Constraint (Dislikes Dirty/Messy Things) +This is Miles' defining consumer trait. +The Problem: He loves nature but hates the "grime factor" (mud, bugs, sweat, chaotic packing). +The Need: He looks for gear that is stain-resistant, waterproof, easy to wipe down, and anti-microbial. +Organization: He despises a messy backpack. He loves packing cubes, compartmentalized bags, and gear with dedicated pockets for his camera equipment. +Shopping Preferences & Triggers +Style: Minimalist, sleek, "Gorpcore" aesthetic (functional but stylish enough for the city). Avoids overly loud colors or rugged "survivalist" looks. +Keywords that attract him: "Easy-clean," "Water-repellent," "Organized," "Odor-control," "Matte finish." +Deal Breakers: Materials that attract lint/pet hair, light-colored shoes that stain instantly, or complicated gear that is hard to wash. +How Nova (The AI) Should Approach Him: +Tone: Precise, polished, and focusing on specs regarding materials and maintenance. +Strategy: Highlight products with Nano-tech coatings or stain resistance. When suggesting hiking boots, suggest the ones that are easy to rinse off, not the heavy leather ones that hold mud. Suggest accessories like camera inserts or waterproof dry bags to keep his gear spotless.` + + return { + info + } +} + +/** + * Execute a tool and return the result + */ +function executeTool(name: string, args: { a: number; b: number }): { result: number } { + console.log(`[Tool] Executing ${name} with args:`, args); + + switch (name) { + case 'add': + return {result: args.a + args.b}; + case 'subtract': + return {result: args.a - args.b}; + default: + throw new Error(`Unknown tool: ${name}`); + } +} + +/** + * Send a JSON message to the client WebSocket + */ +function sendToClient(ws: WebSocket, type: string, data?: unknown): void { + if (ws.readyState === 1) { + // WebSocket.OPEN + ws.send(JSON.stringify({type, data})); + } +} + +/** + * Send an error message to the client + */ +function sendError(ws: WebSocket, error: string): void { + if (ws.readyState === 1) { + ws.send(JSON.stringify({type: 'error', error})); + } +} + +/** + * Handle a WebSocket connection from a frontend client + */ +export async function handleProxyConnection( + clientWs: WebSocket, + geminiConfig: GeminiConfig +): Promise { + let geminiSession: Session | null = null; + let isSetupComplete = false; + + console.log('[Proxy] New client connection'); + + // Initialize GoogleGenAI with Vertex AI authentication + const ai = new GoogleGenAI({ + vertexai: true, + project: geminiConfig.projectId, + location: geminiConfig.location, + googleAuthOptions: { + keyFilename: geminiConfig.keyFilePath, + }, + }); + + console.log('[Proxy] GoogleGenAI initialized with Vertex AI'); + + /** + * Handle setup message - create Gemini Live session + */ + async function handleSetup(message: SetupMessage): Promise { + if (geminiSession) { + console.log('[Proxy] Session already exists, closing old one'); + geminiSession.close(); + } + + const {config} = message; + const fullModel = `projects/${geminiConfig.projectId}/locations/${geminiConfig.location}/publishers/google/models/${geminiConfig.modelId}`; + + console.log(`[Proxy] Creating Gemini session with model: ${fullModel}`); + + // Inject calculator tools into config + const configWithTools: LiveConnectConfig = { + ...config, + tools: [ + ...(config.tools || []), + {functionDeclarations: calculatorTools}, + ], + }; + + configWithTools.thinkingConfig = { + // includeThoughts: true, + // thinkingLevel: ThinkingLevel.HIGH + } + + + console.log('[Proxy] Gemini config prepared', configWithTools); +// configWithTools.systemInstruction = ` +// Nova - StrideNova Intelligent Assistant +// ` + console.log('[Proxy] Injected calculator tools:', calculatorTools.map(t => t.name)); + + try { + geminiSession = await ai.live.connect({ + model: fullModel, + config: configWithTools, + callbacks: { + onopen: () => { + console.log('[Proxy] Gemini session opened'); + sendToClient(clientWs, 'open'); + }, + onmessage: (message: LiveServerMessage) => { + handleGeminiMessage(message); + }, + onerror: (e: ErrorEvent) => { + console.error('[Proxy] Gemini error:', e.message); + sendError(clientWs, e.message); + }, + onclose: (e: CloseEvent) => { + console.log('[Proxy] Gemini session closed:', e.reason); + sendToClient(clientWs, 'close', {reason: e.reason}); + }, + }, + }); + + isSetupComplete = true; + console.log('[Proxy] Gemini session created successfully'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[Proxy] Failed to create Gemini session:', errorMessage); + sendError(clientWs, `Failed to connect to Gemini: ${errorMessage}`); + } + } + + /** + * Handle messages from Gemini and forward to client + */ + function handleGeminiMessage(message: LiveServerMessage): void { + // Setup complete + if (message.setupComplete) { + console.log('[Proxy] Setup complete'); + sendToClient(clientWs, 'setupComplete'); + return; + } + + // Tool call - execute locally and respond + if (message.toolCall) { + console.log('[Proxy] Tool call received:', message.toolCall); + + const functionCalls = message.toolCall.functionCalls || []; + const functionResponses: Array<{ id: string; name: string; response: Record }> = []; + + for (const fc of functionCalls) { + const {name, args, id} = fc; + + switch (name) { + case 'search_products': + const searchResult = search_products((args as any).keyword); + functionResponses.push({ + id: id || '', + name: name, + response: searchResult, + }); + sendToClient(clientWs, 'toolCall', { + name, + args, + result: searchResult, + }); + break; + + } + + if (name === 'cx360') { + const result = callCx360Tool((args as any).email); + functionResponses.push({ + id: id || '', + name: name, + response: result, + }); + sendToClient(clientWs, 'toolCall', { + name, + args, + result, + }); + } + // Check if this is one of our calculator tools + if (name === 'add' || name === 'subtract') { + try { + const result = executeTool(name, args as { a: number; b: number }); + console.log(`[Tool] ${name}(${(args as any).a}, ${(args as any).b}) = ${result.result}`); + + functionResponses.push({ + id: id || '', + name: name, + response: result, + }); + + // Also notify client about the tool call for display + sendToClient(clientWs, 'toolCall', { + name, + args, + result, + }); + } catch (error) { + console.error(`[Tool] Error executing ${name}:`, error); + functionResponses.push({ + id: id || '', + name: name, + response: {error: String(error)}, + }); + } + } else { + // Forward unknown tools to client + sendToClient(clientWs, 'toolCall', message.toolCall); + } + } + + // Send tool responses back to Gemini + if (functionResponses.length > 0 && geminiSession) { + console.log('[Proxy] Sending tool responses to Gemini:', functionResponses); + geminiSession.sendToolResponse({ + functionResponses, + }); + } + return; + } + + // Tool call cancellation + if (message.toolCallCancellation) { + console.log('[Proxy] Tool call cancellation'); + sendToClient(clientWs, 'toolCallCancellation', message.toolCallCancellation); + return; + } + + // Server content + if (message.serverContent) { + const {serverContent} = message; + + // Interrupted + if ('interrupted' in serverContent && serverContent.interrupted) { + console.log('[Proxy] Interrupted'); + sendToClient(clientWs, 'interrupted'); + return; + } + + // Turn complete + if ('turnComplete' in serverContent && serverContent.turnComplete) { + console.log('[Proxy] Turn complete'); + sendToClient(clientWs, 'turnComplete'); + } + + // Model turn with parts + if ('modelTurn' in serverContent && serverContent.modelTurn) { + const parts: Part[] = serverContent.modelTurn.parts || []; + + // Extract audio parts + const audioParts = parts.filter( + (p) => p.inlineData && p.inlineData.mimeType?.startsWith('audio/pcm') + ); + + // Send audio parts + for (const audioPart of audioParts) { + if (audioPart.inlineData?.data) { + sendToClient(clientWs, 'audio', audioPart.inlineData.data); + } + } + + // Send non-audio content + const otherParts = parts.filter( + (p) => !p.inlineData || !p.inlineData.mimeType?.startsWith('audio/pcm') + ); + + if (otherParts.length > 0) { + sendToClient(clientWs, 'content', { + modelTurn: {parts: otherParts}, + }); + } + } + } + } + + /** + * Handle realtime input (audio/video) + */ + function handleRealtimeInput(message: RealtimeInputMessage): void { + if (!geminiSession || !isSetupComplete) { + console.warn('[Proxy] Session not ready, dropping realtime input'); + return; + } + + for (const chunk of message.chunks) { + geminiSession.sendRealtimeInput({media: chunk}); + } + } + + /** + * Handle client content (text) + */ + function handleClientContent(message: ClientContentMessage): void { + if (!geminiSession || !isSetupComplete) { + console.warn('[Proxy] Session not ready, dropping client content'); + return; + } + + geminiSession.sendClientContent({ + turns: message.turns, + turnComplete: message.turnComplete, + }); + } + + /** + * Handle tool response + */ + function handleToolResponse(message: ToolResponseMessage): void { + if (!geminiSession || !isSetupComplete) { + console.warn('[Proxy] Session not ready, dropping tool response'); + return; + } + + geminiSession.sendToolResponse({ + functionResponses: message.functionResponses, + }); + } + + // Handle messages from client + clientWs.on('message', async (data: Buffer) => { + try { + const message: ClientMessage = JSON.parse(data.toString()); + + switch (message.type) { + case 'setup': + await handleSetup(message); + break; + case 'realtimeInput': + handleRealtimeInput(message); + break; + case 'clientContent': + handleClientContent(message); + break; + case 'toolResponse': + handleToolResponse(message); + break; + default: + console.warn('[Proxy] Unknown message type:', (message as any).type); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[Proxy] Error processing message:', errorMessage); + sendError(clientWs, errorMessage); + } + }); + + // Handle client disconnect + clientWs.on('close', (code: number, reason: Buffer) => { + console.log(`[Proxy] Client disconnected: ${code} ${reason.toString()}`); + if (geminiSession) { + geminiSession.close(); + geminiSession = null; + } + }); + + // Handle client error + clientWs.on('error', (error: Error) => { + console.error('[Proxy] Client WebSocket error:', error.message); + if (geminiSession) { + geminiSession.close(); + geminiSession = null; + } + }); +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 000000000..3492ab66e --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,90 @@ +/** + * Gemini Live API Proxy Server + * + * A Fastify-based WebSocket server that proxies connections + * from frontend clients to the Gemini Live API using Vertex AI authentication. + */ + +import Fastify from 'fastify'; +import fastifyWebsocket from '@fastify/websocket'; +import fastifyCors from '@fastify/cors'; +import { loadGeminiConfig, loadServerConfig, validateKeyFile } from './config.js'; +import { handleProxyConnection } from './gemini-proxy.js'; + +async function main(): Promise { + // Load configuration + const geminiConfig = loadGeminiConfig(); + const serverConfig = loadServerConfig(); + + console.log('Gemini Proxy Server starting...'); + console.log('Configuration:'); + console.log(` Project ID: ${geminiConfig.projectId}`); + console.log(` Location: ${geminiConfig.location}`); + console.log(` Model: ${geminiConfig.modelId}`); + console.log(` Key File: ${geminiConfig.keyFilePath}`); + + // Validate key file exists + await validateKeyFile(geminiConfig.keyFilePath); + console.log(' Key file validated'); + + // Create Fastify instance + const fastify = Fastify({ + logger: { + level: 'info', + transport: { + target: 'pino-pretty', + options: { + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname', + }, + }, + }, + }); + + // Register CORS + await fastify.register(fastifyCors, { + origin: true, // Allow all origins in development + credentials: true, + }); + + // Register WebSocket plugin + await fastify.register(fastifyWebsocket); + + // Health check endpoint + fastify.get('/api/health', async () => { + return { + status: 'ok', + timestamp: new Date().toISOString(), + config: { + projectId: geminiConfig.projectId, + location: geminiConfig.location, + modelId: geminiConfig.modelId, + }, + }; + }); + + // WebSocket endpoint + fastify.register(async function (fastify) { + fastify.get('/api/ws', { websocket: true }, (socket, _req) => { + handleProxyConnection(socket, geminiConfig); + }); + }); + + // Start server + try { + await fastify.listen({ + port: serverConfig.port, + host: serverConfig.host, + }); + console.log(`Server listening on http://${serverConfig.host}:${serverConfig.port}`); + console.log(`WebSocket endpoint: ws://${serverConfig.host}:${serverConfig.port}/api/ws`); + } catch (err) { + fastify.log.error(err); + process.exit(1); + } +} + +main().catch((err) => { + console.error('Failed to start server:', err); + process.exit(1); +}); diff --git a/server/src/product.ts b/server/src/product.ts new file mode 100644 index 000000000..1f7c2eb8c --- /dev/null +++ b/server/src/product.ts @@ -0,0 +1,227 @@ +export const product_data = { + "products": [ + { + "id": "SKU-RUN-001", + "name": "NovaGlide X1", + "category": "Running Shoes", + "price": 129.00, + "currency": "USD", + "features": ["High-Cushion", "Road Running", "Breathable Mesh"], + "stock_status": "In Stock", + "description": "Our flagship road runner featuring CloudForm™ midsole for maximum energy return.", + "keywords": [ + "running shoes", + "road running", + "sneakers", + "athletic", + "novaglide", + "footwear" + ] + }, + { + "id": "SKU-RUN-002", + "name": "TerraTrek MudGuard Pro", + "category": "Trail Running Shoes", + "price": 145.00, + "currency": "USD", + "features": ["Waterproof Gore-Tex", "Mud-Shedding Sole", "Quick-Lace System"], + "stock_status": "Low Stock", + "description": "Designed for messy trails. The hydrophobic coating repels mud and water instantly, keeping your feet dry and clean.", + "keywords": [ + "trail running shoes", + "running shoes", + "hiking", + "waterproof", + "gore-tex", + "mudguard", + "terratrek" + ] + }, + { + "id": "SKU-RUN-003", + "name": "AeroPulse Lite", + "category": "Speed Running Shoes", + "price": 99.00, + "currency": "USD", + "features": ["Ultra-Lightweight (180g)", "Carbon Plate", "Race Day Fit"], + "stock_status": "In Stock", + "description": "Built for speed. Minimalist design for 5K to Marathon distances.", + "keywords": [ + "speed running shoes", + "running shoes", + "racing", + "marathon", + "lightweight", + "aeropulse" + ] + }, + { + "id": "SKU-RUN-004", + "name": "NightRunner Reflector", + "category": "Urban Running Shoes", + "price": 110.00, + "currency": "USD", + "features": ["360° Reflectivity", "Asphalt Grip", "Odor-Control Insole"], + "stock_status": "In Stock", + "description": "Safety first. High-visibility materials for city running at night.", + "keywords": [ + "urban running shoes", + "running shoes", + "reflective", + "night", + "safety", + "jogging", + "nightrunner" + ] + }, + { + "id": "SKU-RUN-005", + "name": "CloudWalk Recovery", + "category": "Recovery Footwear", + "price": 85.00, + "currency": "USD", + "features": ["Slip-On", "Arch Support", "Machine Washable"], + "stock_status": "Out of Stock", + "description": "Perfect for post-run relaxation. Easy to slip on and fully machine washable.", + "keywords": [ + "recovery footwear", + "running shoes", + "sandals", + "slip-on", + "comfort", + "cloudwalk" + ] + }, + { + "id": "SKU-HKG-001", + "name": "SummitPack 40L Modular", + "category": "Backpacks", + "price": 180.00, + "currency": "USD", + "features": ["Stain-Resistant Fabric", "Camera Compartment", "Rain Cover Included"], + "stock_status": "In Stock", + "description": "A technical pack for day hikes. Features a dedicated padded zone for photography gear and a wipe-clean exterior surface.", + "keywords": [ + "backpacks", + "hiking equipment", + "mountaineering", + "bag", + "camera bag", + "summitpack", + "outdoor" + ] + }, + { + "id": "SKU-HKG-002", + "name": "Carbon Lite Trekking Poles", + "category": "Hiking Accessories", + "price": 75.00, + "currency": "USD", + "features": ["Collapsible", "Carbon Fiber", "Cork Grip"], + "stock_status": "In Stock", + "description": "Save your knees without adding weight. Folds down small enough to fit inside your bag.", + "keywords": [ + "hiking accessories", + "hiking equipment", + "mountaineering", + "trekking poles", + "walking sticks", + "carbon lite" + ] + }, + { + "id": "SKU-HKG-003", + "name": "Omni-Shield Dry Bag Set", + "category": "Storage", + "price": 35.00, + "currency": "USD", + "features": ["100% Waterproof", "Transparent Window", "3 Sizes"], + "stock_status": "In Stock", + "description": "Keep your gadgets, wallet, and keys completely separated from dirty gear or rain.", + "keywords": [ + "storage", + "hiking equipment", + "mountaineering", + "dry bag", + "waterproof", + "organizer", + "omni-shield" + ] + }, + { + "id": "SKU-JKT-001", + "name": "StormGuard Ultra Shell", + "category": "Hardshell Jackets", + "price": 220.00, + "currency": "USD", + "features": ["3-Layer Waterproofing", "Windproof", "Pit Zips"], + "stock_status": "In Stock", + "description": "Our toughest protection against severe weather. Guaranteed to keep you dry in a downpour.", + "keywords": [ + "hardshell jackets", + "冲锋衣", + "jackets", + "raincoat", + "outerwear", + "stormguard", + "waterproof" + ] + }, + { + "id": "SKU-JKT-002", + "name": "CityTrek Commuter Coat", + "category": "Lifestyle Jackets", + "price": 150.00, + "currency": "USD", + "features": ["Matte Finish", "Hidden Pockets", "Stain Repellent"], + "stock_status": "In Stock", + "description": "Sleek enough for the office, tough enough for the trail. Features a nano-coating that repels coffee spills and rain alike.", + "keywords": [ + "lifestyle jackets", + "冲锋衣", + "jackets", + "coat", + "commuter", + "urban", + "citytrek" + ] + }, + { + "id": "SKU-JKT-003", + "name": "AeroLite Windbreaker", + "category": "Lightweight Jackets", + "price": 89.00, + "currency": "USD", + "features": ["Packable", "Water Resistant", "UV Protection"], + "stock_status": "In Stock", + "description": "Packs into its own pocket. Ideal for windy summits or breezy city evenings.", + "keywords": [ + "lightweight jackets", + "冲锋衣", + "jackets", + "windbreaker", + "softshell", + "aerolite" + ] + }, + { + "id": "SKU-JKT-004", + "name": "TechFleece Midlayer", + "category": "Fleece Jackets", + "price": 110.00, + "currency": "USD", + "features": ["Anti-Pilling", "Moisture Wicking", "Zippered Chest Pocket"], + "stock_status": "Low Stock", + "description": "Warmth without the bulk. A high-density fleece that resists dog hair and lint.", + "keywords": [ + "fleece jackets", + "冲锋衣", + "jackets", + "midlayer", + "sweater", + "techfleece", + "winter" + ] + } +] +} diff --git a/server/src/types.ts b/server/src/types.ts new file mode 100644 index 000000000..965c82d68 --- /dev/null +++ b/server/src/types.ts @@ -0,0 +1,104 @@ +/** + * Type definitions for Gemini Live API Proxy Server + */ + +import type { LiveConnectConfig, Part } from '@google/genai'; + +/** + * Gemini configuration from environment variables + */ +export interface GeminiConfig { + projectId: string; + location: string; + modelId: string; + keyFilePath: string; +} + +/** + * Server configuration + */ +export interface ServerConfig { + port: number; + host: string; +} + +/** + * Client → Proxy message types + */ +export type ClientMessageType = + | 'setup' + | 'realtimeInput' + | 'clientContent' + | 'toolResponse'; + +/** + * Setup message - sent first to configure the session + */ +export interface SetupMessage { + type: 'setup'; + model: string; + config: LiveConnectConfig; +} + +/** + * Realtime input message - audio/video chunks + */ +export interface RealtimeInputMessage { + type: 'realtimeInput'; + chunks: Array<{ mimeType: string; data: string }>; +} + +/** + * Client content message - text/parts + */ +export interface ClientContentMessage { + type: 'clientContent'; + turns: Part | Part[]; + turnComplete: boolean; +} + +/** + * Tool response message + */ +export interface ToolResponseMessage { + type: 'toolResponse'; + functionResponses: Array<{ + id: string; + name: string; + response: Record; + }>; +} + +/** + * Union of all client messages + */ +export type ClientMessage = + | SetupMessage + | RealtimeInputMessage + | ClientContentMessage + | ToolResponseMessage; + +/** + * Proxy → Client message types + */ +export type ProxyMessageType = + | 'open' + | 'setupComplete' + | 'audio' + | 'content' + | 'toolCall' + | 'toolCallCancellation' + | 'turnComplete' + | 'interrupted' + | 'error' + | 'close'; + +/** + * Base proxy message + */ +export interface ProxyMessage { + type: ProxyMessageType; + data?: unknown; + error?: string; + reason?: string; +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 000000000..0633f6491 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "resolveJsonModule": true, + "types": ["node", "bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/App.tsx b/src/App.tsx index 31f00b16b..933733940 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,14 +23,27 @@ import ControlTray from "./components/control-tray/ControlTray"; import cn from "classnames"; import { LiveClientOptions } from "./types"; -const API_KEY = process.env.REACT_APP_GEMINI_API_KEY as string; -if (typeof API_KEY !== "string") { - throw new Error("set REACT_APP_GEMINI_API_KEY in .env"); -} +// Support both direct API key mode and proxy mode (for Vertex AI) +const PROXY_URL = process.env.REACT_APP_PROXY_URL; +const API_KEY = process.env.REACT_APP_GEMINI_API_KEY; + +// Determine connection mode +const apiOptions: LiveClientOptions = PROXY_URL + ? { proxyUrl: PROXY_URL } + : (() => { + if (typeof API_KEY !== "string") { + throw new Error( + "Set REACT_APP_GEMINI_API_KEY or REACT_APP_PROXY_URL in .env" + ); + } + return { apiKey: API_KEY }; + })(); -const apiOptions: LiveClientOptions = { - apiKey: API_KEY, -}; +// Log connection mode +console.log( + "Connection mode:", + PROXY_URL ? `Proxy (${PROXY_URL})` : "Direct API Key" +); function App() { // this video reference is used for displaying the active stream, whether that is the webcam or screen capture diff --git a/src/hooks/use-live-api.ts b/src/hooks/use-live-api.ts index 30659c57a..a0708eec2 100644 --- a/src/hooks/use-live-api.ts +++ b/src/hooks/use-live-api.ts @@ -16,14 +16,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { GenAILiveClient } from "../lib/genai-live-client"; -import { LiveClientOptions } from "../types"; +import { GenAIProxyClient } from "../lib/genai-proxy-client"; +import { LiveClientOptions, isProxyMode, ApiKeyClientOptions } from "../types"; import { AudioStreamer } from "../lib/audio-streamer"; import { audioContext } from "../lib/utils"; import VolMeterWorket from "../lib/worklets/vol-meter"; import { LiveConnectConfig } from "@google/genai"; +/** + * Union type for both client types + */ +type LiveClient = GenAILiveClient | GenAIProxyClient; + export type UseLiveAPIResults = { - client: GenAILiveClient; + client: LiveClient; setConfig: (config: LiveConnectConfig) => void; config: LiveConnectConfig; model: string; @@ -35,7 +41,15 @@ export type UseLiveAPIResults = { }; export function useLiveAPI(options: LiveClientOptions): UseLiveAPIResults { - const client = useMemo(() => new GenAILiveClient(options), [options]); + // Factory: choose client based on options type + const client = useMemo(() => { + if (isProxyMode(options)) { + console.log("Using proxy client:", options.proxyUrl); + return new GenAIProxyClient(options); + } + console.log("Using direct API client"); + return new GenAILiveClient(options as ApiKeyClientOptions); + }, [options]); const audioStreamerRef = useRef(null); const [model, setModel] = useState("models/gemini-2.0-flash-exp"); diff --git a/src/lib/genai-live-client.ts b/src/lib/genai-live-client.ts index 54936c04d..f5bf1fef8 100644 --- a/src/lib/genai-live-client.ts +++ b/src/lib/genai-live-client.ts @@ -30,7 +30,7 @@ import { import { EventEmitter } from "eventemitter3"; import { difference } from "lodash"; -import { LiveClientOptions, StreamingLog } from "../types"; +import { ApiKeyClientOptions, StreamingLog } from "../types"; import { base64ToArrayBuffer } from "./utils"; /** @@ -93,7 +93,7 @@ export class GenAILiveClient extends EventEmitter { return { ...this.config }; } - constructor(options: LiveClientOptions) { + constructor(options: ApiKeyClientOptions) { super(); this.client = new GoogleGenAI(options); this.send = this.send.bind(this); diff --git a/src/lib/genai-proxy-client.ts b/src/lib/genai-proxy-client.ts new file mode 100644 index 000000000..d6afc4842 --- /dev/null +++ b/src/lib/genai-proxy-client.ts @@ -0,0 +1,332 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Proxy Client for Gemini Live API + * + * This client connects to a backend WebSocket proxy server instead of + * directly to the Gemini Live API. It maintains the same event interface + * as GenAILiveClient for compatibility. + */ + +import { + Content, + LiveClientToolResponse, + LiveConnectConfig, + LiveServerContent, + LiveServerToolCall, + LiveServerToolCallCancellation, + Part, +} from "@google/genai"; + +import { EventEmitter } from "eventemitter3"; +import { StreamingLog } from "../types"; +import { base64ToArrayBuffer } from "./utils"; +import type { LiveClientEventTypes } from "./genai-live-client"; + +/** + * Options for the proxy client + */ +export interface ProxyClientOptions { + proxyUrl: string; +} + +/** + * A proxy client that connects to a backend WebSocket server + * which handles the actual Gemini Live API connection. + */ +export class GenAIProxyClient extends EventEmitter { + private proxyUrl: string; + private ws: WebSocket | null = null; + + private _status: "connected" | "disconnected" | "connecting" = "disconnected"; + public get status() { + return this._status; + } + + private _model: string | null = null; + public get model() { + return this._model; + } + + protected config: LiveConnectConfig | null = null; + + public getConfig() { + return { ...this.config }; + } + + constructor(options: ProxyClientOptions) { + super(); + this.proxyUrl = options.proxyUrl; + this.send = this.send.bind(this); + } + + protected log(type: string, message: StreamingLog["message"]) { + const log: StreamingLog = { + date: new Date(), + type, + message, + }; + this.emit("log", log); + } + + async connect(model: string, config: LiveConnectConfig): Promise { + if (this._status === "connected" || this._status === "connecting") { + return false; + } + + this._status = "connecting"; + this.config = config; + this._model = model; + + try { + this.ws = new WebSocket(this.proxyUrl); + + this.ws.onopen = () => { + this.log("client.open", "Connected to proxy"); + + // Send setup message with model and config + this.ws?.send( + JSON.stringify({ + type: "setup", + model, + config, + }) + ); + }; + + this.ws.onmessage = (event: MessageEvent) => { + this.handleProxyMessage(event); + }; + + this.ws.onerror = (event: Event) => { + this.log("proxy.error", "WebSocket error"); + const errorEvent = new ErrorEvent("error", { + message: "WebSocket connection error", + }); + this.emit("error", errorEvent); + }; + + this.ws.onclose = (event: CloseEvent) => { + this.log( + "proxy.close", + `disconnected ${event.reason ? `with reason: ${event.reason}` : ""}` + ); + this._status = "disconnected"; + this.emit("close", event); + }; + + // Wait for connection to establish + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Connection timeout")); + }, 10000); + + const onOpen = () => { + clearTimeout(timeout); + this.ws?.removeEventListener("open", onOpen); + resolve(); + }; + + const onError = () => { + clearTimeout(timeout); + this.ws?.removeEventListener("error", onError); + reject(new Error("Connection failed")); + }; + + this.ws?.addEventListener("open", onOpen); + this.ws?.addEventListener("error", onError); + }); + + this._status = "connected"; + return true; + } catch (e) { + console.error("Error connecting to proxy:", e); + this._status = "disconnected"; + return false; + } + } + + public disconnect() { + if (!this.ws) { + return false; + } + this.ws.close(); + this.ws = null; + this._status = "disconnected"; + + this.log("client.close", "Disconnected"); + return true; + } + + private handleProxyMessage(event: MessageEvent) { + try { + const message = JSON.parse(event.data); + const { type, data, error, reason } = message; + + switch (type) { + case "open": + this.log("server.open", "Gemini session opened"); + this.emit("open"); + break; + + case "setupComplete": + this.log("server.send", "setupComplete"); + this.emit("setupcomplete"); + break; + + case "audio": + // data is base64 string + if (data) { + const arrayBuffer = base64ToArrayBuffer(data); + this.emit("audio", arrayBuffer); + this.log("server.audio", `buffer (${arrayBuffer.byteLength})`); + } + break; + + case "content": + // data is LiveServerContent + if (data) { + this.emit("content", data as LiveServerContent); + this.log("server.content", data); + } + break; + + case "toolCall": + this.log("server.toolCall", data); + this.emit("toolcall", data as LiveServerToolCall); + break; + + case "toolCallCancellation": + this.log("server.toolCallCancellation", data); + this.emit( + "toolcallcancellation", + data as LiveServerToolCallCancellation + ); + break; + + case "turnComplete": + this.log("server.content", "turnComplete"); + this.emit("turncomplete"); + break; + + case "interrupted": + this.log("server.content", "interrupted"); + this.emit("interrupted"); + break; + + case "error": + this.log("server.error", error || "Unknown error"); + this.emit( + "error", + new ErrorEvent("error", { message: error || "Unknown error" }) + ); + break; + + case "close": + this.log("server.close", reason || "Connection closed"); + break; + + default: + console.log("Unknown message type from proxy:", type); + } + } catch (e) { + console.error("Error parsing proxy message:", e); + } + } + + /** + * send realtimeInput, this is base64 chunks of "audio/pcm" and/or "image/jpg" + */ + sendRealtimeInput(chunks: Array<{ mimeType: string; data: string }>) { + if (!this.ws || this._status !== "connected") { + return; + } + + this.ws.send( + JSON.stringify({ + type: "realtimeInput", + chunks, + }) + ); + + let hasAudio = false; + let hasVideo = false; + for (const ch of chunks) { + if (ch.mimeType.includes("audio")) { + hasAudio = true; + } + if (ch.mimeType.includes("image")) { + hasVideo = true; + } + if (hasAudio && hasVideo) { + break; + } + } + const message = + hasAudio && hasVideo + ? "audio + video" + : hasAudio + ? "audio" + : hasVideo + ? "video" + : "unknown"; + this.log("client.realtimeInput", message); + } + + /** + * send a response to a function call and provide the id of the functions you are responding to + */ + sendToolResponse(toolResponse: LiveClientToolResponse) { + if (!this.ws || this._status !== "connected") { + return; + } + + if ( + toolResponse.functionResponses && + toolResponse.functionResponses.length + ) { + this.ws.send( + JSON.stringify({ + type: "toolResponse", + functionResponses: toolResponse.functionResponses, + }) + ); + this.log("client.toolResponse", toolResponse); + } + } + + /** + * send normal content parts such as { text } + */ + send(parts: Part | Part[], turnComplete: boolean = true) { + if (!this.ws || this._status !== "connected") { + return; + } + + this.ws.send( + JSON.stringify({ + type: "clientContent", + turns: parts, + turnComplete, + }) + ); + this.log("client.send", { + turns: Array.isArray(parts) ? parts : [parts], + turnComplete, + }); + } +} diff --git a/src/types.ts b/src/types.ts index 9f5b2b26e..fe58d30fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,9 +22,28 @@ import { } from "@google/genai"; /** - * the options to initiate the client, ensure apiKey is required + * Options for direct API key mode */ -export type LiveClientOptions = GoogleGenAIOptions & { apiKey: string }; +export type ApiKeyClientOptions = GoogleGenAIOptions & { apiKey: string }; + +/** + * Options for proxy mode (Vertex AI via backend) + */ +export type ProxyClientOptions = { proxyUrl: string }; + +/** + * the options to initiate the client + * - ApiKeyClientOptions: Direct connection with API key + * - ProxyClientOptions: Connection via backend proxy (for Vertex AI) + */ +export type LiveClientOptions = ApiKeyClientOptions | ProxyClientOptions; + +/** + * Type guard to check if options are for proxy mode + */ +export function isProxyMode(options: LiveClientOptions): options is ProxyClientOptions { + return 'proxyUrl' in options; +} /** log types */ export type StreamingLog = {