feat(infra): route Gemini WS through SOCKS5 proxy (WARP)
Some checks are pending
CI / quality (push) Waiting to run

- Add socks-proxy-agent dependency
- Add resolveGeminiProxyAgent() helper reading GEMINI_PROXY_URL env
- Apply agent to T1 and T2 Gemini WS factory defaults
- No proxy when GEMINI_PROXY_URL is unset (local dev unchanged)
- Tests: 311/311 green
This commit is contained in:
Hermann_Kitio 2026-06-30 20:30:15 +03:00
parent 74770b6402
commit 5263372839
7 changed files with 139 additions and 4 deletions

View file

@ -6,6 +6,9 @@ SUPABASE_SERVICE_ROLE_KEY=xxx
DEEPSEEK_API_KEY=xxx DEEPSEEK_API_KEY=xxx
GEMINI_API_KEY=xxx GEMINI_API_KEY=xxx
DEEPGRAM_API_KEY=xxx DEEPGRAM_API_KEY=xxx
# Proxy SOCKS5 optionnel pour les WS Gemini Live (ex: Cloudflare WARP sur le VPS
# prod dont l'IP est bloquée par Google). Absent = connexion directe (dev local).
GEMINI_PROXY_URL=
# Stripe # Stripe
STRIPE_SECRET_KEY=xxx STRIPE_SECRET_KEY=xxx

View file

@ -6,6 +6,26 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/).
--- ---
## [Unreleased] — 2026-06-30 — Proxy SOCKS5 (Cloudflare WARP) pour les WS Gemini Live
### Added
- `resolveGeminiProxyAgent()` (`geminiLive.ts`) — helper qui lit la variable d'environnement **optionnelle** `GEMINI_PROXY_URL` et renvoie un `SocksProxyAgent` si elle est définie, sinon `undefined` (connexion directe). Contexte : l'IP du VPS de production (datacenter) est bloquée par Google ; Cloudflare WARP tourne en mode proxy SOCKS5 sur le VPS (`socks5://127.0.0.1:40000`). Seul le trafic WS Gemini est routé via ce proxy ; Supabase, DeepSeek et les clients restent en direct.
- Dépendance `socks-proxy-agent` (`package.json`).
- `GEMINI_PROXY_URL` ajoutée à `.env.example` (vide par défaut → dev local inchangé).
- Tests `geminiLive.test.ts` — 2 tests pour `resolveGeminiProxyAgent` (absente → `undefined` ; `socks5://…` → instance `SocksProxyAgent`).
### Changed
- Factory WS par défaut de `openGeminiLiveSession` (T2, `geminiLive.ts`) et `openGeminiLiveT1Session` (T1, `geminiLiveT1.ts`) — passe désormais `{ agent }` au constructeur `new WebSocket(url, options)` quand un proxy est résolu. La factory injectée par les tests (`clientFactory`) n'est pas affectée. Prompt système, interruption, flush, `runT1/T2LiveCorrection` et close codes inchangés.
### Notes
- Tests backend : 311/311 verts. `tsc --noEmit` OK.
- Activation en prod : définir `GEMINI_PROXY_URL=socks5://127.0.0.1:40000` côté VPS/Render (config d'env, hors code).
---
## [Unreleased] — 2026-06-30 — Sprint 7a-patch — T1 Live : suppression de la dépendance au questionnaire ## [Unreleased] — 2026-06-30 — Sprint 7a-patch — T1 Live : suppression de la dépendance au questionnaire
### Changed ### Changed

59
package-lock.json generated
View file

@ -13,6 +13,7 @@
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"hono": "^4.7.7", "hono": "^4.7.7",
"socks-proxy-agent": "^10.1.0",
"stripe": "^17.7.0", "stripe": "^17.7.0",
"ws": "^8.20.0" "ws": "^8.20.0"
}, },
@ -1275,6 +1276,15 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@ -1451,7 +1461,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -1849,6 +1858,15 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -2034,7 +2052,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -2366,6 +2383,44 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz",
"integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View file

@ -16,6 +16,7 @@
"@supabase/supabase-js": "^2.49.4", "@supabase/supabase-js": "^2.49.4",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"hono": "^4.7.7", "hono": "^4.7.7",
"socks-proxy-agent": "^10.1.0",
"stripe": "^17.7.0", "stripe": "^17.7.0",
"ws": "^8.20.0" "ws": "^8.20.0"
}, },

View file

