Files
2026-07-13 13:30:30 +08:00

152 lines
4.6 KiB
Python

# Copyright 2025 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.
# pylint: disable=too-many-lines
# pylint: disable=import-error
import asyncio
import json
import logging
import os
from typing import Any, Dict
from google.genai import Client
from google.genai.live import AsyncSession
from google.genai.types import LiveConnectConfig
from quart import Quart, Response, Websocket, send_from_directory, websocket
logging.basicConfig(level=logging.INFO)
#
# Gemini API
#
PROJECT_ID: str = os.environ.get("PROJECT_ID", "")
LOCATION: str = os.environ.get("LOCATION", "us-central1")
GEMINI_API_KEY: str = os.environ.get("GEMINI_API_KEY", "")
QUART_DEBUG_MODE: bool = os.environ.get("QUART_DEBUG_MODE") == "True"
GEMINI_MODEL: str = "gemini-2.0-flash-live-preview-04-09"
# Gemini API Client: Use either one of the following APIs
gemini_client: Client = (
Client(vertexai=True, project=PROJECT_ID, location=LOCATION)
if not GEMINI_API_KEY
else Client(api_key=GEMINI_API_KEY, http_options={"api_version": "v1alpha"})
)
# Gemini API config
gemini_config = LiveConnectConfig(
response_modalities=["TEXT"],
)
#
# Quart
#
app: Quart = Quart(__name__)
@app.route("/")
async def index() -> Response:
"""
Serve index.html for the index access.
"""
return await send_from_directory("public", "index.html")
async def upstream_worker(
gemini_session: AsyncSession, client_websocket: Websocket
) -> None:
"""
Continuously read messages from the client WebSocket
and forward them to Gemini.
"""
while True:
message: str = await client_websocket.receive()
await gemini_session.send(input=message, end_of_turn=True)
logging.info(
"upstream_worker(): sent a message from client to Gemini: %s", message
)
async def downstream_worker(
gemini_session: AsyncSession, client_websocket: Websocket
) -> None:
"""
Continuously read streaming responses from Gemini
and send them directly to the client WebSocket.
"""
while True:
async for response in gemini_session.receive():
if not response:
continue
packet: Dict[str, Any] = {
"text": response.text if response.text else "",
"turn_complete": response.server_content.turn_complete,
}
await client_websocket.send(json.dumps(packet))
logging.info("downstream_worker(): sent response to client: %s", packet)
@app.websocket("/live")
async def live() -> None:
"""
WebSocket endpoint for live (streaming) connections to Gemini.
"""
# Connect to Gemini in "live" (streaming) mode
async with gemini_client.aio.live.connect(
model=GEMINI_MODEL, config=gemini_config
) as gemini_session:
upstream_task: asyncio.Task = asyncio.create_task(
upstream_worker(gemini_session, websocket)
)
downstream_task: asyncio.Task = asyncio.create_task(
downstream_worker(gemini_session, websocket)
)
logging.info("live(): connected to Gemini, started workers.")
try:
# Wait until either task finishes or raises an exception
done, pending = await asyncio.wait(
[downstream_task, upstream_task], return_when=asyncio.FIRST_EXCEPTION
)
# If one of them raised, re-raise that exception here
for task in pending:
task.cancel()
for task in done:
exc = task.exception()
if exc:
raise exc
# Handle cancelled errors
except asyncio.CancelledError:
logging.info("live(): client connection closed.")
finally:
# Cancel any leftover tasks
upstream_task.cancel()
downstream_task.cancel()
await asyncio.gather(downstream_task, upstream_task, return_exceptions=True)
# Close Gemini session
await gemini_session.close()
logging.info("live(): Gemini session closed.")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=QUART_DEBUG_MODE)