feat: WS /t2/live — proxy Gemini Live API — 124/124 tests
This commit is contained in:
parent
f08be960b0
commit
653fc3150e
8 changed files with 422 additions and 66 deletions
|
|
@ -1,13 +1,16 @@
|
|||
import 'dotenv/config'
|
||||
import { Hono } from 'hono'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { createNodeWebSocket } from '@hono/node-ws'
|
||||
import authRoutes from './routes/auth.js'
|
||||
import plansRoutes from './routes/plans.js'
|
||||
import simulationsRoutes from './routes/simulations.js'
|
||||
import correctionsRoutes from './routes/corrections.js'
|
||||
import stripeRoutes from './routes/stripe.js'
|
||||
import createT2LiveRoutes from './routes/t2live.js'
|
||||
|
||||
const app = new Hono()
|
||||
const { upgradeWebSocket, injectWebSocket } = createNodeWebSocket({ app })
|
||||
|
||||
app.get('/', (c) => {
|
||||
return c.json({ message: 'Expria API — OK' }, 200)
|
||||
|
|
@ -18,9 +21,12 @@ app.route('/plans', plansRoutes)
|
|||
app.route('/simulations', simulationsRoutes)
|
||||
app.route('/corrections', correctionsRoutes)
|
||||
app.route('/stripe', stripeRoutes)
|
||||
app.route('/t2', createT2LiveRoutes(upgradeWebSocket))
|
||||
|
||||
const port = Number(process.env.PORT) || 3000
|
||||
|
||||
serve({ fetch: app.fetch, port }, () => {
|
||||
const server = serve({ fetch: app.fetch, port }, () => {
|
||||
console.log(`Expria API listening on port ${port}`)
|
||||
})
|
||||
|
||||
injectWebSocket(server)
|
||||
|
|
|
|||
134
src/lib/__tests__/geminiLive.test.ts
Normal file
134
src/lib/__tests__/geminiLive.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import {
|
||||
openGeminiLiveSession,
|
||||
T2_SYSTEM_PROMPT,
|
||||
type WebSocketLike,
|
||||
} from '../geminiLive'
|
||||
|
||||
class FakeWs extends EventEmitter implements WebSocketLike {
|
||||
public sent: unknown[] = []
|
||||
public closed = false
|
||||
public closeCode?: number
|
||||
public closeReason?: string
|
||||
|
||||
send(data: unknown): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.closeCode = code
|
||||
this.closeReason = reason
|
||||
}
|
||||
}
|
||||
|
||||
describe('openGeminiLiveSession', () => {
|
||||
let originalKey: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalKey = process.env.GEMINI_API_KEY
|
||||
process.env.GEMINI_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalKey === undefined) {
|
||||
delete process.env.GEMINI_API_KEY
|
||||
} else {
|
||||
process.env.GEMINI_API_KEY = originalKey
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("envoie le setup frame avec T2_SYSTEM_PROMPT a l'open Gemini", () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
expect(gemini.sent).toHaveLength(1)
|
||||
const setup = JSON.parse(gemini.sent[0] as string)
|
||||
expect(setup.setup.model).toMatch(/gemini/)
|
||||
expect(setup.setup.system_instruction.parts[0].text).toBe(T2_SYSTEM_PROMPT)
|
||||
expect(setup.setup.generation_config.response_modalities).toContain('AUDIO')
|
||||
})
|
||||
|
||||
it('forwarde un message client (Buffer audio) vers Gemini', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
const audioChunk = Buffer.from([0x01, 0x02, 0x03, 0x04])
|
||||
client.emit('message', audioChunk)
|
||||
|
||||
// [0] = setup frame, [1] = audio forwarde
|
||||
expect(gemini.sent).toHaveLength(2)
|
||||
expect(gemini.sent[1]).toBe(audioChunk)
|
||||
})
|
||||
|
||||
it('forwarde un message Gemini vers le client', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
const examinerAudio = Buffer.from([0x10, 0x20])
|
||||
gemini.emit('message', examinerAudio)
|
||||
|
||||
expect(client.sent).toHaveLength(1)
|
||||
expect(client.sent[0]).toBe(examinerAudio)
|
||||
})
|
||||
|
||||
it('fermeture client → ferme Gemini avec code 1000', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
client.emit('close')
|
||||
|
||||
expect(gemini.closed).toBe(true)
|
||||
expect(gemini.closeCode).toBe(1000)
|
||||
})
|
||||
|
||||
it('fermeture Gemini → ferme client avec code 1000', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
gemini.emit('close')
|
||||
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1000)
|
||||
})
|
||||
|
||||
it('erreur Gemini → ferme client avec code 1011 GEMINI_ERROR', () => {
|
||||
const client = new FakeWs()
|
||||
const gemini = new FakeWs()
|
||||
openGeminiLiveSession(client, { geminiFactory: () => gemini })
|
||||
gemini.emit('open')
|
||||
|
||||
gemini.emit('error', new Error('boom'))
|
||||
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1011)
|
||||
expect(client.closeReason).toBe('GEMINI_ERROR')
|
||||
})
|
||||
|
||||
it("absence de GEMINI_API_KEY → close client 1011 CONFIG_ERROR sans appel a la factory", () => {
|
||||
delete process.env.GEMINI_API_KEY
|
||||
const client = new FakeWs()
|
||||
const factory = vi.fn(() => new FakeWs())
|
||||
|
||||
openGeminiLiveSession(client, { geminiFactory: factory })
|
||||
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
expect(client.closed).toBe(true)
|
||||
expect(client.closeCode).toBe(1011)
|
||||
expect(client.closeReason).toBe('CONFIG_ERROR')
|
||||
})
|
||||
})
|
||||
140
src/lib/geminiLive.ts
Normal file
140
src/lib/geminiLive.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { WebSocket as NodeWebSocket } from 'ws'
|
||||
|
||||
export const T2_SYSTEM_PROMPT = `Tu es un examinateur du TCF Canada pour l'épreuve d'Expression Orale, Tâche 2 (dialogue interactif).
|
||||
|
||||
RÔLE : Tu incarnes agent immobilier.
|
||||
CONTEXTE : Le candidat cherche un appartement à louer.
|
||||
|
||||
RÈGLES ABSOLUES :
|
||||
|
||||
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1.
|
||||
2. Tu NE corriges JAMAIS les erreurs du candidat.
|
||||
3. Tu attends que le candidat finisse sa question avant de répondre.
|
||||
4. Tes réponses sont courtes (15 à 25 mots maximum).
|
||||
5. Ne donne pas toutes les informations d'un coup. Force le candidat à poser des questions précises.
|
||||
6. Si le candidat est vague, réponds de façon évasive pour le pousser à reformuler.
|
||||
7. Si le candidat reste silencieux, attends. Ne pose JAMAIS de question spontanée après tes réponses. C'est au candidat d'agir.
|
||||
8. En dernier recours uniquement (silence prolongé) : "Vous avez d'autres questions ?"
|
||||
9. Ne prends jamais d'initiatives : réponds uniquement aux questions posées.
|
||||
10. Tu peux être légèrement pressé ou hésitant pour rendre l'échange réaliste.
|
||||
11. JAMAIS de listes ni de structure numérotée dans tes réponses.
|
||||
12. Ne mentionne jamais que tu es une IA.
|
||||
|
||||
Commence l'exercice en te présentant brièvement dans ton rôle (1 phrase courte),
|
||||
puis attends que le candidat prenne l'initiative.`
|
||||
|
||||
export const GEMINI_LIVE_URL =
|
||||
'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
|
||||
|
||||
export const GEMINI_LIVE_MODEL = 'models/gemini-2.0-flash-exp'
|
||||
|
||||
/**
|
||||
* Subset minimal d'une WebSocket — compatible avec :
|
||||
* - le wrapper exposé par @hono/node-ws (côté client navigateur)
|
||||
* - la WebSocket de `ws` (côté Gemini)
|
||||
* - les fakes basés sur EventEmitter dans les tests
|
||||
*/
|
||||
export interface WebSocketLike {
|
||||
send(data: unknown): void
|
||||
close(code?: number, reason?: string): void
|
||||
on(event: 'message', listener: (data: unknown) => void): void
|
||||
on(event: 'close', listener: () => void): void
|
||||
on(event: 'error', listener: (err: unknown) => void): void
|
||||
on(event: 'open', listener: () => void): void
|
||||
}
|
||||
|
||||
export interface OpenGeminiLiveSessionOptions {
|
||||
/** Injection pour les tests — fabrique de WebSocket vers Gemini. */
|
||||
geminiFactory?: (url: string) => WebSocketLike
|
||||
/** Surcharge la clé API (par défaut : process.env.GEMINI_API_KEY). */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
function buildSetupFrame(): string {
|
||||
return JSON.stringify({
|
||||
setup: {
|
||||
model: GEMINI_LIVE_MODEL,
|
||||
system_instruction: {
|
||||
parts: [{ text: T2_SYSTEM_PROMPT }],
|
||||
},
|
||||
generation_config: {
|
||||
response_modalities: ['AUDIO'],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre une session Gemini Live et proxifie les messages
|
||||
* dans les deux sens entre le client (navigateur) et Gemini.
|
||||
*
|
||||
* - À l'open Gemini : envoie le setup frame (modèle + system_instruction).
|
||||
* - Forward transparent des frames audio dans les deux directions.
|
||||
* - Fermeture coordonnée : close d'un côté → close de l'autre.
|
||||
* - Erreur Gemini → close client avec code 1011.
|
||||
* - Si GEMINI_API_KEY est absente : close client immédiat avec 1011.
|
||||
*/
|
||||
export function openGeminiLiveSession(
|
||||
clientWs: WebSocketLike,
|
||||
opts: OpenGeminiLiveSessionOptions = {}
|
||||
): void {
|
||||
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
clientWs.close(1011, 'CONFIG_ERROR')
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`
|
||||
const factory =
|
||||
opts.geminiFactory ??
|
||||
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike)
|
||||
|
||||
const geminiWs = factory(url)
|
||||
|
||||
let closed = false
|
||||
const closeBoth = (code = 1000, reason = '') => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
try {
|
||||
clientWs.close(code, reason)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
geminiWs.close(code, reason)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
geminiWs.on('open', () => {
|
||||
try {
|
||||
geminiWs.send(buildSetupFrame())
|
||||
} catch {
|
||||
closeBoth(1011, 'SETUP_FAILED')
|
||||
}
|
||||
})
|
||||
|
||||
geminiWs.on('message', (data) => {
|
||||
try {
|
||||
clientWs.send(data)
|
||||
} catch {
|
||||
closeBoth(1011, 'CLIENT_SEND_FAILED')
|
||||
}
|
||||
})
|
||||
|
||||
clientWs.on('message', (data) => {
|
||||
try {
|
||||
geminiWs.send(data)
|
||||
} catch {
|
||||
closeBoth(1011, 'GEMINI_SEND_FAILED')
|
||||
}
|
||||
})
|
||||
|
||||
geminiWs.on('close', () => closeBoth(1000))
|
||||
clientWs.on('close', () => closeBoth(1000))
|
||||
|
||||
geminiWs.on('error', () => closeBoth(1011, 'GEMINI_ERROR'))
|
||||
clientWs.on('error', () => closeBoth(1011, 'CLIENT_ERROR'))
|
||||
}
|
||||
100
src/routes/t2live.ts
Normal file
100
src/routes/t2live.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Hono } from 'hono'
|
||||
import type { UpgradeWebSocket } from 'hono/ws'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { supabase } from '../lib/supabase.js'
|
||||
import { checkFeatureAccess } from '../lib/access.js'
|
||||
import type { Plan } from '../lib/access.js'
|
||||
import {
|
||||
openGeminiLiveSession,
|
||||
type WebSocketLike,
|
||||
} from '../lib/geminiLive.js'
|
||||
|
||||
/**
|
||||
* Crée le router pour `WS /t2/live`.
|
||||
* - Auth : JWT Supabase passé en query param `?token=<jwt>`
|
||||
* - Permission : plan Premium (`oral_t2_live`) via checkFeatureAccess
|
||||
* - Refus auth → close 4001, refus plan → close 4003
|
||||
* - OK → openGeminiLiveSession (proxy vers Gemini Live)
|
||||
*/
|
||||
export default function createT2LiveRoutes(
|
||||
upgradeWebSocket: UpgradeWebSocket
|
||||
) {
|
||||
const app = new Hono()
|
||||
|
||||
app.get(
|
||||
'/live',
|
||||
upgradeWebSocket(async (c) => {
|
||||
const token = c.req.query('token')
|
||||
let denyCode: number | null = null
|
||||
let denyReason = ''
|
||||
|
||||
if (!token) {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
} else {
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser(token)
|
||||
|
||||
if (authError || !user) {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
} else {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('plan')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (profileError || !profile) {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
} else if (
|
||||
!checkFeatureAccess(profile.plan as Plan, 'oral_t2_live')
|
||||
) {
|
||||
denyCode = 4003
|
||||
denyReason = 'PLAN_INSUFFICIENT'
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
denyCode = 4001
|
||||
denyReason = 'AUTH_REQUIRED'
|
||||
}
|
||||
}
|
||||
|
||||
// Adapter EventEmitter → WebSocketLike pour réutiliser openGeminiLiveSession
|
||||
const adapter = new EventEmitter() as EventEmitter & WebSocketLike
|
||||
adapter.send = () => {}
|
||||
adapter.close = () => {}
|
||||
|
||||
return {
|
||||
onOpen(_evt, ws) {
|
||||
adapter.send = (data: unknown) =>
|
||||
ws.send(data as Parameters<typeof ws.send>[0])
|
||||
adapter.close = (code?: number, reason?: string) =>
|
||||
ws.close(code, reason)
|
||||
|
||||
if (denyCode !== null) {
|
||||
ws.close(denyCode, denyReason)
|
||||
return
|
||||
}
|
||||
|
||||
openGeminiLiveSession(adapter)
|
||||
},
|
||||
onMessage(evt) {
|
||||
adapter.emit('message', evt.data)
|
||||
},
|
||||
onClose() {
|
||||
adapter.emit('close')
|
||||
},
|
||||
onError() {
|
||||
adapter.emit('error', new Error('CLIENT_ERROR'))
|
||||
},
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue