Nativu5 commited on
Commit
cd70b5d
·
1 Parent(s): f2fc66c

:sparkles: Add support for multi-round conversation

Browse files
.gitignore CHANGED
@@ -10,4 +10,5 @@ __pycache__
10
  uv.lock
11
 
12
  .env
13
- config.debug.yaml
 
 
10
  uv.lock
11
 
12
  .env
13
+ config.debug.yaml
14
+ data/
app/models/__init__.py CHANGED
@@ -1,9 +1 @@
1
- from .models import (
2
- ChatCompletionRequest,
3
- ChatCompletionResponse,
4
- Message,
5
- ModelData,
6
- ModelListResponse,
7
- )
8
-
9
- __all__ = ["ChatCompletionRequest", "ChatCompletionResponse", "Message", "ModelData", "ModelListResponse"]
 
1
+ from .models import * # noqa: F403
 
 
 
 
 
 
 
 
app/models/models.py CHANGED
@@ -1,6 +1,7 @@
 
1
  from typing import Dict, List, Literal, Optional, Union
2
 
3
- from pydantic import BaseModel
4
 
5
 
6
  class ContentItem(BaseModel):
@@ -20,21 +21,6 @@ class Message(BaseModel):
20
  name: Optional[str] = None
21
 
22
 
23
- class ChatCompletionRequest(BaseModel):
24
- """Chat completion request model"""
25
-
26
- model: str
27
- messages: List[Message]
28
- temperature: Optional[float] = 0.7
29
- top_p: Optional[float] = 1.0
30
- n: Optional[int] = 1
31
- stream: Optional[bool] = False
32
- max_tokens: Optional[int] = None
33
- presence_penalty: Optional[float] = 0
34
- frequency_penalty: Optional[float] = 0
35
- user: Optional[str] = None
36
-
37
-
38
  class Choice(BaseModel):
39
  """Choice model"""
40
 
@@ -51,24 +37,39 @@ class Usage(BaseModel):
51
  total_tokens: int
52
 
53
 
54
- class ChatCompletionResponse(BaseModel):
55
- """Chat completion response model"""
56
 
57
  id: str
58
- object: str = "chat.completion"
59
  created: int
 
 
 
 
 
 
60
  model: str
61
- choices: List[Choice]
62
- usage: Usage
 
 
 
 
 
 
 
63
 
64
 
65
- class ModelData(BaseModel):
66
- """Model data model"""
67
 
68
  id: str
69
- object: str = "model"
70
  created: int
71
- owned_by: str = "google"
 
 
72
 
73
 
74
  class ModelListResponse(BaseModel):
@@ -78,7 +79,21 @@ class ModelListResponse(BaseModel):
78
  data: List[ModelData]
79
 
80
 
81
- class ErrorResponse(BaseModel):
82
- """Error response model"""
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- error: Dict[str, str]
 
 
 
 
1
+ from datetime import datetime
2
  from typing import Dict, List, Literal, Optional, Union
3
 
4
+ from pydantic import BaseModel, Field
5
 
6
 
7
  class ContentItem(BaseModel):
 
21
  name: Optional[str] = None
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  class Choice(BaseModel):
25
  """Choice model"""
26
 
 
37
  total_tokens: int
38
 
39
 
40
+ class ModelData(BaseModel):
41
+ """Model data model"""
42
 
43
  id: str
44
+ object: str = "model"
45
  created: int
46
+ owned_by: str = "google"
47
+
48
+
49
+ class ChatCompletionRequest(BaseModel):
50
+ """Chat completion request model"""
51
+
52
  model: str
53
+ messages: List[Message]
54
+ temperature: Optional[float] = 0.7
55
+ top_p: Optional[float] = 1.0
56
+ n: Optional[int] = 1
57
+ stream: Optional[bool] = False
58
+ max_tokens: Optional[int] = None
59
+ presence_penalty: Optional[float] = 0
60
+ frequency_penalty: Optional[float] = 0
61
+ user: Optional[str] = None
62
 
63
 
64
+ class ChatCompletionResponse(BaseModel):
65
+ """Chat completion response model"""
66
 
67
  id: str
68
+ object: str = "chat.completion"
69
  created: int
70
+ model: str
71
+ choices: List[Choice]
72
+ usage: Usage
73
 
74
 
75
  class ModelListResponse(BaseModel):
 
79
  data: List[ModelData]
80
 
81
 
82
+ class HealthCheckResponse(BaseModel):
83
+ """Health check response model"""
84
+
85
+ ok: bool
86
+ storage: Optional[Dict[str, str]] = None
87
+ error: Optional[str] = None
88
+
89
+
90
+ class ConversationInStore(BaseModel):
91
+ """Conversation model for storing in the database."""
92
+
93
+ created_at: Optional[datetime] = Field(default=None)
94
+ updated_at: Optional[datetime] = Field(default=None)
95
 
96
+ metadata: list[str | None] = Field(
97
+ ..., description="Metadata for Gemini API to locate the conversation"
98
+ )
99
+ messages: list[Message] = Field(..., description="Message contents in the conversation")
app/server/chat.py CHANGED
@@ -1,19 +1,22 @@
1
- import json
2
- import time
3
  import uuid
4
  from datetime import datetime, timezone
5
  from pathlib import Path
6
 
 
7
  from fastapi import APIRouter, Depends, HTTPException, status
8
  from fastapi.responses import StreamingResponse
9
  from gemini_webapi.constants import Model
10
  from loguru import logger
11
 
