Lưu Quang Vũ commited on
Commit
5cb387e
·
unverified ·
1 Parent(s): ff54322

: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 CHANGED
@@ -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.middleware import add_cors_middleware, add_exception_handler
 
 
 
 
 
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
app/models/models.py CHANGED
@@ -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] = None
 
220
 
221
 
222
  class ResponseOutputMessage(BaseModel):
@@ -254,20 +273,22 @@ class ResponseCreateResponse(BaseModel):
254
 
255
  id: str
256
  object: Literal["response"] = "response"
257
- created: int
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
 
app/server/chat.py CHANGED
@@ -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
- request: ResponseCreateRequest,
 
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(request.input)
780
- if not base_messages:
781
- raise HTTPException(
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 request.tools:
803
- for t in request.tools:
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
- request.tool_choice if isinstance(request.tool_choice, ResponseToolChoice) 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.instructions)
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(request.tool_choice, str):
836
- model_tool_choice = request.tool_choice
837
- elif isinstance(request.tool_choice, ToolChoiceFunction):
838
- model_tool_choice = request.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(request.model)
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
- request.tool_choice is not None and request.tool_choice.type == "image_generation"
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={request.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,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, tmp_dir)
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="png" if isinstance(image, GeneratedImage) else "jpeg",
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(ResponseOutputContent(type="output_text", text=assistant_text))
 
 
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
- created=created_time,
1051
- model=request.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=request.metadata or None,
 
 
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 request.stream:
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.created
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
- "created": created_time,
1347
  "model": model,
1348
  }
1349
 
1350
  created_snapshot: dict[str, Any] = {
1351
  "id": response_id,
1352
  "object": "response",
1353
- "created": created_time,
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 textual content, if any
1372
- if assistant_text:
1373
- for chunk in _iter_stream_segments(assistant_text):
1374
- delta_event = {
1375
- **base_event,
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.output_text.done",
1385
- "output_index": 0,
 
1386
  }
1387
- yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n"
1388
- else:
1389
- done_event = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1390
  **base_event,
1391
- "type": "response.output_text.done",
1392
- "output_index": 0,
 
1393
  }
1394
- yield f"data: {orjson.dumps(done_event).decode('utf-8')}\n\n"
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
- data = Path(saved_path).read_bytes()
 
 
 
 
 
 
1526
  width, height = _extract_image_dimensions(data)
1527
- return base64.b64encode(data).decode("ascii"), width, height
 
 
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"![{filename}]({request.base_url}images/{filename}?token={get_image_token(filename)})"
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
app/server/images.py ADDED
@@ -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)
app/server/middleware.py CHANGED
@@ -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):
app/services/client.py CHANGED
@@ -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
app/services/pool.py CHANGED
@@ -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._running:
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._running:
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._running:
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._running:
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._running for client in self._clients}
 
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}