@ -1,8 +1,10 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { SocksProxyAgent } from "socks-proxy-agent";
import { import {
openGeminiLiveSession, openGeminiLiveSession,
buildT2SystemPrompt, buildT2SystemPrompt,
resolveGeminiProxyAgent,
GEMINI_LIVE_MODEL, GEMINI_LIVE_MODEL,
type WebSocketLike, type WebSocketLike,
} from "../geminiLive"; } from "../geminiLive";
@ -44,6 +46,32 @@ describe("buildT2SystemPrompt", () => {
}); });
}); });
describe("resolveGeminiProxyAgent", () => {
let originalProxy: string | undefined;
beforeEach(() => {
originalProxy = process.env.GEMINI_PROXY_URL;
});
afterEach(() => {
if (originalProxy === undefined) {
delete process.env.GEMINI_PROXY_URL;
} else {
process.env.GEMINI_PROXY_URL = originalProxy;
}
});
it("retourne undefined quand GEMINI_PROXY_URL est absente (connexion directe)", () => {
delete process.env.GEMINI_PROXY_URL;
expect(resolveGeminiProxyAgent()).toBeUndefined();
});
it("retourne un SocksProxyAgent quand GEMINI_PROXY_URL est définie", () => {
process.env.GEMINI_PROXY_URL = "socks5://127.0.0.1:40000";
expect(resolveGeminiProxyAgent()).toBeInstanceOf(SocksProxyAgent);
});
});
describe("openGeminiLiveSession (raw WS)", () => { describe("openGeminiLiveSession (raw WS)", () => {
let originalKey: string | undefined; let originalKey: string | undefined;

View file

@ -15,6 +15,23 @@
*/ */
import { WebSocket as NodeWebSocket } from "ws"; import { WebSocket as NodeWebSocket } from "ws";
import { SocksProxyAgent } from "socks-proxy-agent";
/**
* Résout l'agent proxy SOCKS5 pour les connexions WebSocket vers Gemini Live.
*
* Contexte : l'IP du VPS de production (datacenter) est bloquée par Google.
* Cloudflare WARP tourne en mode proxy sur le VPS (socks5://127.0.0.1:40000) ;
* router UNIQUEMENT le trafic Gemini via ce proxy le débloque, sans affecter
* le reste (Supabase, DeepSeek, clients).
*
* `GEMINI_PROXY_URL` est optionnelle : absente connexion directe (dev local
* intact). Présente (ex: socks5://127.0.0.1:40000) → SocksProxyAgent.
*/
export function resolveGeminiProxyAgent(): SocksProxyAgent | undefined {
const url = process.env.GEMINI_PROXY_URL;
return url ? new SocksProxyAgent(url) : undefined;
}
export const GEMINI_LIVE_URL = export const GEMINI_LIVE_URL =
"wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent"; "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent";
@ -294,9 +311,14 @@ export function openGeminiLiveSession(
}); });
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
const proxyAgent = resolveGeminiProxyAgent();
const factory = const factory =
opts.clientFactory ?? opts.clientFactory ??
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike); ((u: string) =>
new NodeWebSocket(
u,
proxyAgent ? { agent: proxyAgent } : undefined,
) as unknown as WebSocketLike);
console.log("[T2] Gemini WS URL:", GEMINI_LIVE_URL + "?key=***"); console.log("[T2] Gemini WS URL:", GEMINI_LIVE_URL + "?key=***");
console.log("[T2] Gemini WS model:", GEMINI_LIVE_MODEL); console.log("[T2] Gemini WS model:", GEMINI_LIVE_MODEL);

View file

@ -30,6 +30,7 @@ import {
isEndSignal, isEndSignal,
parseAudioChunk, parseAudioChunk,
reconstructTranscript, reconstructTranscript,
resolveGeminiProxyAgent,
tryParseGeminiJson, tryParseGeminiJson,
type TranscriptEntry, type TranscriptEntry,
type WebSocketLike, type WebSocketLike,
@ -203,9 +204,14 @@ export function openGeminiLiveT1Session(
const systemPrompt = buildT1SystemPrompt(); const systemPrompt = buildT1SystemPrompt();
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`; const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
const proxyAgent = resolveGeminiProxyAgent();
const factory = const factory =
opts.clientFactory ?? opts.clientFactory ??
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike); ((u: string) =>
new NodeWebSocket(
u,
proxyAgent ? { agent: proxyAgent } : undefined,
) as unknown as WebSocketLike);
const geminiWs = factory(url); const geminiWs = factory(url);