12
- from ..models import ChatCompletionRequest, ModelData, ModelListResponse
13
- from ..services.client import SingletonGeminiClient
14
- from ..utils.utils import (
15
- estimate_tokens,
 
 
16
  )
 
 
17
  from .middleware import get_temp_dir, verify_api_key
18
 
19
  router = APIRouter()
@@ -47,46 +50,99 @@ async def create_chat_completion(
47
  tmp_dir: Path = Depends(get_temp_dir),
48
  ):
49
  client = SingletonGeminiClient()
 
50
  model = Model.from_name(request.model)
51
 
52
- # Preprocess the messages
53
- try:
54
- conversation, files = await client.prepare(request.messages, tmp_dir)
55
- conversation = "\n".join(conversation)
56
- logger.debug(f"Conversation length: {len(conversation)}, files count: {len(files)}")
57
- except ValueError as e:
58
- raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
59
- except Exception as e:
60
- logger.exception(f"Error in preparing conversation: {e}")
61
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  # Generate response
64
  try:
65
- response = await client.generate_content(conversation, files=files, model=model)
 
66
  except Exception as e:
67
  logger.exception(f"Error generating content from Gemini API: {e}")
68
  raise
69
 
70
- # Post process
71
- response_text = client.format_response(response)
72
- if not response_text or response_text.strip() == "":
73
- logger.warning("Empty response received from Gemini")
74
- response_text = "No response generated."
75
 
76
- completion_id = f"chatcmpl-{uuid.uuid4()}"
77
- timestamp = int(time.time())
 
 
 
 
 
 
 
 
 
 
78
 
79
  # Return with streaming or standard response
 
 
80
  if request.stream:
81
- return _create_streaming_response(response_text, completion_id, timestamp, request.model)
82
  else:
83
  return _create_standard_response(
84
- response_text, completion_id, timestamp, request.model, conversation
85
  )
86
 
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  def _create_streaming_response(
89
- response_text: str, completion_id: str, created_time: int, model: str
90
  ) -> StreamingResponse:
91
  """Create streaming response"""
92
 
@@ -99,18 +155,18 @@ def _create_streaming_response(
99
  "model": model,
100
  "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
101
  }
102
- yield f"data: {json.dumps(data)}\n\n"
103
 
104
  # Stream output text
105
- for char in response_text:
106
  data = {
107
  "id": completion_id,
108
  "object": "chat.completion.chunk",
109
  "created": created_time,
110
  "model": model,
111
- "choices": [{"index": 0, "delta": {"content": char}, "finish_reason": None}],
112
  }
113
- yield f"data: {json.dumps(data)}\n\n"
114
 
115
  # Send end event
