mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-04 22:31:31 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb5cd92041 | ||
|
|
0be85e9536 | ||
|
|
632dee38b3 | ||
|
|
16c28bf1ba | ||
|
|
71af1db330 | ||
|
|
fb523f4a2e | ||
|
|
40e5ffa5f4 | ||
|
|
0871548b07 | ||
|
|
5a44a76c48 | ||
|
|
7b5b6c7d4c | ||
|
|
68ed4da789 | ||
|
|
cdbca7ec62 | ||
|
|
48d58ef2e8 | ||
|
|
88d483c1ef | ||
|
|
8d48db026c | ||
|
|
a592269198 | ||
|
|
18a5fe6109 | ||
|
|
348cbbdf2a |
11
.env.example
11
.env.example
@@ -2,7 +2,8 @@ API_KEYS=["AIzaSyxxxxxxxxxxxxxxxxxxx","AIzaSyxxxxxxxxxxxxxxxxxxx"]
|
|||||||
ALLOWED_TOKENS=["sk-123456"]
|
ALLOWED_TOKENS=["sk-123456"]
|
||||||
# AUTH_TOKEN=sk-123456
|
# AUTH_TOKEN=sk-123456
|
||||||
MODEL_SEARCH=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
|
MODEL_SEARCH=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
|
||||||
TOOLS_CODE_EXECUTION_ENABLED=true
|
MODEL_IMAGE=["gemini-2.0-flash-exp"]
|
||||||
|
TOOLS_CODE_EXECUTION_ENABLED=false
|
||||||
SHOW_SEARCH_LINK=true
|
SHOW_SEARCH_LINK=true
|
||||||
SHOW_THINKING_PROCESS=true
|
SHOW_THINKING_PROCESS=true
|
||||||
BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||||
@@ -12,4 +13,12 @@ PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
|
|||||||
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
|
CREATE_IMAGE_MODEL=imagen-3.0-generate-002
|
||||||
UPLOAD_PROVIDER=smms
|
UPLOAD_PROVIDER=smms
|
||||||
SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||||
|
PICGO_API_KEY=xxxx
|
||||||
|
##########################################################################
|
||||||
|
#########################stream_optimizer 相关配置########################
|
||||||
|
STREAM_MIN_DELAY=0.016
|
||||||
|
STREAM_MAX_DELAY=0.024
|
||||||
|
STREAM_SHORT_TEXT_THRESHOLD=10
|
||||||
|
STREAM_LONG_TEXT_THRESHOLD=50
|
||||||
|
STREAM_CHUNK_SIZE=5
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ FROM python:3.10-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制所需文件到容器中
|
# 复制所需文件到容器中
|
||||||
COPY ./app /app/app
|
|
||||||
COPY ./requirements.txt /app
|
COPY ./requirements.txt /app
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY ./app /app/app
|
||||||
ENV API_KEYS='["your_api_key_1"]'
|
ENV API_KEYS='["your_api_key_1"]'
|
||||||
ENV ALLOWED_TOKENS='["your_token_1"]'
|
ENV ALLOWED_TOKENS='["your_token_1"]'
|
||||||
ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||||
ENV TOOLS_CODE_EXECUTION_ENABLED=true
|
ENV TOOLS_CODE_EXECUTION_ENABLED=false
|
||||||
ENV MODEL_SEARCH='["gemini-2.0-flash-exp"]'
|
ENV MODEL_SEARCH='["gemini-2.0-flash-exp"]'
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -76,6 +76,13 @@
|
|||||||
# 图片上传配置
|
# 图片上传配置
|
||||||
UPLOAD_PROVIDER="smms" # 图片上传提供商,目前支持smms
|
UPLOAD_PROVIDER="smms" # 图片上传提供商,目前支持smms
|
||||||
SMMS_SECRET_TOKEN="your-smms-token" # SM.MS图床的API Token
|
SMMS_SECRET_TOKEN="your-smms-token" # SM.MS图床的API Token
|
||||||
|
|
||||||
|
# stream_optimizer 相关配置
|
||||||
|
STREAM_MIN_DELAY=0.016
|
||||||
|
STREAM_MAX_DELAY=0.024
|
||||||
|
STREAM_SHORT_TEXT_THRESHOLD=10
|
||||||
|
STREAM_LONG_TEXT_THRESHOLD=50
|
||||||
|
STREAM_CHUNK_SIZE=5
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置说明
|
### 配置说明
|
||||||
@@ -136,6 +143,24 @@
|
|||||||
- 用途: 用于图片上传到 SM.MS 图床
|
- 用途: 用于图片上传到 SM.MS 图床
|
||||||
- 获取方式: 需要在 SM.MS 官网注册并获取
|
- 获取方式: 需要在 SM.MS 官网注册并获取
|
||||||
|
|
||||||
|
#### 流式输出优化配置
|
||||||
|
|
||||||
|
- `STREAM_MIN_DELAY`: 最小延迟时间
|
||||||
|
- 默认值: `0.016`(秒)
|
||||||
|
- 说明: 长文本输出时使用的最小延迟时间,值越小输出速度越快
|
||||||
|
- `STREAM_MAX_DELAY`: 最大延迟时间
|
||||||
|
- 默认值: `0.024`(秒)
|
||||||
|
- 说明: 短文本输出时使用的最大延迟时间,值越大输出速度越慢
|
||||||
|
- `STREAM_SHORT_TEXT_THRESHOLD`: 短文本阈值
|
||||||
|
- 默认值: `10`(字符)
|
||||||
|
- 说明: 小于此长度的文本被视为短文本,将使用最大延迟输出
|
||||||
|
- `STREAM_LONG_TEXT_THRESHOLD`: 长文本阈值
|
||||||
|
- 默认值: `50`(字符)
|
||||||
|
- 说明: 大于此长度的文本被视为长文本,将使用最小延迟并分块输出
|
||||||
|
- `STREAM_CHUNK_SIZE`: 长文本分块大小
|
||||||
|
- 默认值: `5`(字符)
|
||||||
|
- 说明: 长文本分块输出时,每个块的大小
|
||||||
|
|
||||||
### ▶️ 运行
|
### ▶️ 运行
|
||||||
|
|
||||||
#### 使用 Docker (推荐)
|
#### 使用 Docker (推荐)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse, JSONResponse
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
|
from copy import deepcopy
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logger import get_gemini_logger
|
from app.core.logger import get_gemini_logger
|
||||||
from app.core.security import SecurityService
|
from app.core.security import SecurityService
|
||||||
@@ -23,7 +23,7 @@ async def get_key_manager():
|
|||||||
async def get_next_working_key_wrapper(key_manager: KeyManager = Depends(get_key_manager)):
|
async def get_next_working_key_wrapper(key_manager: KeyManager = Depends(get_key_manager)):
|
||||||
return await key_manager.get_next_working_key()
|
return await key_manager.get_next_working_key()
|
||||||
|
|
||||||
model_service = ModelService(settings.MODEL_SEARCH)
|
model_service = ModelService(settings.MODEL_SEARCH,settings.MODEL_IMAGE)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models")
|
@router.get("/models")
|
||||||
@@ -36,12 +36,40 @@ async def list_models(_=Depends(security_service.verify_key),
|
|||||||
api_key = await key_manager.get_next_working_key()
|
api_key = await key_manager.get_next_working_key()
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.info(f"Using API key: {api_key}")
|
||||||
models_json = model_service.get_gemini_models(api_key)
|
models_json = model_service.get_gemini_models(api_key)
|
||||||
models_json["models"].append({"name": "models/gemini-2.0-flash-exp-search", "version": "2.0",
|
|
||||||
"displayName": "Gemini 2.0 Flash Search Experimental",
|
# 模型名称以及对应的详细信息
|
||||||
"description": "Gemini 2.0 Flash Search Experimental", "inputTokenLimit": 32767,
|
model_mapping = {x.get("name", "").split("/", maxsplit=1)[1]: x for x in models_json["models"]}
|
||||||
"outputTokenLimit": 8192,
|
|
||||||
"supportedGenerationMethods": ["generateContent", "countTokens"], "temperature": 1,
|
# 添加搜索模型
|
||||||
"topP": 0.95, "topK": 64, "maxTemperature": 2})
|
if settings.MODEL_SEARCH:
|
||||||
|
for name in settings.MODEL_SEARCH:
|
||||||
|
model = model_mapping.get(name, None)
|
||||||
|
if not model:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = deepcopy(model)
|
||||||
|
item["name"] = f"models/{name}-search"
|
||||||
|
display_name = f'{item.get("displayName")} For Search'
|
||||||
|
item["displayName"] = display_name
|
||||||
|
item["description"] = display_name
|
||||||
|
|
||||||
|
models_json["models"].append(item)
|
||||||
|
|
||||||
|
# 添加图像生成模型
|
||||||
|
if settings.MODEL_IMAGE:
|
||||||
|
for name in settings.MODEL_IMAGE:
|
||||||
|
model = model_mapping.get(name, None)
|
||||||
|
if not model:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = deepcopy(model)
|
||||||
|
item["name"] = f"models/{name}-image"
|
||||||
|
display_name = f'{item.get("displayName")} For Image'
|
||||||
|
item["displayName"] = display_name
|
||||||
|
item["description"] = display_name
|
||||||
|
|
||||||
|
models_json["models"].append(item)
|
||||||
|
|
||||||
return models_json
|
return models_json
|
||||||
|
|
||||||
|
|
||||||
@@ -62,8 +90,11 @@ async def generate_content(
|
|||||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.info(f"Using API key: {api_key}")
|
||||||
|
|
||||||
|
if not model_service.check_model_support(model_name):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = chat_service.generate_content(
|
response = await chat_service.generate_content(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
request=request,
|
request=request,
|
||||||
api_key=api_key
|
api_key=api_key
|
||||||
@@ -92,6 +123,9 @@ async def stream_generate_content(
|
|||||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.info(f"Using API key: {api_key}")
|
||||||
|
|
||||||
|
if not model_service.check_model_support(model_name):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response_stream = chat_service.stream_generate_content(
|
response_stream = chat_service.stream_generate_content(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
@@ -122,7 +156,7 @@ async def verify_key(api_key: str):
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
response = chat_service.generate_content(settings.TEST_MODEL,gemini_requset, api_key)
|
response = await chat_service.generate_content(settings.TEST_MODEL,gemini_requset, api_key)
|
||||||
if response:
|
if response:
|
||||||
return JSONResponse({"status": "valid"})
|
return JSONResponse({"status": "valid"})
|
||||||
return JSONResponse({"status": "invalid"})
|
return JSONResponse({"status": "invalid"})
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ logger = get_openai_logger()
|
|||||||
|
|
||||||
# 初始化服务
|
# 初始化服务
|
||||||
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
||||||
model_service = ModelService(settings.MODEL_SEARCH)
|
model_service = ModelService(settings.MODEL_SEARCH,settings.MODEL_IMAGE)
|
||||||
embedding_service = EmbeddingService(settings.BASE_URL)
|
embedding_service = EmbeddingService(settings.BASE_URL)
|
||||||
image_create_service = ImageCreateService()
|
image_create_service = ImageCreateService()
|
||||||
|
|
||||||
@@ -61,6 +61,10 @@ async def chat_completion(
|
|||||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.info(f"Using API key: {api_key}")
|
||||||
|
|
||||||
|
if not model_service.check_model_support(request.model):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Model {request.model} is not supported")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 如果model是imagen3,使用paid_key
|
# 如果model是imagen3,使用paid_key
|
||||||
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Settings(BaseSettings):
|
|||||||
ALLOWED_TOKENS: List[str]
|
ALLOWED_TOKENS: List[str]
|
||||||
BASE_URL: str = "https://generativelanguage.googleapis.com/v1beta"
|
BASE_URL: str = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
MODEL_SEARCH: List[str] = ["gemini-2.0-flash-exp"]
|
MODEL_SEARCH: List[str] = ["gemini-2.0-flash-exp"]
|
||||||
|
MODEL_IMAGE: List[str] = ["gemini-2.0-flash-exp"]
|
||||||
TOOLS_CODE_EXECUTION_ENABLED: bool = False
|
TOOLS_CODE_EXECUTION_ENABLED: bool = False
|
||||||
SHOW_SEARCH_LINK: bool = True
|
SHOW_SEARCH_LINK: bool = True
|
||||||
SHOW_THINKING_PROCESS: bool = True
|
SHOW_THINKING_PROCESS: bool = True
|
||||||
@@ -16,7 +17,15 @@ class Settings(BaseSettings):
|
|||||||
CREATE_IMAGE_MODEL: str = "imagen-3.0-generate-002"
|
CREATE_IMAGE_MODEL: str = "imagen-3.0-generate-002"
|
||||||
UPLOAD_PROVIDER: str = "smms"
|
UPLOAD_PROVIDER: str = "smms"
|
||||||
SMMS_SECRET_TOKEN: str = ""
|
SMMS_SECRET_TOKEN: str = ""
|
||||||
|
PICGO_API_KEY: str = ""
|
||||||
TEST_MODEL: str = "gemini-1.5-flash"
|
TEST_MODEL: str = "gemini-1.5-flash"
|
||||||
|
|
||||||
|
# 流式输出优化器配置
|
||||||
|
STREAM_MIN_DELAY: float = 0.016
|
||||||
|
STREAM_MAX_DELAY: float = 0.024
|
||||||
|
STREAM_SHORT_TEXT_THRESHOLD: int = 10
|
||||||
|
STREAM_LONG_TEXT_THRESHOLD: int = 50
|
||||||
|
STREAM_CHUNK_SIZE: int = 5
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|||||||
@@ -149,6 +149,116 @@ class QiniuUploader(ImageUploader):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PicGoUploader(ImageUploader):
|
||||||
|
"""Chevereto API 图片上传器"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, api_url: str = "https://www.picgo.net/api/1/upload"):
|
||||||
|
"""
|
||||||
|
初始化 Chevereto 上传器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Chevereto API 密钥
|
||||||
|
api_url: Chevereto API 上传地址
|
||||||
|
"""
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_url = api_url
|
||||||
|
|
||||||
|
def upload(self, file: bytes, filename: str) -> UploadResponse:
|
||||||
|
"""
|
||||||
|
上传图片到 Chevereto 服务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: 图片文件二进制数据
|
||||||
|
filename: 文件名
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UploadResponse: 上传响应对象
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UploadError: 上传失败时抛出异常
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 准备请求头
|
||||||
|
headers = {
|
||||||
|
"X-API-Key": self.api_key
|
||||||
|
}
|
||||||
|
|
||||||
|
# 准备文件数据
|
||||||
|
files = {
|
||||||
|
"source": (filename, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 发送请求
|
||||||
|
response = requests.post(
|
||||||
|
self.api_url,
|
||||||
|
headers=headers,
|
||||||
|
files=files
|
||||||
|
)
|
||||||
|
|
||||||
|
# 检查响应状态
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# 解析响应
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
# 验证上传是否成功
|
||||||
|
if result.get("status_code") != 200:
|
||||||
|
error_message = "Upload failed"
|
||||||
|
if "error" in result:
|
||||||
|
error_message = result["error"].get("message", error_message)
|
||||||
|
raise UploadError(
|
||||||
|
message=error_message,
|
||||||
|
error_type=UploadErrorType.SERVER_ERROR,
|
||||||
|
status_code=result.get("status_code"),
|
||||||
|
details=result.get("error")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从响应中提取图片信息
|
||||||
|
image_data = result.get("image", {})
|
||||||
|
|
||||||
|
# 构建图片元数据
|
||||||
|
image_metadata = ImageMetadata(
|
||||||
|
width=image_data.get("width", 0),
|
||||||
|
height=image_data.get("height", 0),
|
||||||
|
filename=image_data.get("filename", filename),
|
||||||
|
size=image_data.get("size", 0),
|
||||||
|
url=image_data.get("url", ""),
|
||||||
|
delete_url=image_data.get("delete_url", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
return UploadResponse(
|
||||||
|
success=True,
|
||||||
|
code="success",
|
||||||
|
message=result.get("success", {}).get("message", "Upload success"),
|
||||||
|
data=image_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
# 处理网络请求相关错误
|
||||||
|
raise UploadError(
|
||||||
|
message=f"Upload request failed: {str(e)}",
|
||||||
|
error_type=UploadErrorType.NETWORK_ERROR,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError, TypeError) as e:
|
||||||
|
# 处理响应解析错误
|
||||||
|
raise UploadError(
|
||||||
|
message=f"Invalid response format: {str(e)}",
|
||||||
|
error_type=UploadErrorType.PARSE_ERROR,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
except UploadError:
|
||||||
|
# 重新抛出已经是 UploadError 类型的异常
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
# 处理其他未预期的错误
|
||||||
|
raise UploadError(
|
||||||
|
message=f"Upload failed: {str(e)}",
|
||||||
|
error_type=UploadErrorType.UNKNOWN,
|
||||||
|
original_error=e
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ImageUploaderFactory:
|
class ImageUploaderFactory:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(provider: str, **credentials) -> ImageUploader:
|
def create(provider: str, **credentials) -> ImageUploader:
|
||||||
@@ -159,5 +269,7 @@ class ImageUploaderFactory:
|
|||||||
credentials["access_key"],
|
credentials["access_key"],
|
||||||
credentials["secret_key"]
|
credentials["secret_key"]
|
||||||
)
|
)
|
||||||
|
elif provider == "picgo":
|
||||||
|
api_url = credentials.get("api_url", "https://www.picgo.net/api/1/upload")
|
||||||
|
return PicGoUploader(credentials["api_key"], api_url)
|
||||||
raise ValueError(f"Unknown provider: {provider}")
|
raise ValueError(f"Unknown provider: {provider}")
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ class GeminiContent(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class GeminiRequest(BaseModel):
|
class GeminiRequest(BaseModel):
|
||||||
contents: List[GeminiContent]
|
contents: List[GeminiContent] = []
|
||||||
tools: Optional[List[Dict[str, Any]]] = []
|
tools: Optional[List[Dict[str, Any]]] = []
|
||||||
safetySettings: Optional[List[SafetySetting]] = None
|
safetySettings: Optional[List[SafetySetting]] = None
|
||||||
generationConfig: Optional[GenerationConfig] = None
|
generationConfig: Optional[GenerationConfig] = {}
|
||||||
systemInstruction: Optional[SystemInstruction] = None
|
systemInstruction: Optional[SystemInstruction] = None
|
||||||
|
|||||||
@@ -24,13 +24,21 @@ class GeminiApiClient(ApiClient):
|
|||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
def _get_real_model(self, model: str) -> str:
|
||||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
|
||||||
if model.endswith("-search"):
|
if model.endswith("-search"):
|
||||||
model = model[:-7]
|
model = model[:-7]
|
||||||
with httpx.Client(timeout=timeout) as client:
|
if model.endswith("-image"):
|
||||||
|
model = model[:-6]
|
||||||
|
|
||||||
|
return model
|
||||||
|
|
||||||
|
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||||
|
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||||
|
model = self._get_real_model(model)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
|
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
|
||||||
response = client.post(url, json=payload)
|
response = await client.post(url, json=payload)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
error_content = response.text
|
error_content = response.text
|
||||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||||
@@ -38,8 +46,8 @@ class GeminiApiClient(ApiClient):
|
|||||||
|
|
||||||
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
|
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
|
||||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||||
if model.endswith("-search"):
|
model = self._get_real_model(model)
|
||||||
model = model[:-7]
|
|
||||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
|
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
|
||||||
async with client.stream(method="POST", url=url, json=payload) as response:
|
async with client.stream(method="POST", url=url, json=payload) as response:
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
# app/services/chat/message_converter.py
|
# app/services/chat/message_converter.py
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
SUPPORTED_ROLES = ["user", "model", "system"]
|
||||||
|
|
||||||
|
|
||||||
class MessageConverter(ABC):
|
class MessageConverter(ABC):
|
||||||
"""消息转换器基类"""
|
"""消息转换器基类"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -30,24 +32,48 @@ def _convert_image(image_url: str) -> Dict[str, Any]:
|
|||||||
class OpenAIMessageConverter(MessageConverter):
|
class OpenAIMessageConverter(MessageConverter):
|
||||||
"""OpenAI消息格式转换器"""
|
"""OpenAI消息格式转换器"""
|
||||||
|
|
||||||
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
converted_messages = []
|
converted_messages = []
|
||||||
for msg in messages:
|
system_instruction_parts = []
|
||||||
role = "user" if msg["role"] == "user" else "model"
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
if isinstance(msg["content"], str):
|
for idx, msg in enumerate(messages):
|
||||||
|
role = msg.get("role", "")
|
||||||
|
if role not in SUPPORTED_ROLES:
|
||||||
|
if role == "tool":
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
|
# 如果是最后一条消息,则认为是用户消息
|
||||||
|
if idx == len(messages) - 1:
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
|
role = "model"
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if isinstance(msg["content"], str) and msg["content"]:
|
||||||
|
# 请求 gemini 接口时如果包含 content 字段但内容为空时会返回 400 错误,所以需要判断是否为空并移除
|
||||||
parts.append({"text": msg["content"]})
|
parts.append({"text": msg["content"]})
|
||||||
elif isinstance(msg["content"], list):
|
elif isinstance(msg["content"], list):
|
||||||
for content in msg["content"]:
|
for content in msg["content"]:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str) and content:
|
||||||
parts.append({"text": content})
|
parts.append({"text": content})
|
||||||
elif isinstance(content, dict):
|
elif isinstance(content, dict):
|
||||||
if content["type"] == "text":
|
if content["type"] == "text" and content["text"]:
|
||||||
parts.append({"text": content["text"]})
|
parts.append({"text": content["text"]})
|
||||||
elif content["type"] == "image_url":
|
elif content["type"] == "image_url":
|
||||||
parts.append(_convert_image(content["image_url"]["url"]))
|
parts.append(_convert_image(content["image_url"]["url"]))
|
||||||
|
|
||||||
converted_messages.append({"role": role, "parts": parts})
|
if parts:
|
||||||
|
if role == "system":
|
||||||
|
system_instruction_parts.extend(parts)
|
||||||
|
else:
|
||||||
|
converted_messages.append({"role": role, "parts": parts})
|
||||||
|
|
||||||
return converted_messages
|
system_instruction = (
|
||||||
|
None
|
||||||
|
if not system_instruction_parts
|
||||||
|
else {
|
||||||
|
"role": "system",
|
||||||
|
"parts": system_instruction_parts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return converted_messages, system_instruction
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
# app/services/chat/response_handler.py
|
# app/services/chat/response_handler.py
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import string
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.uploader import ImageUploaderFactory
|
||||||
|
|
||||||
|
|
||||||
class ResponseHandler(ABC):
|
class ResponseHandler(ABC):
|
||||||
@@ -29,40 +34,38 @@ class GeminiResponseHandler(ResponseHandler):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=True)
|
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
|
||||||
|
if not text and not tool_calls:
|
||||||
|
delta = {}
|
||||||
|
else:
|
||||||
|
delta = {"content": text, "role": "assistant"}
|
||||||
|
if tool_calls:
|
||||||
|
delta["tool_calls"] = tool_calls
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [{
|
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
||||||
"index": 0,
|
|
||||||
"delta": {"content": text} if text else {},
|
|
||||||
"finish_reason": finish_reason
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=False)
|
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
|
||||||
return {
|
return {
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [{
|
"choices": [
|
||||||
"index": 0,
|
{
|
||||||
"message": {
|
"index": 0,
|
||||||
"role": "assistant",
|
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
|
||||||
"content": text
|
"finish_reason": finish_reason,
|
||||||
},
|
}
|
||||||
"finish_reason": finish_reason
|
],
|
||||||
}],
|
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 0,
|
|
||||||
"completion_tokens": 0,
|
|
||||||
"total_tokens": 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -127,74 +130,15 @@ def _handle_openai_normal_image_response(image_str: str,model: str,finish_reason
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) -> str:
|
def _extract_result(response: Dict[str, Any], model: str, stream: bool = False, gemini_format: bool = False) -> tuple[str, List[Dict[str, Any]]]:
|
||||||
text = ""
|
text, tool_calls = "", []
|
||||||
if stream:
|
if stream:
|
||||||
if response.get("candidates"):
|
if response.get("candidates"):
|
||||||
candidate = response["candidates"][0]
|
candidate = response["candidates"][0]
|
||||||
content = candidate.get("content", {})
|
content = candidate.get("content", {})
|
||||||
parts = content.get("parts", [])
|
parts = content.get("parts", [])
|
||||||
# if "thinking" in model:
|
if not parts:
|
||||||
# if settings.SHOW_THINKING_PROCESS:
|
return "", []
|
||||||
# if len(parts) == 1:
|
|
||||||
# if self.thinking_first:
|
|
||||||
# self.thinking_first = False
|
|
||||||
# self.thinking_status = True
|
|
||||||
# text = "> thinking\n\n" + parts[0].get("text")
|
|
||||||
# else:
|
|
||||||
# text = parts[0].get("text")
|
|
||||||
|
|
||||||
# if len(parts) == 2:
|
|
||||||
# self.thinking_status = False
|
|
||||||
# if self.thinking_first:
|
|
||||||
# self.thinking_first = False
|
|
||||||
# text = (
|
|
||||||
# "> thinking\n\n"
|
|
||||||
# + parts[0].get("text")
|
|
||||||
# + "\n\n---\n> output\n\n"
|
|
||||||
# + parts[1].get("text")
|
|
||||||
# )
|
|
||||||
# else:
|
|
||||||
# text = (
|
|
||||||
# parts[0].get("text")
|
|
||||||
# + "\n\n---\n> output\n\n"
|
|
||||||
# + parts[1].get("text")
|
|
||||||
# )
|
|
||||||
# else:
|
|
||||||
# if len(parts) == 1:
|
|
||||||
# if self.thinking_first:
|
|
||||||
# self.thinking_first = False
|
|
||||||
# self.thinking_status = True
|
|
||||||
# text = ""
|
|
||||||
# elif self.thinking_status:
|
|
||||||
# text = ""
|
|
||||||
# else:
|
|
||||||
# text = parts[0].get("text")
|
|
||||||
|
|
||||||
# if len(parts) == 2:
|
|
||||||
# self.thinking_status = False
|
|
||||||
# if self.thinking_first:
|
|
||||||
# self.thinking_first = False
|
|
||||||
# text = parts[1].get("text")
|
|
||||||
# else:
|
|
||||||
# text = parts[1].get("text")
|
|
||||||
# else:
|
|
||||||
# if "text" in parts[0]:
|
|
||||||
# text = parts[0].get("text")
|
|
||||||
# elif "executableCode" in parts[0]:
|
|
||||||
# text = _format_code_block(parts[0]["executableCode"])
|
|
||||||
# elif "codeExecution" in parts[0]:
|
|
||||||
# text = _format_code_block(parts[0]["codeExecution"])
|
|
||||||
# elif "executableCodeResult" in parts[0]:
|
|
||||||
# text = _format_execution_result(
|
|
||||||
# parts[0]["executableCodeResult"]
|
|
||||||
# )
|
|
||||||
# elif "codeExecutionResult" in parts[0]:
|
|
||||||
# text = _format_execution_result(
|
|
||||||
# parts[0]["codeExecutionResult"]
|
|
||||||
# )
|
|
||||||
# else:
|
|
||||||
# text = ""
|
|
||||||
if "text" in parts[0]:
|
if "text" in parts[0]:
|
||||||
text = parts[0].get("text")
|
text = parts[0].get("text")
|
||||||
elif "executableCode" in parts[0]:
|
elif "executableCode" in parts[0]:
|
||||||
@@ -209,9 +153,12 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
|
|||||||
text = _format_execution_result(
|
text = _format_execution_result(
|
||||||
parts[0]["codeExecutionResult"]
|
parts[0]["codeExecutionResult"]
|
||||||
)
|
)
|
||||||
|
elif "inlineData" in parts[0]:
|
||||||
|
text = _extract_image_data(parts[0])
|
||||||
else:
|
else:
|
||||||
text = ""
|
text = ""
|
||||||
text = _add_search_link_text(model, candidate, text)
|
text = _add_search_link_text(model, candidate, text)
|
||||||
|
tool_calls = _extract_tool_calls(parts, gemini_format)
|
||||||
else:
|
else:
|
||||||
if response.get("candidates"):
|
if response.get("candidates"):
|
||||||
candidate = response["candidates"][0]
|
candidate = response["candidates"][0]
|
||||||
@@ -233,24 +180,90 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
|
|||||||
text = candidate["content"]["parts"][0]["text"]
|
text = candidate["content"]["parts"][0]["text"]
|
||||||
else:
|
else:
|
||||||
text = ""
|
text = ""
|
||||||
for part in candidate["content"]["parts"]:
|
if "parts" in candidate["content"]:
|
||||||
text += part["text"]
|
for part in candidate["content"]["parts"]:
|
||||||
|
if "text" in part:
|
||||||
|
text += part["text"]
|
||||||
|
elif "inlineData" in part:
|
||||||
|
text += _extract_image_data(part)
|
||||||
|
|
||||||
|
|
||||||
text = _add_search_link_text(model, candidate, text)
|
text = _add_search_link_text(model, candidate, text)
|
||||||
|
tool_calls = _extract_tool_calls(candidate["content"]["parts"], gemini_format)
|
||||||
else:
|
else:
|
||||||
text = "暂无返回"
|
text = "暂无返回"
|
||||||
|
return text, tool_calls
|
||||||
|
|
||||||
|
def _extract_image_data(part: dict) -> str:
|
||||||
|
image_uploader = None
|
||||||
|
if settings.UPLOAD_PROVIDER == "smms":
|
||||||
|
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,api_key=settings.SMMS_SECRET_TOKEN)
|
||||||
|
elif settings.UPLOAD_PROVIDER == "picgo":
|
||||||
|
image_uploader = ImageUploaderFactory.create(provider=settings.UPLOAD_PROVIDER,api_key=settings.PICGO_API_KEY)
|
||||||
|
current_date = time.strftime("%Y/%m/%d")
|
||||||
|
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||||
|
base64_data = part["inlineData"]["data"]
|
||||||
|
#将base64_data转成bytes数组
|
||||||
|
bytes_data = base64.b64decode(base64_data)
|
||||||
|
upload_response = image_uploader.upload(bytes_data,filename)
|
||||||
|
if upload_response.success:
|
||||||
|
text = f"\n\n"
|
||||||
|
else:
|
||||||
|
text = ""
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> List[Dict[str, Any]]:
|
||||||
|
"""提取工具调用信息"""
|
||||||
|
if not parts or not isinstance(parts, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
letters = string.ascii_lowercase + string.digits
|
||||||
|
|
||||||
|
tool_calls = list()
|
||||||
|
for i in range(len(parts)):
|
||||||
|
part = parts[i]
|
||||||
|
if not part or not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = part.get("functionCall", {})
|
||||||
|
if not item or not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if gemini_format:
|
||||||
|
tool_calls.append(part)
|
||||||
|
else:
|
||||||
|
id = f"call_{''.join(random.sample(letters, 32))}"
|
||||||
|
name = item.get("name", "")
|
||||||
|
arguments = json.dumps(item.get("args", None) or {})
|
||||||
|
|
||||||
|
tool_calls.append(
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": name, "arguments": arguments},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return tool_calls
|
||||||
|
|
||||||
|
|
||||||
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=stream)
|
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
||||||
content = {"parts": [{"text": text}], "role": "model"}
|
if tool_calls:
|
||||||
|
content = {"parts": tool_calls, "role": "model"}
|
||||||
|
else:
|
||||||
|
content = {"parts": [{"text": text}], "role": "model"}
|
||||||
response["candidates"][0]["content"] = content
|
response["candidates"][0]["content"] = content
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=stream)
|
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
||||||
content = {"parts": [{"text": text}], "role": "model"}
|
if tool_calls:
|
||||||
|
content = {"parts": tool_calls, "role": "model"}
|
||||||
|
else:
|
||||||
|
content = {"parts": [{"text": text}], "role": "model"}
|
||||||
response["candidates"][0]["content"] = content
|
response["candidates"][0]["content"] = content
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
132
app/services/chat/stream_optimizer.py
Normal file
132
app/services/chat/stream_optimizer.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# app/services/chat/stream_optimizer.py
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
from typing import Any, List, AsyncGenerator, Callable
|
||||||
|
from app.core.logger import get_openai_logger, get_gemini_logger
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger_openai = get_openai_logger()
|
||||||
|
logger_gemini = get_gemini_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class StreamOptimizer:
|
||||||
|
"""流式输出优化器
|
||||||
|
|
||||||
|
提供流式输出优化功能,包括智能延迟调整和长文本分块输出。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
logger=None,
|
||||||
|
min_delay: float = 0.016,
|
||||||
|
max_delay: float = 0.024,
|
||||||
|
short_text_threshold: int = 10,
|
||||||
|
long_text_threshold: int = 50,
|
||||||
|
chunk_size: int = 5):
|
||||||
|
"""初始化流式输出优化器
|
||||||
|
|
||||||
|
参数:
|
||||||
|
logger: 日志记录器
|
||||||
|
min_delay: 最小延迟时间(秒)
|
||||||
|
max_delay: 最大延迟时间(秒)
|
||||||
|
short_text_threshold: 短文本阈值(字符数)
|
||||||
|
long_text_threshold: 长文本阈值(字符数)
|
||||||
|
chunk_size: 长文本分块大小(字符数)
|
||||||
|
"""
|
||||||
|
self.logger = logger
|
||||||
|
self.min_delay = min_delay
|
||||||
|
self.max_delay = max_delay
|
||||||
|
self.short_text_threshold = short_text_threshold
|
||||||
|
self.long_text_threshold = long_text_threshold
|
||||||
|
self.chunk_size = chunk_size
|
||||||
|
|
||||||
|
def calculate_delay(self, text_length: int) -> float:
|
||||||
|
"""根据文本长度计算延迟时间
|
||||||
|
|
||||||
|
参数:
|
||||||
|
text_length: 文本长度
|
||||||
|
|
||||||
|
返回:
|
||||||
|
延迟时间(秒)
|
||||||
|
"""
|
||||||
|
if text_length <= self.short_text_threshold:
|
||||||
|
# 短文本使用较大延迟
|
||||||
|
return self.max_delay
|
||||||
|
elif text_length >= self.long_text_threshold:
|
||||||
|
# 长文本使用较小延迟
|
||||||
|
return self.min_delay
|
||||||
|
else:
|
||||||
|
# 中等长度文本使用线性插值计算延迟
|
||||||
|
# 使用对数函数使延迟变化更平滑
|
||||||
|
ratio = math.log(text_length / self.short_text_threshold) / math.log(self.long_text_threshold / self.short_text_threshold)
|
||||||
|
return self.max_delay - ratio * (self.max_delay - self.min_delay)
|
||||||
|
|
||||||
|
def split_text_into_chunks(self, text: str) -> List[str]:
|
||||||
|
"""将文本分割成小块
|
||||||
|
|
||||||
|
参数:
|
||||||
|
text: 要分割的文本
|
||||||
|
|
||||||
|
返回:
|
||||||
|
文本块列表
|
||||||
|
"""
|
||||||
|
return [text[i:i+self.chunk_size] for i in range(0, len(text), self.chunk_size)]
|
||||||
|
|
||||||
|
async def optimize_stream_output(self,
|
||||||
|
text: str,
|
||||||
|
create_response_chunk: Callable[[str], Any],
|
||||||
|
format_chunk: Callable[[Any], str]) -> AsyncGenerator[str, None]:
|
||||||
|
"""优化流式输出
|
||||||
|
|
||||||
|
参数:
|
||||||
|
text: 要输出的文本
|
||||||
|
create_response_chunk: 创建响应块的函数,接收文本,返回响应块
|
||||||
|
format_chunk: 格式化响应块的函数,接收响应块,返回格式化后的字符串
|
||||||
|
|
||||||
|
返回:
|
||||||
|
异步生成器,生成格式化后的响应块
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 计算智能延迟时间
|
||||||
|
delay = self.calculate_delay(len(text))
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"Text length: {len(text)}, delay: {delay:.4f}s")
|
||||||
|
|
||||||
|
# 根据文本长度决定输出方式
|
||||||
|
if len(text) >= self.long_text_threshold:
|
||||||
|
# 长文本:分块输出
|
||||||
|
chunks = self.split_text_into_chunks(text)
|
||||||
|
if self.logger:
|
||||||
|
self.logger.info(f"Long text: splitting into {len(chunks)} chunks")
|
||||||
|
for chunk_text in chunks:
|
||||||
|
chunk_response = create_response_chunk(chunk_text)
|
||||||
|
yield format_chunk(chunk_response)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
else:
|
||||||
|
# 短文本:逐字符输出
|
||||||
|
for char in text:
|
||||||
|
char_chunk = create_response_chunk(char)
|
||||||
|
yield format_chunk(char_chunk)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
|
||||||
|
# 创建默认的优化器实例,可以直接导入使用
|
||||||
|
openai_optimizer = StreamOptimizer(
|
||||||
|
logger=logger_openai,
|
||||||
|
min_delay=settings.STREAM_MIN_DELAY,
|
||||||
|
max_delay=settings.STREAM_MAX_DELAY,
|
||||||
|
short_text_threshold=settings.STREAM_SHORT_TEXT_THRESHOLD,
|
||||||
|
long_text_threshold=settings.STREAM_LONG_TEXT_THRESHOLD,
|
||||||
|
chunk_size=settings.STREAM_CHUNK_SIZE
|
||||||
|
)
|
||||||
|
|
||||||
|
gemini_optimizer = StreamOptimizer(
|
||||||
|
logger=logger_gemini,
|
||||||
|
min_delay=settings.STREAM_MIN_DELAY,
|
||||||
|
max_delay=settings.STREAM_MAX_DELAY,
|
||||||
|
short_text_threshold=settings.STREAM_SHORT_TEXT_THRESHOLD,
|
||||||
|
long_text_threshold=settings.STREAM_LONG_TEXT_THRESHOLD,
|
||||||
|
chunk_size=settings.STREAM_CHUNK_SIZE
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
from typing import Dict, Any, AsyncGenerator, List
|
from typing import Dict, Any, AsyncGenerator, List
|
||||||
from app.core.logger import get_gemini_logger
|
from app.core.logger import get_gemini_logger
|
||||||
from app.services.chat.api_client import GeminiApiClient
|
from app.services.chat.api_client import GeminiApiClient
|
||||||
|
from app.services.chat.stream_optimizer import gemini_optimizer
|
||||||
from app.schemas.gemini_models import GeminiRequest
|
from app.schemas.gemini_models import GeminiRequest
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.chat.response_handler import GeminiResponseHandler
|
from app.services.chat.response_handler import GeminiResponseHandler
|
||||||
@@ -31,6 +32,12 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
tools.append({"code_execution": {}})
|
tools.append({"code_execution": {}})
|
||||||
if model.endswith("-search"):
|
if model.endswith("-search"):
|
||||||
tools.append({"googleSearch": {}})
|
tools.append({"googleSearch": {}})
|
||||||
|
|
||||||
|
if payload and isinstance(payload, dict) and "tools" in payload:
|
||||||
|
items = payload.get("tools", [])
|
||||||
|
if items and isinstance(items, list):
|
||||||
|
tools.extend(items)
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
@@ -55,14 +62,19 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
|||||||
|
|
||||||
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
||||||
"""构建请求payload"""
|
"""构建请求payload"""
|
||||||
payload = request.model_dump()
|
request_dict = request.model_dump()
|
||||||
return {
|
payload = {
|
||||||
"contents": payload.get("contents", []),
|
"contents": request_dict.get("contents", []),
|
||||||
"tools": _build_tools(model, payload),
|
"tools": _build_tools(model, request_dict),
|
||||||
"safetySettings": _get_safety_settings(model),
|
"safetySettings": _get_safety_settings(model),
|
||||||
"generationConfig": payload.get("generationConfig", {}),
|
"generationConfig": request_dict.get("generationConfig", {}),
|
||||||
"systemInstruction": payload.get("systemInstruction", [])
|
"systemInstruction": request_dict.get("systemInstruction", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if model.endswith("-image"):
|
||||||
|
payload.pop("systemInstruction")
|
||||||
|
payload["generationConfig"]["responseModalities"] = ["Text","Image"]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
class GeminiChatService:
|
class GeminiChatService:
|
||||||
@@ -72,11 +84,31 @@ class GeminiChatService:
|
|||||||
self.api_client = GeminiApiClient(base_url)
|
self.api_client = GeminiApiClient(base_url)
|
||||||
self.key_manager = key_manager
|
self.key_manager = key_manager
|
||||||
self.response_handler = GeminiResponseHandler()
|
self.response_handler = GeminiResponseHandler()
|
||||||
|
|
||||||
|
def _extract_text_from_response(self, response: Dict[str, Any]) -> str:
|
||||||
|
"""从响应中提取文本内容"""
|
||||||
|
if not response.get("candidates"):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
candidate = response["candidates"][0]
|
||||||
|
content = candidate.get("content", {})
|
||||||
|
parts = content.get("parts", [])
|
||||||
|
|
||||||
|
if parts and "text" in parts[0]:
|
||||||
|
return parts[0].get("text", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _create_char_response(self, original_response: Dict[str, Any], text: str) -> Dict[str, Any]:
|
||||||
|
"""创建包含指定文本的响应"""
|
||||||
|
response_copy = json.loads(json.dumps(original_response)) # 深拷贝
|
||||||
|
if response_copy.get("candidates") and response_copy["candidates"][0].get("content", {}).get("parts"):
|
||||||
|
response_copy["candidates"][0]["content"]["parts"][0]["text"] = text
|
||||||
|
return response_copy
|
||||||
|
|
||||||
def generate_content(self, model: str, request: GeminiRequest, api_key: str) -> Dict[str, Any]:
|
async def generate_content(self, model: str, request: GeminiRequest, api_key: str) -> Dict[str, Any]:
|
||||||
"""生成内容"""
|
"""生成内容"""
|
||||||
payload = _build_payload(model, request)
|
payload = _build_payload(model, request)
|
||||||
response = self.api_client.generate_content(payload, model, api_key)
|
response = await self.api_client.generate_content(payload, model, api_key)
|
||||||
return self.response_handler.handle_response(response, model, stream=False)
|
return self.response_handler.handle_response(response, model, stream=False)
|
||||||
|
|
||||||
async def stream_generate_content(self, model: str, request: GeminiRequest, api_key: str) -> AsyncGenerator[str, None]:
|
async def stream_generate_content(self, model: str, request: GeminiRequest, api_key: str) -> AsyncGenerator[str, None]:
|
||||||
@@ -90,8 +122,21 @@ class GeminiChatService:
|
|||||||
# print(line)
|
# print(line)
|
||||||
if line.startswith("data:"):
|
if line.startswith("data:"):
|
||||||
line = line[6:]
|
line = line[6:]
|
||||||
line = json.dumps(self.response_handler.handle_response(json.loads(line), model, stream=True))
|
response_data = self.response_handler.handle_response(json.loads(line), model, stream=True)
|
||||||
yield "data: " + line + "\n\n"
|
text = self._extract_text_from_response(response_data)
|
||||||
|
|
||||||
|
# 如果有文本内容,使用流式输出优化器处理
|
||||||
|
if text:
|
||||||
|
# 使用流式输出优化器处理文本输出
|
||||||
|
async for optimized_chunk in gemini_optimizer.optimize_stream_output(
|
||||||
|
text,
|
||||||
|
lambda t: self._create_char_response(response_data, t),
|
||||||
|
lambda c: "data: " + json.dumps(c) + "\n\n"
|
||||||
|
):
|
||||||
|
yield optimized_chunk
|
||||||
|
else:
|
||||||
|
# 如果没有文本内容(如工具调用等),整块输出
|
||||||
|
yield "data: " + json.dumps(response_data) + "\n\n"
|
||||||
logger.info("Streaming completed successfully")
|
logger.info("Streaming completed successfully")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ from app.core.config import settings
|
|||||||
logger = get_model_logger()
|
logger = get_model_logger()
|
||||||
|
|
||||||
class ModelService:
|
class ModelService:
|
||||||
def __init__(self, model_search: list):
|
def __init__(self, model_search: list, model_image: list):
|
||||||
self.model_search = model_search
|
self.model_search = model_search
|
||||||
|
self.model_image = model_image
|
||||||
self.base_url = "https://generativelanguage.googleapis.com/v1beta"
|
self.base_url = "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
|
||||||
def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
def get_gemini_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||||
@@ -57,9 +58,27 @@ class ModelService:
|
|||||||
search_model = openai_model.copy()
|
search_model = openai_model.copy()
|
||||||
search_model["id"] = f"{model_id}-search"
|
search_model["id"] = f"{model_id}-search"
|
||||||
openai_format["data"].append(search_model)
|
openai_format["data"].append(search_model)
|
||||||
|
if model_id in self.model_image:
|
||||||
|
image_model = openai_model.copy()
|
||||||
|
image_model["id"] = f"{model_id}-image"
|
||||||
|
openai_format["data"].append(image_model)
|
||||||
|
|
||||||
if settings.CREATE_IMAGE_MODEL:
|
if settings.CREATE_IMAGE_MODEL:
|
||||||
image_model = openai_model.copy()
|
image_model = openai_model.copy()
|
||||||
image_model["id"] = f"{settings.CREATE_IMAGE_MODEL}-chat"
|
image_model["id"] = f"{settings.CREATE_IMAGE_MODEL}-chat"
|
||||||
openai_format["data"].append(image_model)
|
openai_format["data"].append(image_model)
|
||||||
return openai_format
|
return openai_format
|
||||||
|
|
||||||
|
def check_model_support(self, model: str) -> bool:
|
||||||
|
if not model or not isinstance(model, str):
|
||||||
|
return False
|
||||||
|
|
||||||
|
model = model.strip()
|
||||||
|
if model.endswith("-search"):
|
||||||
|
model = model[:-7]
|
||||||
|
return model in settings.MODEL_SEARCH
|
||||||
|
if model.endswith("-image"):
|
||||||
|
model = model[:-6]
|
||||||
|
return model in settings.MODEL_IMAGE
|
||||||
|
|
||||||
|
return True
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
# app/services/chat_service.py
|
# app/services/chat_service.py
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, AsyncGenerator, List, Union
|
from typing import Dict, Any, AsyncGenerator, List, Optional, Union
|
||||||
from app.core.logger import get_openai_logger
|
from app.core.logger import get_openai_logger
|
||||||
from app.services.chat.message_converter import OpenAIMessageConverter
|
from app.services.chat.message_converter import OpenAIMessageConverter
|
||||||
from app.services.chat.response_handler import OpenAIResponseHandler
|
from app.services.chat.response_handler import OpenAIResponseHandler
|
||||||
from app.services.chat.api_client import GeminiApiClient
|
from app.services.chat.api_client import GeminiApiClient
|
||||||
|
from app.services.chat.stream_optimizer import openai_optimizer
|
||||||
from app.schemas.openai_models import ChatRequest, ImageGenerationRequest
|
from app.schemas.openai_models import ChatRequest, ImageGenerationRequest
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.image_create_service import ImageCreateService
|
from app.services.image_create_service import ImageCreateService
|
||||||
@@ -33,12 +35,38 @@ def _build_tools(
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||||
and not (model.endswith("-search") or "-thinking" in model)
|
and not (model.endswith("-search") or "-thinking" in model or model.endswith("-image"))
|
||||||
and not _has_image_parts(messages)
|
and not _has_image_parts(messages)
|
||||||
):
|
):
|
||||||
tools.append({"code_execution": {}})
|
tools.append({"code_execution": {}})
|
||||||
if model.endswith("-search"):
|
if model.endswith("-search"):
|
||||||
tools.append({"googleSearch": {}})
|
tools.append({"googleSearch": {}})
|
||||||
|
|
||||||
|
# 将 request 中的 tools 合并到 tools 中
|
||||||
|
if request.tools:
|
||||||
|
function_declarations = []
|
||||||
|
for tool in request.tools:
|
||||||
|
if not tool or not isinstance(tool, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tool.get("type", "") == "function" and tool.get("function"):
|
||||||
|
function = deepcopy(tool.get("function"))
|
||||||
|
parameters = function.get("parameters", {})
|
||||||
|
if parameters.get("type") == "object" and not parameters.get("properties", {}):
|
||||||
|
function.pop("parameters", None)
|
||||||
|
|
||||||
|
function_declarations.append(function)
|
||||||
|
|
||||||
|
if function_declarations:
|
||||||
|
# 按照 function 的 name 去重
|
||||||
|
names, functions = set(), []
|
||||||
|
for item in function_declarations:
|
||||||
|
if item.get("name") not in names:
|
||||||
|
names.add(item.get("name"))
|
||||||
|
functions.append(item)
|
||||||
|
|
||||||
|
tools.append({"functionDeclarations": functions})
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
@@ -67,10 +95,10 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def _build_payload(
|
def _build_payload(
|
||||||
request: ChatRequest, messages: List[Dict[str, Any]]
|
request: ChatRequest, messages: List[Dict[str, Any]], instruction: Optional[Dict[str, Any]] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""构建请求payload"""
|
"""构建请求payload"""
|
||||||
return {
|
payload = {
|
||||||
"contents": messages,
|
"contents": messages,
|
||||||
"generationConfig": {
|
"generationConfig": {
|
||||||
"temperature": request.temperature,
|
"temperature": request.temperature,
|
||||||
@@ -82,6 +110,19 @@ def _build_payload(
|
|||||||
"tools": _build_tools(request, messages),
|
"tools": _build_tools(request, messages),
|
||||||
"safetySettings": _get_safety_settings(request.model),
|
"safetySettings": _get_safety_settings(request.model),
|
||||||
}
|
}
|
||||||
|
if request.model.endswith("-image"):
|
||||||
|
payload["generationConfig"]["responseModalities"] = ["Text","Image"]
|
||||||
|
|
||||||
|
if (
|
||||||
|
instruction
|
||||||
|
and isinstance(instruction, dict)
|
||||||
|
and instruction.get("role") == "system"
|
||||||
|
and instruction.get("parts")
|
||||||
|
and not request.model.endswith("-image")
|
||||||
|
):
|
||||||
|
payload["systemInstruction"] = instruction
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
class OpenAIChatService:
|
class OpenAIChatService:
|
||||||
@@ -92,6 +133,23 @@ class OpenAIChatService:
|
|||||||
self.api_client = GeminiApiClient(base_url)
|
self.api_client = GeminiApiClient(base_url)
|
||||||
self.key_manager = key_manager
|
self.key_manager = key_manager
|
||||||
self.image_create_service = ImageCreateService()
|
self.image_create_service = ImageCreateService()
|
||||||
|
|
||||||
|
def _extract_text_from_openai_chunk(self, chunk: Dict[str, Any]) -> str:
|
||||||
|
"""从OpenAI响应块中提取文本内容"""
|
||||||
|
if not chunk.get("choices"):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
choice = chunk["choices"][0]
|
||||||
|
if "delta" in choice and "content" in choice["delta"]:
|
||||||
|
return choice["delta"]["content"]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _create_char_openai_chunk(self, original_chunk: Dict[str, Any], text: str) -> Dict[str, Any]:
|
||||||
|
"""创建包含指定文本的OpenAI响应块"""
|
||||||
|
chunk_copy = json.loads(json.dumps(original_chunk)) # 深拷贝
|
||||||
|
if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]:
|
||||||
|
chunk_copy["choices"][0]["delta"]["content"] = text
|
||||||
|
return chunk_copy
|
||||||
|
|
||||||
async def create_chat_completion(
|
async def create_chat_completion(
|
||||||
self,
|
self,
|
||||||
@@ -100,20 +158,20 @@ class OpenAIChatService:
|
|||||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||||
"""创建聊天完成"""
|
"""创建聊天完成"""
|
||||||
# 转换消息格式
|
# 转换消息格式
|
||||||
messages = self.message_converter.convert(request.messages)
|
messages, instruction = self.message_converter.convert(request.messages)
|
||||||
|
|
||||||
# 构建请求payload
|
# 构建请求payload
|
||||||
payload = _build_payload(request, messages)
|
payload = _build_payload(request, messages, instruction)
|
||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
return self._handle_stream_completion(request.model, payload, api_key)
|
return self._handle_stream_completion(request.model, payload, api_key)
|
||||||
return self._handle_normal_completion(request.model, payload, api_key)
|
return await self._handle_normal_completion(request.model, payload, api_key)
|
||||||
|
|
||||||
def _handle_normal_completion(
|
async def _handle_normal_completion(
|
||||||
self, model: str, payload: Dict[str, Any], api_key: str
|
self, model: str, payload: Dict[str, Any], api_key: str
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""处理普通聊天完成"""
|
"""处理普通聊天完成"""
|
||||||
response = self.api_client.generate_content(payload, model, api_key)
|
response = await self.api_client.generate_content(payload, model, api_key)
|
||||||
return self.response_handler.handle_response(
|
return self.response_handler.handle_response(
|
||||||
response, model, stream=False, finish_reason="stop"
|
response, model, stream=False, finish_reason="stop"
|
||||||
)
|
)
|
||||||
@@ -136,7 +194,19 @@ class OpenAIChatService:
|
|||||||
chunk, model, stream=True, finish_reason=None
|
chunk, model, stream=True, finish_reason=None
|
||||||
)
|
)
|
||||||
if openai_chunk:
|
if openai_chunk:
|
||||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
# 提取文本内容
|
||||||
|
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||||
|
if text:
|
||||||
|
# 使用流式输出优化器处理文本输出
|
||||||
|
async for optimized_chunk in openai_optimizer.optimize_stream_output(
|
||||||
|
text,
|
||||||
|
lambda t: self._create_char_openai_chunk(openai_chunk, t),
|
||||||
|
lambda c: f"data: {json.dumps(c)}\n\n"
|
||||||
|
):
|
||||||
|
yield optimized_chunk
|
||||||
|
else:
|
||||||
|
# 如果没有文本内容(如工具调用等),整块输出
|
||||||
|
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
logger.info("Streaming completed successfully")
|
logger.info("Streaming completed successfully")
|
||||||
@@ -178,7 +248,19 @@ class OpenAIChatService:
|
|||||||
image_data, model, stream=True, finish_reason=None
|
image_data, model, stream=True, finish_reason=None
|
||||||
)
|
)
|
||||||
if openai_chunk:
|
if openai_chunk:
|
||||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
# 提取文本内容
|
||||||
|
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||||
|
if text:
|
||||||
|
# 使用流式输出优化器处理文本输出
|
||||||
|
async for optimized_chunk in openai_optimizer.optimize_stream_output(
|
||||||
|
text,
|
||||||
|
lambda t: self._create_char_openai_chunk(openai_chunk, t),
|
||||||
|
lambda c: f"data: {json.dumps(c)}\n\n"
|
||||||
|
):
|
||||||
|
yield optimized_chunk
|
||||||
|
else:
|
||||||
|
# 如果没有文本内容(如图片URL等),整块输出
|
||||||
|
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
logger.info("Image chat streaming completed successfully")
|
logger.info("Image chat streaming completed successfully")
|
||||||
@@ -189,4 +271,4 @@ class OpenAIChatService:
|
|||||||
|
|
||||||
return self.response_handler.handle_image_chat_response(
|
return self.response_handler.handle_image_chat_response(
|
||||||
image_data, model, stream=False, finish_reason="stop"
|
image_data, model, stream=False, finish_reason="stop"
|
||||||
)
|
)
|
||||||
|
|||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
gemini-balance:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
Reference in New Issue
Block a user