Spaces:
Paused
:sparkles: Update the /v1/responses endpoint (#75)
Browse files* Remove the unused auto-refresh functionality and related imports.
They are no longer needed since the underlying library issue has been resolved.
* Enhance error handling in client initialization and message sending
* Refactor link handling to extract file paths and simplify Google search links
* Fix regex pattern for Google search link matching
* Fix regex patterns for Markdown escaping, code fence and Google search link matching
* Increase timeout value in configuration files from 60 to 120 seconds to better handle heavy tasks
* Fix Image generation
* Refactor tool handling to support standard and image generation tools separately
* Fix: use "ascii" decoding for base64-encoded image data consistency
* Fix: replace `running` with `_running` for internal client status checks
* Refactor: replace direct `_running` access with `running()` method in client status checks
* Extend models with new fields for annotations, reasoning, audio, log probabilities, and token details; adjust response handling accordingly.
* Extend models with new fields (annotations, error), add `normalize_output_text` validator, rename `created` to `created_at`, and update response handling accordingly.
* Extend response models to support tool choices, image output, and improved streaming of response items. Refactor image generation handling for consistency and add compatibility with output content.
* Set default `text` value to an empty string for `ResponseOutputContent` and ensure consistent initialization in image output handling.
* feat: Add /images endpoint with dedicated router and improved image management
Add dedicated router for /images endpoint and refactor image handling logic for better modularity. Enhance temporary image management with secure naming, token verification, and cleanup functionality.
* feat: Add token-based verification for image access
* Refactor: rename image store directory to `ai_generated_images` for clarity
* fix: Update create_response to use FastAPI Request object for base_url and refactor variable handling
* fix: Correct attribute access in request_data handling within `chat.py` for tools, tool_choice, and streaming settings
* fix: Save generated images to persistent storage
* fix: Remove unused `output_image` type from `ResponseOutputContent` and update response handling for consistency
* fix: Update image URL generation in chat response to use Markdown format for compatibility
- app/main.py +8 -1
- app/models/models.py +26 -5
- app/server/chat.py +108 -59
- app/server/images.py +18 -0
- app/server/middleware.py +59 -0
- app/services/client.py +3 -0
- app/services/pool.py +5 -5
|
@@ -6,7 +6,12 @@ from loguru import logger
|
|
| 6 |
|
| 7 |
from .server.chat import router as chat_router
|
| 8 |
from .server.health import router as health_router
|
| 9 |
-
from .server.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from .services import GeminiClientPool, LMDBConversationStore
|
| 11 |
|
| 12 |
RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours
|
|
@@ -28,6 +33,7 @@ async def _run_retention_cleanup(stop_event: asyncio.Event) -> None:
|
|
| 28 |
while not stop_event.is_set():
|
| 29 |
try:
|
| 30 |
store.cleanup_expired()
|
|
|
|
| 31 |
except Exception:
|
| 32 |
logger.exception("LMDB retention cleanup task failed.")
|
| 33 |
|
|
@@ -93,5 +99,6 @@ def create_app() -> FastAPI:
|
|
| 93 |
|
| 94 |
app.include_router(health_router, tags=["Health"])
|
| 95 |
app.include_router(chat_router, tags=["Chat"])
|
|
|
|
| 96 |
|
| 97 |
return app
|
|
|
|
| 6 |
|
| 7 |
from .server.chat import router as chat_router
|
| 8 |
from .server.health import router as health_router
|
| 9 |
+
from .server.images import router as images_router
|
| 10 |
+
from .server.middleware import (
|
| 11 |
+
add_cors_middleware,
|
| 12 |
+
add_exception_handler,
|
| 13 |
+
cleanup_expired_images,
|
| 14 |
+
)
|
| 15 |
from .services import GeminiClientPool, LMDBConversationStore
|
| 16 |
|
| 17 |
RETENTION_CLEANUP_INTERVAL_SECONDS = 6 * 60 * 60 # 6 hours
|
|
|
|
| 33 |
while not stop_event.is_set():
|
| 34 |
try:
|
| 35 |
store.cleanup_expired()
|
| 36 |
+
cleanup_expired_images(store.retention_days)
|
| 37 |
except Exception:
|
| 38 |
logger.exception("LMDB retention cleanup task failed.")
|
| 39 |
|
|
|
|
| 99 |
|
| 100 |
app.include_router(health_router, tags=["Health"])
|
| 101 |
app.include_router(chat_router, tags=["Chat"])
|
| 102 |
+
app.include_router(images_router, tags=["Images"])
|
| 103 |
|
| 104 |
return app
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import Any, Dict, List, Literal, Optional, Union
|
| 5 |
|
| 6 |
-
from pydantic import BaseModel, Field
|
| 7 |
|
| 8 |
|
| 9 |
class ContentItem(BaseModel):
|
|
@@ -12,7 +12,9 @@ class ContentItem(BaseModel):
|
|
| 12 |
type: Literal["text", "image_url", "file", "input_audio"]
|
| 13 |
text: Optional[str] = None
|
| 14 |
image_url: Optional[Dict[str, str]] = None
|
|
|
|
| 15 |
file: Optional[Dict[str, str]] = None
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class Message(BaseModel):
|
|
@@ -22,6 +24,10 @@ class Message(BaseModel):
|
|
| 22 |
content: Union[str, List[ContentItem], None] = None
|
| 23 |
name: Optional[str] = None
|
| 24 |
tool_calls: Optional[List["ToolCall"]] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class Choice(BaseModel):
|
|
@@ -30,6 +36,7 @@ class Choice(BaseModel):
|
|
| 30 |
index: int
|
| 31 |
message: Message
|
| 32 |
finish_reason: str
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
class FunctionCall(BaseModel):
|
|
@@ -81,6 +88,8 @@ class Usage(BaseModel):
|
|
| 81 |
prompt_tokens: int
|
| 82 |
completion_tokens: int
|
| 83 |
total_tokens: int
|
|
|
|
|
|
|
| 84 |
|
| 85 |
|
| 86 |
class ModelData(BaseModel):
|
|
@@ -161,6 +170,15 @@ class ResponseInputContent(BaseModel):
|
|
| 161 |
file_url: Optional[str] = None
|
| 162 |
file_data: Optional[str] = None
|
| 163 |
filename: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
|
| 166 |
class ResponseInputItem(BaseModel):
|
|
@@ -216,7 +234,8 @@ class ResponseOutputContent(BaseModel):
|
|
| 216 |
"""Content item for Responses API output."""
|
| 217 |
|
| 218 |
type: Literal["output_text"]
|
| 219 |
-
text: Optional[str] =
|
|
|
|
| 220 |
|
| 221 |
|
| 222 |
class ResponseOutputMessage(BaseModel):
|
|
@@ -254,20 +273,22 @@ class ResponseCreateResponse(BaseModel):
|
|
| 254 |
|
| 255 |
id: str
|
| 256 |
object: Literal["response"] = "response"
|
| 257 |
-
|
| 258 |
model: str
|
| 259 |
output: List[Union[ResponseOutputMessage, ResponseImageGenerationCall, ResponseToolCall]]
|
| 260 |
-
output_text: Optional[str] = None
|
| 261 |
status: Literal[
|
| 262 |
"in_progress",
|
| 263 |
"completed",
|
| 264 |
"failed",
|
| 265 |
"incomplete",
|
|
|
|
| 266 |
"requires_action",
|
| 267 |
] = "completed"
|
|
|
|
|
|
|
| 268 |
usage: ResponseUsage
|
|
|
|
| 269 |
metadata: Optional[Dict[str, Any]] = None
|
| 270 |
-
system_fingerprint: Optional[str] = None
|
| 271 |
input: Optional[Union[str, List[ResponseInputItem]]] = None
|
| 272 |
|
| 273 |
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import Any, Dict, List, Literal, Optional, Union
|
| 5 |
|
| 6 |
+
from pydantic import BaseModel, Field, model_validator
|
| 7 |
|
| 8 |
|
| 9 |
class ContentItem(BaseModel):
|
|
|
|
| 12 |
type: Literal["text", "image_url", "file", "input_audio"]
|
| 13 |
text: Optional[str] = None
|
| 14 |
image_url: Optional[Dict[str, str]] = None
|
| 15 |
+
input_audio: Optional[Dict[str, Any]] = None
|
| 16 |
file: Optional[Dict[str, str]] = None
|
| 17 |
+
annotations: List[Dict[str, Any]] = Field(default_factory=list)
|
| 18 |
|
| 19 |
|
| 20 |
class Message(BaseModel):
|
|
|
|
| 24 |
content: Union[str, List[ContentItem], None] = None
|
| 25 |
name: Optional[str] = None
|
| 26 |
tool_calls: Optional[List["ToolCall"]] = None
|
| 27 |
+
refusal: Optional[str] = None
|
| 28 |
+
reasoning_content: Optional[str] = None
|
| 29 |
+
audio: Optional[Dict[str, Any]] = None
|
| 30 |
+
annotations: List[Dict[str, Any]] = Field(default_factory=list)
|
| 31 |
|
| 32 |
|
| 33 |
class Choice(BaseModel):
|
|
|
|
| 36 |
index: int
|
| 37 |
message: Message
|
| 38 |
finish_reason: str
|
| 39 |
+
logprobs: Optional[Dict[str, Any]] = None
|
| 40 |
|
| 41 |
|
| 42 |
class FunctionCall(BaseModel):
|
|
|
|
| 88 |
prompt_tokens: int
|
| 89 |
completion_tokens: int
|
| 90 |
total_tokens: int
|
| 91 |
+
prompt_tokens_details: Optional[Dict[str, int]] = None
|
| 92 |
+
completion_tokens_details: Optional[Dict[str, int]] = None
|
| 93 |
|
| 94 |
|
| 95 |
class ModelData(BaseModel):
|
|
|
|
| 170 |
file_url: Optional[str] = None
|
| 171 |
file_data: Optional[str] = None
|
| 172 |
filename: Optional[str] = None
|
| 173 |
+
annotations: List[Dict[str, Any]] = Field(default_factory=list)
|
| 174 |
+
|
| 175 |
+
@model_validator(mode="before")
|
| 176 |
+
@classmethod
|
| 177 |
+
def normalize_output_text(cls, data: Any) -> Any:
|
| 178 |
+
"""Allow output_text (from previous turns) to be treated as input_text."""
|
| 179 |
+
if isinstance(data, dict) and data.get("type") == "output_text":
|
| 180 |
+
data["type"] = "input_text"
|
| 181 |
+
return data
|
| 182 |
|
| 183 |
|
| 184 |
class ResponseInputItem(BaseModel):
|
|
|
|
| 234 |
"""Content item for Responses API output."""
|
| 235 |
|
| 236 |
type: Literal["output_text"]
|
| 237 |
+
text: Optional[str] = ""
|
| 238 |
+
annotations: List[Dict[str, Any]] = Field(default_factory=list)
|
| 239 |
|
| 240 |
|
| 241 |
class ResponseOutputMessage(BaseModel):
|
|
|
|
| 273 |
|
| 274 |
id: str
|
| 275 |
object: Literal["response"] = "response"
|
| 276 |
+
created_at: int
|
| 277 |
model: str
|
| 278 |
output: List[Union[ResponseOutputMessage, ResponseImageGenerationCall, ResponseToolCall]]
|
|
|
|
| 279 |
status: Literal[
|
| 280 |
"in_progress",
|
| 281 |
"completed",
|
| 282 |
"failed",
|
| 283 |
"incomplete",
|
| 284 |
+
"cancelled",
|
| 285 |
"requires_action",
|
| 286 |
] = "completed"
|
| 287 |
+
tool_choice: Optional[Union[str, ResponseToolChoice]] = None
|
| 288 |
+
tools: Optional[List[Union[Tool, ResponseImageTool]]] = None
|
| 289 |
usage: ResponseUsage
|
| 290 |
+
error: Optional[Dict[str, Any]] = None
|
| 291 |
metadata: Optional[Dict[str, Any]] = None
|
|
|
|
| 292 |
input: Optional[Union[str, List[ResponseInputItem]]] = None
|
| 293 |
|
| 294 |
|
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
| 9 |
from typing import Any, Iterator
|
| 10 |
|
| 11 |
import orjson
|
| 12 |
-
from fastapi import APIRouter, Depends, HTTPException, status
|
| 13 |
from fastapi.responses import StreamingResponse
|
| 14 |
from gemini_webapi.client import ChatSession
|
| 15 |
from gemini_webapi.constants import Model
|
|
@@ -44,7 +44,7 @@ from ..services import GeminiClientPool, GeminiClientWrapper, LMDBConversationSt
|
|
| 44 |
from ..services.client import CODE_BLOCK_HINT, XML_WRAP_HINT
|
| 45 |
from ..utils import g_config
|
| 46 |
from ..utils.helper import estimate_tokens
|
| 47 |
-
from .middleware import get_temp_dir, verify_api_key
|
| 48 |
|
| 49 |
# Maximum characters Gemini Web can accept in a single request (configurable)
|
| 50 |
MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9)
|
|
@@ -588,6 +588,7 @@ async def create_chat_completion(
|
|
| 588 |
request: ChatCompletionRequest,
|
| 589 |
api_key: str = Depends(verify_api_key),
|
| 590 |
tmp_dir: Path = Depends(get_temp_dir),
|
|
|
|
| 591 |
):
|
| 592 |
pool = GeminiClientPool()
|
| 593 |
db = LMDBConversationStore()
|
|
@@ -772,18 +773,15 @@ async def create_chat_completion(
|
|
| 772 |
|
| 773 |
@router.post("/v1/responses")
|
| 774 |
async def create_response(
|
| 775 |
-
|
|
|
|
| 776 |
api_key: str = Depends(verify_api_key),
|
| 777 |
tmp_dir: Path = Depends(get_temp_dir),
|
|
|
|
| 778 |
):
|
| 779 |
-
base_messages, normalized_input = _response_items_to_messages(
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
status_code=status.HTTP_400_BAD_REQUEST, detail="No message input provided."
|
| 783 |
-
)
|
| 784 |
-
|
| 785 |
-
structured_requirement = _build_structured_requirement(request.response_format)
|
| 786 |
-
if structured_requirement and request.stream:
|
| 787 |
logger.debug(
|
| 788 |
"Structured response requested with streaming enabled; streaming not supported for Responses."
|
| 789 |
)
|
|
@@ -799,8 +797,8 @@ async def create_response(
|
|
| 799 |
standard_tools: list[Tool] = []
|
| 800 |
image_tools: list[ResponseImageTool] = []
|
| 801 |
|
| 802 |
-
if
|
| 803 |
-
for t in
|
| 804 |
if isinstance(t, Tool):
|
| 805 |
standard_tools.append(t)
|
| 806 |
elif isinstance(t, ResponseImageTool):
|
|
@@ -815,13 +813,15 @@ async def create_response(
|
|
| 815 |
|
| 816 |
image_instruction = _build_image_generation_instruction(
|
| 817 |
image_tools,
|
| 818 |
-
|
|
|
|
|
|
|
| 819 |
)
|
| 820 |
if image_instruction:
|
| 821 |
extra_instructions.append(image_instruction)
|
| 822 |
logger.debug("Image generation support enabled for /v1/responses request.")
|
| 823 |
|
| 824 |
-
preface_messages = _instructions_to_messages(
|
| 825 |
conversation_messages = base_messages
|
| 826 |
if preface_messages:
|
| 827 |
conversation_messages = [*preface_messages, *base_messages]
|
|
@@ -832,10 +832,10 @@ async def create_response(
|
|
| 832 |
# Pass standard tools to the prompt builder
|
| 833 |
# Determine tool_choice for standard tools (ignore image_generation choice here as it is handled via instruction)
|
| 834 |
model_tool_choice = None
|
| 835 |
-
if isinstance(
|
| 836 |
-
model_tool_choice =
|
| 837 |
-
elif isinstance(
|
| 838 |
-
model_tool_choice =
|
| 839 |
# If tool_choice is ResponseToolChoice (image_generation), we don't pass it as a function tool choice.
|
| 840 |
|
| 841 |
messages = _prepare_messages_for_model(
|
|
@@ -849,7 +849,7 @@ async def create_response(
|
|
| 849 |
db = LMDBConversationStore()
|
| 850 |
|
| 851 |
try:
|
| 852 |
-
model = Model.from_name(
|
| 853 |
except ValueError as exc:
|
| 854 |
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
| 855 |
|
|
@@ -969,7 +969,7 @@ async def create_response(
|
|
| 969 |
)
|
| 970 |
|
| 971 |
expects_image = (
|
| 972 |
-
|
| 973 |
)
|
| 974 |
images = model_output.images or []
|
| 975 |
logger.debug(
|
|
@@ -984,7 +984,7 @@ async def create_response(
|
|
| 984 |
summary = f"{summary[:197]}..."
|
| 985 |
logger.warning(
|
| 986 |
"Image generation requested but Gemini produced no images. "
|
| 987 |
-
f"client_id={client_id}, forced_tool_choice={
|
| 988 |
f"instruction_applied={bool(image_instruction)}, assistant_preview='{summary}'"
|
| 989 |
)
|
| 990 |
detail = "LLM returned no images for the requested image_generation tool."
|
|
@@ -992,22 +992,35 @@ async def create_response(
|
|
| 992 |
detail = f"{detail} Assistant response: {summary}"
|
| 993 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail)
|
| 994 |
|
|
|
|
| 995 |
image_call_items: list[ResponseImageGenerationCall] = []
|
| 996 |
for image in images:
|
| 997 |
try:
|
| 998 |
-
image_base64, width, height = await _image_to_base64(image,
|
| 999 |
except Exception as exc:
|
| 1000 |
logger.warning(f"Failed to download generated image: {exc}")
|
| 1001 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1002 |
image_call_items.append(
|
| 1003 |
ResponseImageGenerationCall(
|
| 1004 |
id=f"img_{uuid.uuid4().hex}",
|
| 1005 |
status="completed",
|
| 1006 |
result=image_base64,
|
| 1007 |
-
output_format=
|
| 1008 |
size=f"{width}x{height}" if width and height else None,
|
| 1009 |
)
|
| 1010 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
|
| 1012 |
tool_call_items: list[ResponseToolCall] = []
|
| 1013 |
if detected_tool_calls:
|
|
@@ -1020,12 +1033,13 @@ async def create_response(
|
|
| 1020 |
for call in detected_tool_calls
|
| 1021 |
]
|
| 1022 |
|
| 1023 |
-
response_contents: list[ResponseOutputContent] = []
|
| 1024 |
if assistant_text:
|
| 1025 |
-
response_contents.append(
|
|
|
|
|
|
|
| 1026 |
|
| 1027 |
if not response_contents:
|
| 1028 |
-
response_contents.append(ResponseOutputContent(type="output_text", text=""))
|
| 1029 |
|
| 1030 |
created_time = int(datetime.now(tz=timezone.utc).timestamp())
|
| 1031 |
response_id = f"resp_{uuid.uuid4().hex}"
|
|
@@ -1047,8 +1061,8 @@ async def create_response(
|
|
| 1047 |
|
| 1048 |
response_payload = ResponseCreateResponse(
|
| 1049 |
id=response_id,
|
| 1050 |
-
|
| 1051 |
-
model=
|
| 1052 |
output=[
|
| 1053 |
ResponseOutputMessage(
|
| 1054 |
id=message_id,
|
|
@@ -1059,11 +1073,12 @@ async def create_response(
|
|
| 1059 |
*tool_call_items,
|
| 1060 |
*image_call_items,
|
| 1061 |
],
|
| 1062 |
-
output_text=assistant_text or None,
|
| 1063 |
status="completed",
|
| 1064 |
usage=usage,
|
| 1065 |
input=normalized_input or None,
|
| 1066 |
-
metadata=
|
|
|
|
|
|
|
| 1067 |
)
|
| 1068 |
|
| 1069 |
try:
|
|
@@ -1084,7 +1099,7 @@ async def create_response(
|
|
| 1084 |
except Exception as exc:
|
| 1085 |
logger.warning(f"Failed to save Responses conversation to LMDB: {exc}")
|
| 1086 |
|
| 1087 |
-
if
|
| 1088 |
logger.debug(
|
| 1089 |
f"Streaming Responses API payload (response_id={response_payload.id}, text_chunks={bool(assistant_text)})."
|
| 1090 |
)
|
|
@@ -1333,7 +1348,7 @@ def _create_responses_streaming_response(
|
|
| 1333 |
|
| 1334 |
response_dict = response_payload.model_dump(mode="json")
|
| 1335 |
response_id = response_payload.id
|
| 1336 |
-
created_time = response_payload.
|
| 1337 |
model = response_payload.model
|
| 1338 |
|
| 1339 |
logger.debug(
|
|
@@ -1343,14 +1358,14 @@ def _create_responses_streaming_response(
|
|
| 1343 |
base_event = {
|
| 1344 |
"id": response_id,
|
| 1345 |
"object": "response",
|
| 1346 |
-
"
|
| 1347 |
"model": model,
|
| 1348 |
}
|
| 1349 |
|
| 1350 |
created_snapshot: dict[str, Any] = {
|
| 1351 |
"id": response_id,
|
| 1352 |
"object": "response",
|
| 1353 |
-
"
|
| 1354 |
"model": model,
|
| 1355 |
"status": "in_progress",
|
| 1356 |
}
|
|
@@ -1358,6 +1373,10 @@ def _create_responses_streaming_response(
|
|
| 1358 |
created_snapshot["metadata"] = response_dict["metadata"]
|
| 1359 |
if response_dict.get("input") is not None:
|
| 1360 |
created_snapshot["input"] = response_dict["input"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1361 |
|
| 1362 |
async def generate_stream():
|
| 1363 |
# Emit creation event
|
|
@@ -1368,30 +1387,53 @@ def _create_responses_streaming_response(
|
|
| 1368 |
}
|
| 1369 |
yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n"
|
| 1370 |
|
| 1371 |
-
# Stream
|
| 1372 |
-
|
| 1373 |
-
|
| 1374 |
-
|
| 1375 |
-
|
| 1376 |
-
"type": "response.output_text.delta",
|
| 1377 |
-
"output_index": 0,
|
| 1378 |
-
"delta": chunk,
|
| 1379 |
-
}
|
| 1380 |
-
yield f"data: {orjson.dumps(delta_event).decode('utf-8')}\n\n"
|
| 1381 |
-
|
| 1382 |
-
done_event = {
|
| 1383 |
**base_event,
|
| 1384 |
-
"type": "response.
|
| 1385 |
-
"output_index":
|
|
|
|
| 1386 |
}
|
| 1387 |
-
yield f"data: {orjson.dumps(
|
| 1388 |
-
|
| 1389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1390 |
**base_event,
|
| 1391 |
-
"type": "response.
|
| 1392 |
-
"output_index":
|
|
|
|
| 1393 |
}
|
| 1394 |
-
yield f"data: {orjson.dumps(
|
| 1395 |
|
| 1396 |
# Emit completed event with full payload
|
| 1397 |
completed_event = {
|
|
@@ -1512,8 +1554,8 @@ def _extract_image_dimensions(data: bytes) -> tuple[int | None, int | None]:
|
|
| 1512 |
return None, None
|
| 1513 |
|
| 1514 |
|
| 1515 |
-
async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None]:
|
| 1516 |
-
"""Persist an image provided by gemini_webapi and return base64 plus dimensions."""
|
| 1517 |
if isinstance(image, GeneratedImage):
|
| 1518 |
saved_path = await image.save(path=str(temp_dir), full_size=True)
|
| 1519 |
else:
|
|
@@ -1522,6 +1564,13 @@ async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | Non
|
|
| 1522 |
if not saved_path:
|
| 1523 |
raise ValueError("Failed to save generated image")
|
| 1524 |
|
| 1525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1526 |
width, height = _extract_image_dimensions(data)
|
| 1527 |
-
|
|
|
|
|
|
| 9 |
from typing import Any, Iterator
|
| 10 |
|
| 11 |
import orjson
|
| 12 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 13 |
from fastapi.responses import StreamingResponse
|
| 14 |
from gemini_webapi.client import ChatSession
|
| 15 |
from gemini_webapi.constants import Model
|
|
|
|
| 44 |
from ..services.client import CODE_BLOCK_HINT, XML_WRAP_HINT
|
| 45 |
from ..utils import g_config
|
| 46 |
from ..utils.helper import estimate_tokens
|
| 47 |
+
from .middleware import get_image_store_dir, get_image_token, get_temp_dir, verify_api_key
|
| 48 |
|
| 49 |
# Maximum characters Gemini Web can accept in a single request (configurable)
|
| 50 |
MAX_CHARS_PER_REQUEST = int(g_config.gemini.max_chars_per_request * 0.9)
|
|
|
|
| 588 |
request: ChatCompletionRequest,
|
| 589 |
api_key: str = Depends(verify_api_key),
|
| 590 |
tmp_dir: Path = Depends(get_temp_dir),
|
| 591 |
+
image_store: Path = Depends(get_image_store_dir),
|
| 592 |
):
|
| 593 |
pool = GeminiClientPool()
|
| 594 |
db = LMDBConversationStore()
|
|
|
|
| 773 |
|
| 774 |
@router.post("/v1/responses")
|
| 775 |
async def create_response(
|
| 776 |
+
request_data: ResponseCreateRequest,
|
| 777 |
+
request: Request,
|
| 778 |
api_key: str = Depends(verify_api_key),
|
| 779 |
tmp_dir: Path = Depends(get_temp_dir),
|
| 780 |
+
image_store: Path = Depends(get_image_store_dir),
|
| 781 |
):
|
| 782 |
+
base_messages, normalized_input = _response_items_to_messages(request_data.input)
|
| 783 |
+
structured_requirement = _build_structured_requirement(request_data.response_format)
|
| 784 |
+
if structured_requirement and request_data.stream:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 785 |
logger.debug(
|
| 786 |
"Structured response requested with streaming enabled; streaming not supported for Responses."
|
| 787 |
)
|
|
|
|
| 797 |
standard_tools: list[Tool] = []
|
| 798 |
image_tools: list[ResponseImageTool] = []
|
| 799 |
|
| 800 |
+
if request_data.tools:
|
| 801 |
+
for t in request_data.tools:
|
| 802 |
if isinstance(t, Tool):
|
| 803 |
standard_tools.append(t)
|
| 804 |
elif isinstance(t, ResponseImageTool):
|
|
|
|
| 813 |
|
| 814 |
image_instruction = _build_image_generation_instruction(
|
| 815 |
image_tools,
|
| 816 |
+
request_data.tool_choice
|
| 817 |
+
if isinstance(request_data.tool_choice, ResponseToolChoice)
|
| 818 |
+
else None,
|
| 819 |
)
|
| 820 |
if image_instruction:
|
| 821 |
extra_instructions.append(image_instruction)
|
| 822 |
logger.debug("Image generation support enabled for /v1/responses request.")
|
| 823 |
|
| 824 |
+
preface_messages = _instructions_to_messages(request_data.instructions)
|
| 825 |
conversation_messages = base_messages
|
| 826 |
if preface_messages:
|
| 827 |
conversation_messages = [*preface_messages, *base_messages]
|
|
|
|
| 832 |
# Pass standard tools to the prompt builder
|
| 833 |
# Determine tool_choice for standard tools (ignore image_generation choice here as it is handled via instruction)
|
| 834 |
model_tool_choice = None
|
| 835 |
+
if isinstance(request_data.tool_choice, str):
|
| 836 |
+
model_tool_choice = request_data.tool_choice
|
| 837 |
+
elif isinstance(request_data.tool_choice, ToolChoiceFunction):
|
| 838 |
+
model_tool_choice = request_data.tool_choice
|
| 839 |
# If tool_choice is ResponseToolChoice (image_generation), we don't pass it as a function tool choice.
|
| 840 |
|
| 841 |
messages = _prepare_messages_for_model(
|
|
|
|
| 849 |
db = LMDBConversationStore()
|
| 850 |
|
| 851 |
try:
|
| 852 |
+
model = Model.from_name(request_data.model)
|
| 853 |
except ValueError as exc:
|
| 854 |
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
| 855 |
|
|
|
|
| 969 |
)
|
| 970 |
|
| 971 |
expects_image = (
|
| 972 |
+
request_data.tool_choice is not None and request_data.tool_choice.type == "image_generation"
|
| 973 |
)
|
| 974 |
images = model_output.images or []
|
| 975 |
logger.debug(
|
|
|
|
| 984 |
summary = f"{summary[:197]}..."
|
| 985 |
logger.warning(
|
| 986 |
"Image generation requested but Gemini produced no images. "
|
| 987 |
+
f"client_id={client_id}, forced_tool_choice={request_data.tool_choice is not None}, "
|
| 988 |
f"instruction_applied={bool(image_instruction)}, assistant_preview='{summary}'"
|
| 989 |
)
|
| 990 |
detail = "LLM returned no images for the requested image_generation tool."
|
|
|
|
| 992 |
detail = f"{detail} Assistant response: {summary}"
|
| 993 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail)
|
| 994 |
|
| 995 |
+
response_contents: list[ResponseOutputContent] = []
|
| 996 |
image_call_items: list[ResponseImageGenerationCall] = []
|
| 997 |
for image in images:
|
| 998 |
try:
|
| 999 |
+
image_base64, width, height, filename = await _image_to_base64(image, image_store)
|
| 1000 |
except Exception as exc:
|
| 1001 |
logger.warning(f"Failed to download generated image: {exc}")
|
| 1002 |
continue
|
| 1003 |
+
|
| 1004 |
+
img_format = "png" if isinstance(image, GeneratedImage) else "jpeg"
|
| 1005 |
+
|
| 1006 |
+
# Use static URL for compatibility
|
| 1007 |
+
image_url = (
|
| 1008 |
+
f"})"
|
| 1009 |
+
)
|
| 1010 |
+
|
| 1011 |
image_call_items.append(
|
| 1012 |
ResponseImageGenerationCall(
|
| 1013 |
id=f"img_{uuid.uuid4().hex}",
|
| 1014 |
status="completed",
|
| 1015 |
result=image_base64,
|
| 1016 |
+
output_format=img_format,
|
| 1017 |
size=f"{width}x{height}" if width and height else None,
|
| 1018 |
)
|
| 1019 |
)
|
| 1020 |
+
# Add as output_text content for compatibility
|
| 1021 |
+
response_contents.append(
|
| 1022 |
+
ResponseOutputContent(type="output_text", text=image_url, annotations=[])
|
| 1023 |
+
)
|
| 1024 |
|
| 1025 |
tool_call_items: list[ResponseToolCall] = []
|
| 1026 |
if detected_tool_calls:
|
|
|
|
| 1033 |
for call in detected_tool_calls
|
| 1034 |
]
|
| 1035 |
|
|
|
|
| 1036 |
if assistant_text:
|
| 1037 |
+
response_contents.append(
|
| 1038 |
+
ResponseOutputContent(type="output_text", text=assistant_text, annotations=[])
|
| 1039 |
+
)
|
| 1040 |
|
| 1041 |
if not response_contents:
|
| 1042 |
+
response_contents.append(ResponseOutputContent(type="output_text", text="", annotations=[]))
|
| 1043 |
|
| 1044 |
created_time = int(datetime.now(tz=timezone.utc).timestamp())
|
| 1045 |
response_id = f"resp_{uuid.uuid4().hex}"
|
|
|
|
| 1061 |
|
| 1062 |
response_payload = ResponseCreateResponse(
|
| 1063 |
id=response_id,
|
| 1064 |
+
created_at=created_time,
|
| 1065 |
+
model=request_data.model,
|
| 1066 |
output=[
|
| 1067 |
ResponseOutputMessage(
|
| 1068 |
id=message_id,
|
|
|
|
| 1073 |
*tool_call_items,
|
| 1074 |
*image_call_items,
|
| 1075 |
],
|
|
|
|
| 1076 |
status="completed",
|
| 1077 |
usage=usage,
|
| 1078 |
input=normalized_input or None,
|
| 1079 |
+
metadata=request_data.metadata or None,
|
| 1080 |
+
tools=request_data.tools,
|
| 1081 |
+
tool_choice=request_data.tool_choice,
|
| 1082 |
)
|
| 1083 |
|
| 1084 |
try:
|
|
|
|
| 1099 |
except Exception as exc:
|
| 1100 |
logger.warning(f"Failed to save Responses conversation to LMDB: {exc}")
|
| 1101 |
|
| 1102 |
+
if request_data.stream:
|
| 1103 |
logger.debug(
|
| 1104 |
f"Streaming Responses API payload (response_id={response_payload.id}, text_chunks={bool(assistant_text)})."
|
| 1105 |
)
|
|
|
|
| 1348 |
|
| 1349 |
response_dict = response_payload.model_dump(mode="json")
|
| 1350 |
response_id = response_payload.id
|
| 1351 |
+
created_time = response_payload.created_at
|
| 1352 |
model = response_payload.model
|
| 1353 |
|
| 1354 |
logger.debug(
|
|
|
|
| 1358 |
base_event = {
|
| 1359 |
"id": response_id,
|
| 1360 |
"object": "response",
|
| 1361 |
+
"created_at": created_time,
|
| 1362 |
"model": model,
|
| 1363 |
}
|
| 1364 |
|
| 1365 |
created_snapshot: dict[str, Any] = {
|
| 1366 |
"id": response_id,
|
| 1367 |
"object": "response",
|
| 1368 |
+
"created_at": created_time,
|
| 1369 |
"model": model,
|
| 1370 |
"status": "in_progress",
|
| 1371 |
}
|
|
|
|
| 1373 |
created_snapshot["metadata"] = response_dict["metadata"]
|
| 1374 |
if response_dict.get("input") is not None:
|
| 1375 |
created_snapshot["input"] = response_dict["input"]
|
| 1376 |
+
if response_dict.get("tools") is not None:
|
| 1377 |
+
created_snapshot["tools"] = response_dict["tools"]
|
| 1378 |
+
if response_dict.get("tool_choice") is not None:
|
| 1379 |
+
created_snapshot["tool_choice"] = response_dict["tool_choice"]
|
| 1380 |
|
| 1381 |
async def generate_stream():
|
| 1382 |
# Emit creation event
|
|
|
|
| 1387 |
}
|
| 1388 |
yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n"
|
| 1389 |
|
| 1390 |
+
# Stream output items (Message/Text, Tool Calls, Images)
|
| 1391 |
+
for i, item in enumerate(response_payload.output):
|
| 1392 |
+
item_json = item.model_dump(mode="json", exclude_none=True)
|
| 1393 |
+
|
| 1394 |
+
added_event = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1395 |
**base_event,
|
| 1396 |
+
"type": "response.output_item.added",
|
| 1397 |
+
"output_index": i,
|
| 1398 |
+
"item": item_json,
|
| 1399 |
}
|
| 1400 |
+
yield f"data: {orjson.dumps(added_event).decode('utf-8')}\n\n"
|
| 1401 |
+
|
| 1402 |
+
# 2. Stream content if it's a message (text)
|
| 1403 |
+
if item.type == "message":
|
| 1404 |
+
content_text = ""
|
| 1405 |
+
# Aggregate text content to stream
|
| 1406 |
+
for c in item.content:
|
| 1407 |
+
if c.type == "output_text" and c.text:
|
| 1408 |
+
content_text += c.text
|
| 1409 |
+
|
| 1410 |
+
if content_text:
|
| 1411 |
+
for chunk in _iter_stream_segments(content_text):
|
| 1412 |
+
delta_event = {
|
| 1413 |
+
**base_event,
|
| 1414 |
+
"type": "response.output_text.delta",
|
| 1415 |
+
"output_index": i,
|
| 1416 |
+
"delta": chunk,
|
| 1417 |
+
}
|
| 1418 |
+
yield f"data: {orjson.dumps(delta_event).decode('utf-8')}\n\n"
|
| 1419 |
+
|
| 1420 |
+
# Text done
|
| 1421 |
+
done_event = {
|
| 1422 |
+
**base_event,
|
| 1423 |
+
"type": "response.output_text.done",
|
| 1424 |
+
"output_index": i,
|
| 1425 |
+
}
|
| 1426 |
+
yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n"
|
| 1427 |
+
|
| 1428 |
+
# 3. Emit output_item.done for all types
|
| 1429 |
+
# This confirms the item is fully transferred.
|
| 1430 |
+
item_done_event = {
|
| 1431 |
**base_event,
|
| 1432 |
+
"type": "response.output_item.done",
|
| 1433 |
+
"output_index": i,
|
| 1434 |
+
"item": item_json,
|
| 1435 |
}
|
| 1436 |
+
yield f"data: {orjson.dumps(item_done_event).decode('utf-8')}\n\n"
|
| 1437 |
|
| 1438 |
# Emit completed event with full payload
|
| 1439 |
completed_event = {
|
|
|
|
| 1554 |
return None, None
|
| 1555 |
|
| 1556 |
|
| 1557 |
+
async def _image_to_base64(image: Image, temp_dir: Path) -> tuple[str, int | None, int | None, str]:
|
| 1558 |
+
"""Persist an image provided by gemini_webapi and return base64 plus dimensions and filename."""
|
| 1559 |
if isinstance(image, GeneratedImage):
|
| 1560 |
saved_path = await image.save(path=str(temp_dir), full_size=True)
|
| 1561 |
else:
|
|
|
|
| 1564 |
if not saved_path:
|
| 1565 |
raise ValueError("Failed to save generated image")
|
| 1566 |
|
| 1567 |
+
# Rename file to a random UUID to ensure uniqueness and unpredictability
|
| 1568 |
+
original_path = Path(saved_path)
|
| 1569 |
+
random_name = f"img_{uuid.uuid4().hex}{original_path.suffix}"
|
| 1570 |
+
new_path = temp_dir / random_name
|
| 1571 |
+
original_path.rename(new_path)
|
| 1572 |
+
|
| 1573 |
+
data = new_path.read_bytes()
|
| 1574 |
width, height = _extract_image_dimensions(data)
|
| 1575 |
+
filename = random_name
|
| 1576 |
+
return base64.b64encode(data).decode("ascii"), width, height, filename
|
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
|
| 4 |
+
from ..server.middleware import get_image_store_dir, verify_image_token
|
| 5 |
+
|
| 6 |
+
router = APIRouter()
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@router.get("/images/{filename}", tags=["Images"])
|
| 10 |
+
async def get_image(filename: str, token: str | None = Query(default=None)):
|
| 11 |
+
if not verify_image_token(filename, token):
|
| 12 |
+
raise HTTPException(status_code=403, detail="Invalid token")
|
| 13 |
+
|
| 14 |
+
image_store = get_image_store_dir()
|
| 15 |
+
file_path = image_store / filename
|
| 16 |
+
if not file_path.exists():
|
| 17 |
+
raise HTTPException(status_code=404, detail="Image not found")
|
| 18 |
+
return FileResponse(file_path)
|
|
@@ -1,13 +1,72 @@
|
|
|
|
|
|
|
|
| 1 |
import tempfile
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
from fastapi.responses import ORJSONResponse
|
| 7 |
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
|
|
| 8 |
|
| 9 |
from ..utils import g_config
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
def global_exception_handler(request: Request, exc: Exception):
|
| 13 |
if isinstance(exc, HTTPException):
|
|
|
|
| 1 |
+
import hashlib
|
| 2 |
+
import hmac
|
| 3 |
import tempfile
|
| 4 |
+
import time
|
| 5 |
from pathlib import Path
|
| 6 |
|
| 7 |
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
from fastapi.responses import ORJSONResponse
|
| 10 |
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 11 |
+
from loguru import logger
|
| 12 |
|
| 13 |
from ..utils import g_config
|
| 14 |
|
| 15 |
+
# Persistent directory for storing generated images
|
| 16 |
+
IMAGE_STORE_DIR = Path(tempfile.gettempdir()) / "ai_generated_images"
|
| 17 |
+
IMAGE_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_image_store_dir() -> Path:
|
| 21 |
+
"""Returns a persistent directory for storing images."""
|
| 22 |
+
return IMAGE_STORE_DIR
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_image_token(filename: str) -> str:
|
| 26 |
+
"""Generate a HMAC-SHA256 token for a filename using the API key."""
|
| 27 |
+
secret = g_config.server.api_key
|
| 28 |
+
if not secret:
|
| 29 |
+
return ""
|
| 30 |
+
|
| 31 |
+
msg = filename.encode("utf-8")
|
| 32 |
+
secret_bytes = secret.encode("utf-8")
|
| 33 |
+
return hmac.new(secret_bytes, msg, hashlib.sha256).hexdigest()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def verify_image_token(filename: str, token: str | None) -> bool:
|
| 37 |
+
"""Verify the provided token against the filename."""
|
| 38 |
+
expected = get_image_token(filename)
|
| 39 |
+
if not expected:
|
| 40 |
+
return True # No auth required
|
| 41 |
+
if not token:
|
| 42 |
+
return False
|
| 43 |
+
return hmac.compare_digest(token, expected)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def cleanup_expired_images(retention_days: int) -> int:
|
| 47 |
+
"""Delete images in IMAGE_STORE_DIR older than retention_days."""
|
| 48 |
+
if retention_days <= 0:
|
| 49 |
+
return 0
|
| 50 |
+
|
| 51 |
+
now = time.time()
|
| 52 |
+
retention_seconds = retention_days * 24 * 60 * 60
|
| 53 |
+
cutoff = now - retention_seconds
|
| 54 |
+
|
| 55 |
+
count = 0
|
| 56 |
+
for file_path in IMAGE_STORE_DIR.iterdir():
|
| 57 |
+
if not file_path.is_file():
|
| 58 |
+
continue
|
| 59 |
+
try:
|
| 60 |
+
if file_path.stat().st_mtime < cutoff:
|
| 61 |
+
file_path.unlink()
|
| 62 |
+
count += 1
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.warning(f"Failed to delete expired image {file_path}: {e}")
|
| 65 |
+
|
| 66 |
+
if count > 0:
|
| 67 |
+
logger.info(f"Cleaned up {count} expired images.")
|
| 68 |
+
return count
|
| 69 |
+
|
| 70 |
|
| 71 |
def global_exception_handler(request: Request, exc: Exception):
|
| 72 |
if isinstance(exc, HTTPException):
|
|
@@ -76,6 +76,9 @@ class GeminiClientWrapper(GeminiClient):
|
|
| 76 |
logger.exception(f"Failed to initialize GeminiClient {self.id}")
|
| 77 |
raise
|
| 78 |
|
|
|
|
|
|
|
|
|
|
| 79 |
@staticmethod
|
| 80 |
async def process_message(
|
| 81 |
message: Message, tempdir: Path | None = None, tagged: bool = True
|
|
|
|
| 76 |
logger.exception(f"Failed to initialize GeminiClient {self.id}")
|
| 77 |
raise
|
| 78 |
|
| 79 |
+
def running(self) -> bool:
|
| 80 |
+
return self._running
|
| 81 |
+
|
| 82 |
@staticmethod
|
| 83 |
async def process_message(
|
| 84 |
message: Message, tempdir: Path | None = None, tagged: bool = True
|
|
@@ -37,7 +37,7 @@ class GeminiClientPool(metaclass=Singleton):
|
|
| 37 |
"""Initialize all clients in the pool."""
|
| 38 |
success_count = 0
|
| 39 |
for client in self._clients:
|
| 40 |
-
if not client.
|
| 41 |
try:
|
| 42 |
await client.init(
|
| 43 |
timeout=g_config.gemini.timeout,
|
|
@@ -48,7 +48,7 @@ class GeminiClientPool(metaclass=Singleton):
|
|
| 48 |
except Exception:
|
| 49 |
logger.exception(f"Failed to initialize client {client.id}")
|
| 50 |
|
| 51 |
-
if client.
|
| 52 |
success_count += 1
|
| 53 |
|
| 54 |
if success_count == 0:
|
|
@@ -79,7 +79,7 @@ class GeminiClientPool(metaclass=Singleton):
|
|
| 79 |
|
| 80 |
async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool:
|
| 81 |
"""Make sure the client is running, attempting a restart if needed."""
|
| 82 |
-
if client.
|
| 83 |
return True
|
| 84 |
|
| 85 |
lock = self._restart_locks.get(client.id)
|
|
@@ -87,7 +87,7 @@ class GeminiClientPool(metaclass=Singleton):
|
|
| 87 |
return False # Should not happen
|
| 88 |
|
| 89 |
async with lock:
|
| 90 |
-
if client.
|
| 91 |
return True
|
| 92 |
|
| 93 |
try:
|
|
@@ -110,4 +110,4 @@ class GeminiClientPool(metaclass=Singleton):
|
|
| 110 |
|
| 111 |
def status(self) -> Dict[str, bool]:
|
| 112 |
"""Return running status for each client."""
|
| 113 |
-
return {client.id: client.
|
|
|
|
| 37 |
"""Initialize all clients in the pool."""
|
| 38 |
success_count = 0
|
| 39 |
for client in self._clients:
|
| 40 |
+
if not client.running():
|
| 41 |
try:
|
| 42 |
await client.init(
|
| 43 |
timeout=g_config.gemini.timeout,
|
|
|
|
| 48 |
except Exception:
|
| 49 |
logger.exception(f"Failed to initialize client {client.id}")
|
| 50 |
|
| 51 |
+
if client.running():
|
| 52 |
success_count += 1
|
| 53 |
|
| 54 |
if success_count == 0:
|
|
|
|
| 79 |
|
| 80 |
async def _ensure_client_ready(self, client: GeminiClientWrapper) -> bool:
|
| 81 |
"""Make sure the client is running, attempting a restart if needed."""
|
| 82 |
+
if client.running():
|
| 83 |
return True
|
| 84 |
|
| 85 |
lock = self._restart_locks.get(client.id)
|
|
|
|
| 87 |
return False # Should not happen
|
| 88 |
|
| 89 |
async with lock:
|
| 90 |
+
if client.running():
|
| 91 |
return True
|
| 92 |
|
| 93 |
try:
|
|
|
|
| 110 |
|
| 111 |
def status(self) -> Dict[str, bool]:
|
| 112 |
"""Return running status for each client."""
|
| 113 |
+
return {client.id: client.running() for client in self._clients}
|