116
  data = {
@@ -120,19 +176,19 @@ def _create_streaming_response(
120
  "model": model,
121
  "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
122
  }
123
- yield f"data: {json.dumps(data)}\n\n"
124
  yield "data: [DONE]\n\n"
125
 
126
  return StreamingResponse(generate_stream(), media_type="text/event-stream")
127
 
128
 
129
  def _create_standard_response(
130
- response_text: str, completion_id: str, created_time: int, model: str, conversation: str
131
  ) -> dict:
132
  """Create standard response"""
133
  # Calculate token usage
134
- prompt_tokens = estimate_tokens(conversation)
135
- completion_tokens = estimate_tokens(response_text)
136
  total_tokens = prompt_tokens + completion_tokens
137
 
138
  result = {
@@ -143,7 +199,7 @@ def _create_standard_response(
143
  "choices": [
144
  {
145
  "index": 0,
146
- "message": {"role": "assistant", "content": response_text},
147
  "finish_reason": "stop",
148
  }
149
  ],
 
 
 
1
  import uuid
2
  from datetime import datetime, timezone
3
  from pathlib import Path
4
 
5
+ import orjson
6
  from fastapi import APIRouter, Depends, HTTPException, status
7
  from fastapi.responses import StreamingResponse
8
  from gemini_webapi.constants import Model
9
  from loguru import logger
10
 
11
+ from ..models import (
12
+ ChatCompletionRequest,
13
+ ConversationInStore,
14
+ Message,
15
+ ModelData,
16
+ ModelListResponse,
17
  )
18
+ from ..services import LMDBConversationStore, SingletonGeminiClient
19
+ from ..utils.helper import estimate_tokens
20
  from .middleware import get_temp_dir, verify_api_key
21
 
22
  router = APIRouter()
 
50
  tmp_dir: Path = Depends(get_temp_dir),
51
  ):
52
  client = SingletonGeminiClient()
53
+ db = LMDBConversationStore()
54
  model = Model.from_name(request.model)
55
 
56
+ if len(request.messages) == 0:
57
+ raise HTTPException(
58
+ status_code=status.HTTP_400_BAD_REQUEST,
59
+ detail="At least one message is required in the conversation.",
60
+ )
61
+
62
+ # Check if conversation is reusable
63
+ session = None
64
+ if _check_reusable(request.messages):
65
+ try:
66
+ # Exclude the last message from user
67
+ if old_conv := db.find(request.messages[:-1]):
68
+ session = client.start_chat(metadata=old_conv.metadata, model=model)
69
+ except Exception as e:
70
+ session = None
71
+ logger.warning(f"Error checking LMDB for reusable session: {e}")
72
+
73
+ if session:
74
+ # Just send the last message to the existing session
75
+ model_input, files = await client.process_message(
76
+ request.messages[-1], tmp_dir, tagged=False
77
+ )
78
+ logger.debug(f"Found reusable session: {session.metadata}")
79
+ else:
80
+ # Start a new session and concat messages into a single string
81
+ session = client.start_chat(model=model)
82
+ try:
83
+ model_input, files = await client.precess_conversation(request.messages, tmp_dir)
84
+ except ValueError as e:
85
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
86
+ except Exception as e:
87
+ logger.exception(f"Error in preparing conversation: {e}")
88
+ raise
89
+ logger.debug("New session started.")
90
 
91
  # Generate response
92
  try:
93
+ logger.debug(f"Input length: {len(model_input)}, files count: {len(files)}")
94
+ response = await session.send_message(model_input, files=files)
95
  except Exception as e:
96
  logger.exception(f"Error generating content from Gemini API: {e}")
97
  raise
98
 
99
+ # Format and clean the output
100
+ model_output = client.extract_output(response)
 
 
 
101
 
102
+ # After cleaning, persist the conversation
103
+ try:
104
+ last_message = Message(role="assistant", content=model_output)
105
+ conv = ConversationInStore(
106
+ metadata=session.metadata,
107
+ messages=[*request.messages, last_message],
108
+ )
109
+ key = db.store(conv)
110
+ logger.debug(f"Conversation saved to LMDB with key: {key}")
111
+ except Exception as e:
112
+ # We can still return the response even if saving fails
113
+ logger.warning(f"Failed to save conversation to LMDB: {e}")
114
 
115
  # Return with streaming or standard response
116
+ completion_id = f"chatcmpl-{uuid.uuid4()}"
117
+ timestamp = int(datetime.now(tz=timezone.utc).timestamp())
118
  if request.stream:
119
+ return _create_streaming_response(model_output, completion_id, timestamp, request.model)
120
  else:
121
  return _create_standard_response(
122
+ model_output, completion_id, timestamp, request.model, model_input
123
  )
124
 
125
 
126
+ def _check_reusable(messages: list[Message]) -> bool:
127
+ """
128
+ Check if the conversation is reusable based on the message history.
129
+ """
130
+ if not messages or len(messages) < 2:
131
+ return False
132
+
133
+ # Last message must from the user
134
+ if messages[-1].role != "user" or not messages[-1].content:
135
+ return False
136
+
137
+ # The second last message must be from the assistant or system
138
+ if messages[-2].role not in ["assistant", "system"]:
139
+ return False
140
+
141
+ return True
142
+
143
+
144
  def _create_streaming_response(
145
+ model_output: str, completion_id: str, created_time: int, model: str
146
  ) -> StreamingResponse:
147
  """Create streaming response"""
148
 
 
155
  "model": model,
156
  "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
157
  }
158
+ yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n"
159
 
160
  # Stream output text
161
+ for part in model_output.split():
162
  data = {
163
  "id": completion_id,
164
  "object": "chat.completion.chunk",
165
  "created": created_time,
166
  "model": model,
167
+ "choices": [{"index": 0, "delta": {"content": part}, "finish_reason": None}],
168
  }
169
+ yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n"
170
 
171
  # Send end event
172
  data = {
 
176
  "model": model,
177
  "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
178
  }
179
+ yield f"data: {orjson.dumps(data).decode('utf-8')}\n\n"
180
  yield "data: [DONE]\n\n"
181
 
182
  return StreamingResponse(generate_stream(), media_type="text/event-stream")
183
 
184
 
185
  def _create_standard_response(
186
+ model_output: str, completion_id: str, created_time: int, model: str, model_input: str
187
  ) -> dict:
188
  """Create standard response"""
189
  # Calculate token usage
190
+ prompt_tokens = estimate_tokens(model_input)
191
+ completion_tokens = estimate_tokens(model_output)
192
  total_tokens = prompt_tokens + completion_tokens
193
 
194
  result = {
 
199
  "choices": [
200
  {
201
  "index": 0,
202
+ "message": {"role": "assistant", "content": model_output},
203
  "finish_reason": "stop",
204
  }
205
  ],
app/server/health.py CHANGED
@@ -1,7 +1,8 @@
1
  from fastapi import APIRouter
2
  from loguru import logger
3
 
4
- from ..services.client import SingletonGeminiClient
 
5
 
6
  router = APIRouter()
7
 
@@ -9,15 +10,18 @@ router = APIRouter()
9
  @router.get("/health")
10
  async def health_check():
11
  client = SingletonGeminiClient()
 
12
 
13
  if not client.running:
14
  try:
15
  await client.init()
16
  except Exception as e:
17
  logger.error(f"Failed to initialize Gemini client: {e}")
18
- return {
19
- "status": "unhealthy",
20
- "error": str(e),
21
- }
22
 
23
- return {"status": "healthy"}
 
 
 
 
 
 
1
  from fastapi import APIRouter
2
  from loguru import logger
3
 
4
+ from ..models import HealthCheckResponse
5
+ from ..services import LMDBConversationStore, SingletonGeminiClient
6
 
7
  router = APIRouter()
8
 
 
10
  @router.get("/health")
11
  async def health_check():
12
  client = SingletonGeminiClient()
13
+ db = LMDBConversationStore()
14
 
15
  if not client.running:
16
  try:
17
  await client.init()
18
  except Exception as e:
19
  logger.error(f"Failed to initialize Gemini client: {e}")
20
+ return HealthCheckResponse(ok=False, error=str(e))
 
 
 
21
 
22
+ stat = db.stats()
23
+ if not stat:
24
+ logger.error("Failed to retrieve LMDB conversation store stats")
25
+ return HealthCheckResponse(ok=False, error="LMDB conversation store unavailable")
26
+
27
+ return HealthCheckResponse(ok=True, storage=stat)
app/server/middleware.py CHANGED
@@ -3,7 +3,7 @@ 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 JSONResponse
7
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
8
 
9
  from ..utils import g_config
@@ -11,12 +11,12 @@ from ..utils import g_config
11
 
12
  def global_exception_handler(request: Request, exc: Exception):
13
  if isinstance(exc, HTTPException):
14
- return JSONResponse(
15
  status_code=exc.status_code,
16
  content={"error": {"message": exc.detail}},
17
  )
18
 
19
- return JSONResponse(
20
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": {"message": str(exc)}}
21
  )
22
 
 
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
 
11
 
12
  def global_exception_handler(request: Request, exc: Exception):
13
  if isinstance(exc, HTTPException):
14
+ return ORJSONResponse(
15
  status_code=exc.status_code,
16
  content={"error": {"message": exc.detail}},
17
  )
18
 
19
+ return ORJSONResponse(
20
  status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"error": {"message": str(exc)}}
21
  )
22
 
app/services/__init__.py CHANGED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from .client import SingletonGeminiClient
2
+ from .lmdb import LMDBConversationStore
3
+
4
+ __all__ = [
5
+ "LMDBConversationStore",
6
+ "SingletonGeminiClient",
7
+ ]
app/services/client.py CHANGED
@@ -5,63 +5,92 @@ from gemini_webapi import GeminiClient, ModelOutput
5
 
6
  from ..models import Message
7
  from ..utils import g_config
 
8
  from ..utils.singleton import Singleton
9
- from ..utils.utils import add_tag, save_file_to_tempfile, save_url_to_tempfile
10
 
11
 
12
  class SingletonGeminiClient(GeminiClient, metaclass=Singleton):
13
  def __init__(self, **kwargs):
14
- # TODO: Add proxy support if needed
15
- super().__init__(
16
- secure_1psid=g_config.gemini.secure_1psid,
17
- secure_1psidts=g_config.gemini.secure_1psidts,
18
- **kwargs,
19
- )
20
-
21
- async def init(self):
22
- await super().init(
23
- timeout=g_config.gemini.timeout,
24
- auto_refresh=g_config.gemini.auto_refresh,
25
- verbose=g_config.gemini.verbose,
26
- )
27
-
28
- async def prepare(self, messages: list[Message], tempdir: Path | None = None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  conversation: list[str] = []
30
  files: list[Path | str] = []
31
 
32
  for msg in messages:
33
- if isinstance(msg.content, str):
34
- # Pure text content
35
- conversation.append(add_tag(msg.role, msg.content))
36
- else:
37
- # Mixed content
38
- for item in msg.content:
39
- if item.type == "text":
40
- conversation.append(add_tag(msg.role, item.text or ""))
41
-
42
- elif item.type == "image_url":
43
- # TODO: Use Pydantic to enforce the value checking
44
- if not item.image_url:
45
- raise ValueError("Image URL cannot be empty")
46
- if url := item.image_url.get("url", None):
47
- files.append(await save_url_to_tempfile(url, tempdir))
48
- else:
49
- raise ValueError("Image URL must contain 'url' key")
50
-
51
- elif item.type == "file":
52
- if not item.file:
53
- raise ValueError("File cannot be empty")
54
- if file_data := item.file.get("file_data", None):
55
- filename = item.file.get("filename", "")
56
- files.append(await save_file_to_tempfile(file_data, filename, tempdir))
57
- else:
58
- raise ValueError("File must contain 'file_data' key")
59
 
60
  # Left with the last message as the assistant's response
61
- conversation.append(add_tag("assistant", "", open=True))
62
- return conversation, files
 
63
 
64
- def format_response(self, response: ModelOutput):
 
 
 
 
65
  text = ""
66
 
67
  if response.thoughts:
@@ -76,14 +105,12 @@ class SingletonGeminiClient(GeminiClient, metaclass=Singleton):
76
  text = text.replace("&lt;", "<").replace("\\<", "<").replace("\\_", "_").replace("\\>", ">")
77
 
78
  def simplify_link_target(text_content: str) -> str:
79
- """简化链接目标"""
80
  match_colon_num = re.match(r"([^:]+:\d+)", text_content)
81
  if match_colon_num:
82
  return match_colon_num.group(1)
83
  return text_content
84
 
85
  def replacer(match: re.Match) -> str:
86
- """链接替换器"""
87
  outer_open_paren = match.group(1)
88
  display_text = match.group(2)
89
 
@@ -95,10 +122,10 @@ class SingletonGeminiClient(GeminiClient, metaclass=Singleton):
95
  else:
96
  return new_link_segment
97
 
98
- # 修复Google搜索链接
99
  pattern = r"(\()?\[`([^`]+?)`\]\((https://www.google.com/search\?q=)(.*?)(?<!\\)\)\)*(\))?"
100
  text = re.sub(pattern, replacer, text)
101
 
102
- # 修复包装的markdown链接
103
  pattern = r"`(\[[^\]]+\]\([^\)]+\))`"
104
  return re.sub(pattern, r"\1", text)
 
5
 
6
  from ..models import Message
7
  from ..utils import g_config
8
+ from ..utils.helper import add_tag, save_file_to_tempfile, save_url_to_tempfile
9
  from ..utils.singleton import Singleton
 
10
 
11
 
12
  class SingletonGeminiClient(GeminiClient, metaclass=Singleton):
13
  def __init__(self, **kwargs):
14
+ kwargs.setdefault("secure_1psid", g_config.gemini.secure_1psid)
15
+ kwargs.setdefault("secure_1psidts", g_config.gemini.secure_1psidts)
16
+
17
+ super().__init__(**kwargs)
18
+
19
+ async def init(self, **kwargs):
20
+ # Inject default configuration values
21
+ kwargs.setdefault("timeout", g_config.gemini.timeout)
22
+ kwargs.setdefault("auto_refresh", g_config.gemini.auto_refresh)
23
+ kwargs.setdefault("verbose", g_config.gemini.verbose)
24
+ kwargs.setdefault("refresh_interval", g_config.gemini.refresh_interval)
25
+
26
+ await super().init(**kwargs)
27
+
28
+ @staticmethod
29
+ async def process_message(
30
+ message: Message, tempdir: Path | None = None, tagged: bool = True
31
+ ) -> tuple[str, list[Path | str]]:
32
+ """
33
+ Process a single message and return model input.
34
+ """
35
+ model_input = ""
36
+ files: list[Path | str] = []
37
+ if isinstance(message.content, str):
38
+ # Pure text content
39
+ model_input = message.content
40
+ else:
41
+ # Mixed content
42
+ # TODO: Use Pydantic to enforce the value checking
43
+ for item in message.content:
44
+ if item.type == "text":
45
+ model_input = item.text or ""
46
+
47
+ elif item.type == "image_url":
48
+ if not item.image_url:
49
+ raise ValueError("Image URL cannot be empty")
50
+ if url := item.image_url.get("url", None):
51
+ files.append(await save_url_to_tempfile(url, tempdir))
52
+ else:
53
+ raise ValueError("Image URL must contain 'url' key")
54
+
55
+ elif item.type == "file":
56
+ if not item.file:
57
+ raise ValueError("File cannot be empty")
58
+ if file_data := item.file.get("file_data", None):
59
+ filename = item.file.get("filename", "")
60
+ files.append(await save_file_to_tempfile(file_data, filename, tempdir))
61
+ else:
62
+ raise ValueError("File must contain 'file_data' key")
63
+
64
+ # Add role tag if needed
65
+ if model_input and tagged:
66
+ model_input = add_tag(message.role, model_input)
67
+
68
+ return model_input, files
69
+
70
+ @staticmethod
71
+ async def precess_conversation(messages: list[Message], tempdir: Path | None = None):
72
+ """
73
+ Process the entire conversation and return a formatted string and list of files.
74
+ The last message is assumed to be the assistant's response.
75
+ """
76
  conversation: list[str] = []
77
  files: list[Path | str] = []
78
 
79
  for msg in messages:
80
+ input_part, files_part = await SingletonGeminiClient.process_message(msg, tempdir)
81
+ conversation.append(input_part)
82
+ files.extend(files_part)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  # Left with the last message as the assistant's response
85
+ conversation.append(add_tag("assistant", "", unclose=True))
86
+
87
+ return "\n".join(conversation), files
88
 
89
+ @staticmethod
90
+ def extract_output(response: ModelOutput):
91
+ """
92
+ Extract and format the output text from the Gemini response.
93
+ """
94
  text = ""
95
 
96
  if response.thoughts:
 
105
  text = text.replace("&lt;", "<").replace("\\<", "<").replace("\\_", "_").replace("\\>", ">")
106
 
107
  def simplify_link_target(text_content: str) -> str:
 
108
  match_colon_num = re.match(r"([^:]+:\d+)", text_content)
109
  if match_colon_num:
110
  return match_colon_num.group(1)
111
  return text_content
112
 
113
  def replacer(match: re.Match) -> str:
 
114
  outer_open_paren = match.group(1)
115
  display_text = match.group(2)
116
 
 
122
  else:
123
  return new_link_segment
124
 
125
+ # Replace Google search links with simplified markdown links
126
  pattern = r"(\()?\[`([^`]+?)`\]\((https://www.google.com/search\?q=)(.*?)(?<!\\)\)\)*(\))?"
127
  text = re.sub(pattern, replacer, text)
128
 
129
+ # Fix inline code blocks
130
  pattern = r"`(\[[^\]]+\]\([^\)]+\))`"
131
  return re.sub(pattern, r"\1", text)
app/services/lmdb.py CHANGED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ from contextlib import contextmanager
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import lmdb
8
+ import orjson
9
+ from loguru import logger
10
+
11
+ from ..models import ConversationInStore, Message
12
+ from ..utils import g_config
13
+ from ..utils.singleton import Singleton
14
+
15
+
16
+ def hash_message(message: Message) -> str:
17
+ """Generate a hash for a single message."""
18
+ # Convert message to dict and sort keys for consistent hashing
19
+ message_dict = message.model_dump(mode="json")
20
+ message_bytes = orjson.dumps(message_dict, option=orjson.OPT_SORT_KEYS)
21
+ return hashlib.sha256(message_bytes).hexdigest()
22
+
23
+
24
+ def hash_message_list(messages: List[Message]) -> str:
25
+ """Generate a hash for a list of messages."""
26
+ # Create a combined hash from all individual message hashes
27
+ combined_hash = hashlib.sha256()
28
+ for message in messages:
29
+ message_hash = hash_message(message)
30
+ combined_hash.update(message_hash.encode("utf-8"))
31
+ return combined_hash.hexdigest()
32
+
33
+
34
+ class LMDBConversationStore(metaclass=Singleton):
35
+ """LMDB-based storage for Message lists with hash-based key-value operations."""
36
+
37
+ CUSTOM_KEY_BINDING_PREFIX = "hash:"
38
+
39
+ def __init__(self, db_path: Optional[str] = None, max_db_size: Optional[int] = None):
40
+ """
41
+ Initialize LMDB store.
42
+
43
+ Args:
44
+ db_path: Path to LMDB database directory
45
+ max_db_size: Maximum database size in bytes (default: 128MB)
46
+ """
47
+
48
+ if db_path is None:
49
+ db_path = g_config.storage.path
50
+ if max_db_size is None:
51
+ max_db_size = g_config.storage.max_size
52
+
53
+ self.db_path: Path = Path(db_path)
54
+ self.max_db_size: int = max_db_size
55
+ self._env: lmdb.Environment | None = None
56
+
57
+ self._ensure_db_path()
58
+ self._init_environment()
59
+
60
+ def _ensure_db_path(self) -> None:
61
+ """Ensure database directory exists."""
62
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
63
+
64
+ def _init_environment(self) -> None:
65
+ """Initialize LMDB environment."""
66
+ try:
67
+ self._env = lmdb.open(
68
+ str(self.db_path),
69
+ map_size=self.max_db_size,
70
+ max_dbs=3, # main, metadata, and index databases
71
+ writemap=True,
72
+ readahead=False,
73
+ meminit=False,
74
+ )
75
+ logger.info(f"LMDB environment initialized at {self.db_path}")
76
+ except Exception as e:
77
+ logger.error(f"Failed to initialize LMDB environment: {e}")
78
+ raise
79
+
80
+ @contextmanager
81
+ def _get_transaction(self, write: bool = False):
82
+ """Get LMDB transaction context manager."""
83
+ if not self._env:
84
+ raise RuntimeError("LMDB environment not initialized")
85
+
86
+ txn: lmdb.Transaction = self._env.begin(write=write)
87
+ try:
88
+ yield txn
89
+ if write:
90
+ txn.commit()
91
+ except Exception:
92
+ if write:
93
+ txn.abort()
94
+ raise
95
+ finally:
96
+ pass # Transaction is automatically cleaned up
97
+
98
+ def store(
99
+ self,
100
+ conv: ConversationInStore,
101
+ custom_key: Optional[str] = None,
102
+ ) -> str:
103
+ """
104
+ Store a conversation model in LMDB.
105
+
106
+ Args:
107
+ conv: Conversation model to store
108
+ custom_key: Optional custom key, if not provided, hash will be used
109
+
110
+ Returns:
111
+ str: The key used to store the messages (hash or custom key)
112
+ """
113
+ if not conv:
114
+ raise ValueError("Messages list cannot be empty")
115
+
116
+ # Generate hash for the message list
117
+ message_hash = hash_message_list(conv.messages)
118
+ storage_key = custom_key or message_hash
119
+
120
+ # Prepare data for storage
121
+ now = datetime.now()
122
+ if conv.created_at is None:
123
+ conv.created_at = now
124
+ conv.updated_at = now
125
+
126
+ value = orjson.dumps(conv.model_dump(mode="json"))
127
+
128
+ try:
129
+ with self._get_transaction(write=True) as txn:
130
+ # Store main data
131
+ txn.put(storage_key.encode("utf-8"), value, overwrite=True)
132
+
133
+ # Store hash -> key mapping for reverse lookup
134
+ if custom_key:
135
+ txn.put(
136
+ f"{self.CUSTOM_KEY_BINDING_PREFIX}{message_hash}".encode("utf-8"),
137
+ custom_key.encode("utf-8"),
138
+ )
139
+
140
+ logger.debug(f"Stored {len(conv.messages)} messages with key: {storage_key}")
141
+ return storage_key
142
+
143
+ except Exception as e:
144
+ logger.error(f"Failed to store conversation: {e}")
145
+ raise
146
+
147
+ def get(self, key: str) -> Optional[ConversationInStore]:
148
+ """
149
+ Retrieve conversation data by key.
150
+
151
+ Args:
152
+ key: Storage key (hash or custom key)
153
+
154
+ Returns:
155
+ Conversation or None if not found
156
+ """
157
+ try:
158
+ with self._get_transaction(write=False) as txn:
159
+ data = txn.get(key.encode("utf-8"), default=None)
160
+ if not data:
161
+ return None
162
+
163
+ storage_data = orjson.loads(data) # type: ignore
164
+ conv = ConversationInStore.model_validate(storage_data)
165
+
166
+ logger.debug(f"Retrieved {len(conv.messages)} messages for key: {key}")
167
+ return conv
168
+
169
+ except Exception as e:
170
+ logger.error(f"Failed to retrieve messages for key {key}: {e}")
171
+ return None
172
+
173
+ def find(self, messages: List[Message]) -> Optional[ConversationInStore]:
174
+ """
175
+ Search conversation data by message list.
176
+
177
+ Args:
178
+ messages: List of messages to search for
179
+
180
+ Returns:
181
+ Conversation or None if not found
182
+ """
183
+ if not messages:
184
+ return None
185
+
186
+ message_hash = hash_message_list(messages)
187
+ key = f"{self.CUSTOM_KEY_BINDING_PREFIX}{message_hash}"
188
+
189
+ try:
190
+ with self._get_transaction(write=False) as txn:
191
+ # Try custom key binding first
192
+ key = txn.get(key.encode("utf-8"), default=None)
193
+ key = key.decode("utf-8") if key else message_hash # type: ignore
194
+
195
+ # Fallback to hash if no custom key found
196
+ return self.get(key)
197
+
198
+ except Exception as e:
199
+ logger.error(f"Failed to retrieve messages by message list: {e}")
200
+ return None
201
+
202
+ def exists(self, key: str) -> bool:
203
+ """
204
+ Check if a key exists in the store.
205
+
206
+ Args:
207
+ key: Storage key to check
208
+
209
+ Returns:
210
+ bool: True if key exists, False otherwise
211
+ """
212
+ try:
213
+ with self._get_transaction(write=False) as txn:
214
+ return txn.get(key.encode("utf-8")) is not None
215
+ except Exception as e:
216
+ logger.error(f"Failed to check existence of key {key}: {e}")
217
+ return False
218
+
219
+ def delete(self, key: str) -> Optional[ConversationInStore]:
220
+ """
221
+ Delete conversation model by key.
222
+
223
+ Args:
224
+ key: Storage key to delete
225
+
226
+ Returns:
227
+ ConversationInStore: The deleted conversation data, or None if not found
228
+ """
229
+ try:
230
+ with self._get_transaction(write=True) as txn:
231
+ # Get data first to clean up hash mapping
232
+ data = txn.get(key.encode("utf-8"))
233
+ if not data:
234
+ return None
235
+
236
+ storage_data = orjson.loads(data) # type: ignore
237
+ conv = ConversationInStore.model_validate(storage_data)
238
+ message_hash = hash_message_list(conv.messages)
239
+
240
+ # Delete main data
241
+ txn.delete(key.encode("utf-8"))
242
+
243
+ # Clean up hash mapping if it exists
244
+ if message_hash and key != message_hash:
245
+ txn.delete(f"{self.CUSTOM_KEY_BINDING_PREFIX}{message_hash}".encode("utf-8"))
246
+
247
+ logger.info(f"Deleted messages with key: {key}")
248
+ return conv
249
+
250
+ except Exception as e:
251
+ logger.error(f"Failed to delete key {key}: {e}")
252
+ return None
253
+
254
+ def keys(self, prefix: str = "", limit: Optional[int] = None) -> List[str]:
255
+ """
256
+ List all keys in the store, optionally filtered by prefix.
257
+
258
+ Args:
259
+ prefix: Optional prefix to filter keys
260
+ limit: Optional limit on number of keys returned
261
+
262
+ Returns:
263
+ List of keys
264
+ """
265
+ keys = []
266
+ try:
267
+ with self._get_transaction(write=False) as txn:
268
+ cursor = txn.cursor()
269
+ cursor.first()
270
+
271
+ count = 0
272
+ for key, _ in cursor:
273
+ key_str = key.decode("utf-8")
274
+ # Skip internal hash mappings
275
+ if key_str.startswith(self.CUSTOM_KEY_BINDING_PREFIX):
276
+ continue
277
+
278
+ if not prefix or key_str.startswith(prefix):
279
+ keys.append(key_str)
280
+ count += 1
281
+
282
+ if limit and count >= limit:
283
+ break
284
+
285
+ except Exception as e:
286
+ logger.error(f"Failed to list keys: {e}")
287
+
288
+ return keys
289
+
290
+ def stats(self) -> Dict[str, Any]:
291
+ """
292
+ Get database statistics.
293
+
294
+ Returns:
295
+ Dict with database statistics
296
+ """
297
+ if not self._env:
298
+ logger.error("LMDB environment not initialized")
299
+ return {}
300
+
301
+ try:
302
+ with self._get_transaction(write=False) as txn:
303
+ stat = txn.stat(self._env._db)
304
+ return {
305
+ "entries": stat["entries"],
306
+ "page_size": stat["psize"],
307
+ "depth": stat["depth"],
308
+ "branch_pages": stat["branch_pages"],
309
+ "leaf_pages": stat["leaf_pages"],
310
+ "overflow_pages": stat["overflow_pages"],
311
+ }
312
+ except Exception as e:
313
+ logger.error(f"Failed to get database stats: {e}")
314
+ return {}
315
+
316
+ def close(self) -> None:
317
+ """Close the LMDB environment."""
318
+ if self._env:
319
+ self._env.close()
320
+ self._env = None
321
+ logger.info("LMDB environment closed")
322
+
323
+ def __del__(self):
324
+ """Cleanup on destruction."""
325
+ self.close()
app/utils/config.py CHANGED
@@ -23,10 +23,13 @@ class ServerConfig(BaseModel):
23
  class GeminiConfig(BaseModel):
24
  """Gemini API configuration"""
25
 
26
- secure_1psid: str = Field(..., description="Gemini API Secure 1PSID")
27
- secure_1psidts: str = Field(..., description="Gemini API Secure 1PSIDTS")
28
  timeout: int = Field(default=60, ge=1, description="Init timeout")
29
- auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini API credentials")
 
 
 
30
  verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests")
31
 
32
 
@@ -45,6 +48,18 @@ class CORSConfig(BaseModel):
45
  )
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  class LoggingConfig(BaseModel):
49
  level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
50
  default="DEBUG",
@@ -70,6 +85,11 @@ class Config(BaseSettings):
70
  # Gemini API configuration
71
  gemini: GeminiConfig = Field(..., description="Gemini API configuration, must be set")
72
 
 
 
 
 
 
73
  # Logging configuration
74
  logging: LoggingConfig = Field(
75
  default=LoggingConfig(),
 
23
  class GeminiConfig(BaseModel):
24
  """Gemini API configuration"""
25
 
26
+ secure_1psid: str = Field(..., description="Gemini Secure 1PSID")
27
+ secure_1psidts: str = Field(..., description="Gemini Secure 1PSIDTS")
28
  timeout: int = Field(default=60, ge=1, description="Init timeout")
29
+ auto_refresh: bool = Field(True, description="Enable auto-refresh for Gemini cookies")
30
+ refresh_interval: int = Field(
31
+ default=540, ge=1, description="Interval in seconds to refresh Gemini cookies"
32
+ )
33
  verbose: bool = Field(False, description="Enable verbose logging for Gemini API requests")
34
 
35
 
 
48
  )
49
 
50
 
51
+ class StorageConfig(BaseModel):
52
+ path: str = Field(
53
+ default="data/msg.lmdb",
54
+ description="Path to the storage directory where data will be saved",
55
+ )
56
+ max_size: int = Field(
57
+ default=1024**2 * 128, # 128 MB
58
+ ge=1,
59
+ description="Maximum size of the storage in bytes",
60
+ )
61
+
62
+
63
  class LoggingConfig(BaseModel):
64
  level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
65
  default="DEBUG",
 
85
  # Gemini API configuration
86
  gemini: GeminiConfig = Field(..., description="Gemini API configuration, must be set")
87
 
88
+ storage: StorageConfig = Field(
89
+ default=StorageConfig(),
90
+ description="Storage configuration, defines where and how data will be stored",
91
+ )
92
+
93
  # Logging configuration
94
  logging: LoggingConfig = Field(
95
  default=LoggingConfig(),
app/utils/{utils.py → helper.py} RENAMED
@@ -6,13 +6,13 @@ import httpx
6
  from loguru import logger
7
 
8
 
9
- def add_tag(role: str, content: str, open: bool = False) -> str:
10
  """Surround content with role tags"""
11
  if role not in ["user", "assistant", "system"]:
12
  logger.warning(f"Unknown role: {role}, returning content without tags")
13
  return content
14
 
15
- return f"<|im_start|>{role}\n{content}" + ("\n<|im_end|>" if not open else "")
16
 
17
 
18
  def estimate_tokens(text: str) -> int:
 
6
  from loguru import logger
7
 
8
 
9
+ def add_tag(role: str, content: str, unclose: bool = False) -> str:
10
  """Surround content with role tags"""
11
  if role not in ["user", "assistant", "system"]:
12
  logger.warning(f"Unknown role: {role}, returning content without tags")
13
  return content
14
 
15
+ return f"<|im_start|>{role}\n{content}" + ("\n<|im_end|>" if not unclose else "")
16
 
17
 
18
  def estimate_tokens(text: str) -> int:
app/utils/logging.py CHANGED
@@ -3,7 +3,6 @@ import logging
3
  import sys
4
  from typing import Literal
5
 
6
- from gemini_webapi.utils.logger import set_log_level
7
  from loguru import logger
8
 
9
 
@@ -22,10 +21,7 @@ def setup_logging(
22
  backtrace: Whether to enable backtrace information
23
  colorize: Whether to enable colors
24
  """
25
- # Set gemini_webapi log level to avoid conflicts
26
- set_log_level(level)
27
-
28
- # Remove all existing loguru handlers
29
  logger.remove()
30
 
31
  # Add unified handler for all logs
@@ -69,4 +65,4 @@ def _setup_logging_intercept() -> None:
69
  logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
70
 
71
  # Remove all existing handlers and add our interceptor
72
- logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
 
3
  import sys
4
  from typing import Literal
5
 
 
6
  from loguru import logger
7
 
8
 
 
21
  backtrace: Whether to enable backtrace information
22
  colorize: Whether to enable colors
23
  """
24
+ # Reset all logger handlers
 
 
 
25
  logger.remove()
26
 
27
  # Add unified handler for all logs
 
65
  logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
66
 
67
  # Remove all existing handlers and add our interceptor
68
+ logging.basicConfig(handlers=[InterceptHandler()], level="INFO", force=True)
config/config.yaml CHANGED
@@ -11,12 +11,17 @@ cors:
11
  allow_methods: ["*"]
12
  allow_headers: ["*"]
13
 
 
 
 
 
14
  gemini:
15
  secure_1psid: "YOUR_SECURE_1PSID_HERE"
16
  secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE"
17
  timeout: 60
18
  auto_refresh: true
 
19
  verbose: false
20
 
21
  logging:
22
- level: "DEBUG"
 
11
  allow_methods: ["*"]
12
  allow_headers: ["*"]
13
 
14
+ storage:
15
+ path: "data/lmdb"
16
+ max_size: 134217728 # 128 MB
17
+
18
  gemini:
19
  secure_1psid: "YOUR_SECURE_1PSID_HERE"
20
  secure_1psidts: "YOUR_SECURE_1PSIDTS_HERE"
21
  timeout: 60
22
  auto_refresh: true
23
+ refresh_interval: 540
24
  verbose: false
25
 
26
  logging:
27
+ level: "DEBUG"
pyproject.toml CHANGED
@@ -6,9 +6,10 @@ readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
8
  "fastapi>=0.115.12",
9
- "gemini-webapi>=1.12.1",
 
10
  "loguru>=0.7.0",
11
- "pydantic-settings>=2.9.1",
12
  "uvicorn>=0.34.1",
13
  "uvloop>=0.21.0",
14
  ]
 
6
  requires-python = ">=3.11"
7
  dependencies = [
8
  "fastapi>=0.115.12",
9
+ "gemini-webapi>=1.14.0",
10
+ "lmdb>=1.6.2",
11
  "loguru>=0.7.0",
12
+ "pydantic-settings[yaml]>=2.9.1",
13
  "uvicorn>=0.34.1",
14
  "uvloop>=0.21.0",
15
  ]