mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-09-06 16:16:37 +08:00
feat(image): 支持多模态模型输入base64格式图片
- 在消息转换中,增加对 `data:image/png;base64,...` 格式图片的支持,允许用户直接在输入中提供base64编码的图片。 - 调整图片处理逻辑,使其能够根据模型名称判断是否启用多模态能力,避免非多模态模型错误处理图片链接。 - 当未配置图床时,模型输出的图片将回退为base64格式,确保图片内容始终可用。 - 优化了相关函数的参数传递和代码格式,提高了代码的可读性和健壮性。
This commit is contained in:
@@ -27,7 +27,7 @@ class MessageConverter(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def convert(
|
def convert(
|
||||||
self, messages: List[Dict[str, Any]]
|
self, messages: List[Dict[str, Any]], model: str
|
||||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ def _convert_image_to_base64(url: str) -> str:
|
|||||||
raise Exception(f"Failed to fetch image: {response.status_code}")
|
raise Exception(f"Failed to fetch image: {response.status_code}")
|
||||||
|
|
||||||
|
|
||||||
def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
def _process_text_with_image(text: str, model: str) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
处理可能包含图片URL的文本,提取图片并转换为base64
|
处理可能包含图片URL的文本,提取图片并转换为base64
|
||||||
|
|
||||||
@@ -94,17 +94,31 @@ def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
|||||||
Returns:
|
Returns:
|
||||||
List[Dict[str, Any]]: 包含文本和图片的部分列表
|
List[Dict[str, Any]]: 包含文本和图片的部分列表
|
||||||
"""
|
"""
|
||||||
|
# 如果模型名中没有包含image,当作普通文本处理
|
||||||
|
if "image" not in model:
|
||||||
|
return [{"text": text}]
|
||||||
parts = []
|
parts = []
|
||||||
img_url_match = re.search(IMAGE_URL_PATTERN, text)
|
img_url_match = re.search(IMAGE_URL_PATTERN, text)
|
||||||
if img_url_match:
|
if img_url_match:
|
||||||
# 提取URL
|
# 提取URL
|
||||||
img_url = img_url_match.group(2)
|
img_url = img_url_match.group(2)
|
||||||
# 将URL对应的图片转换为base64
|
# 先判断是否是base64url如果是,直接用,不过不是,再将URL对应的图片转换为base64
|
||||||
try:
|
try:
|
||||||
base64_data = _convert_image_to_base64(img_url)
|
base64_url_match = re.search(DATA_URL_PATTERN, img_url)
|
||||||
parts.append(
|
if base64_url_match:
|
||||||
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
|
parts.append(
|
||||||
)
|
{
|
||||||
|
"inline_data": {
|
||||||
|
"mimeType": base64_url_match.group(1),
|
||||||
|
"data": base64_url_match.group(2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
base64_data = _convert_image_to_base64(img_url)
|
||||||
|
parts.append(
|
||||||
|
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# 如果转换失败,回退到文本模式
|
# 如果转换失败,回退到文本模式
|
||||||
parts.append({"text": text})
|
parts.append({"text": text})
|
||||||
@@ -145,7 +159,7 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def convert(
|
def convert(
|
||||||
self, messages: List[Dict[str, Any]]
|
self, messages: List[Dict[str, Any]], model: str
|
||||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
converted_messages = []
|
converted_messages = []
|
||||||
system_instruction_parts = []
|
system_instruction_parts = []
|
||||||
@@ -296,7 +310,7 @@ class OpenAIMessageConverter(MessageConverter):
|
|||||||
elif (
|
elif (
|
||||||
"content" in msg and isinstance(msg["content"], str) and msg["content"]
|
"content" in msg and isinstance(msg["content"], str) and msg["content"]
|
||||||
):
|
):
|
||||||
parts.extend(_process_text_with_image(msg["content"]))
|
parts.extend(_process_text_with_image(msg["content"], model))
|
||||||
elif "tool_calls" in msg and isinstance(msg["tool_calls"], list):
|
elif "tool_calls" in msg and isinstance(msg["tool_calls"], list):
|
||||||
# Keep existing tool call processing
|
# Keep existing tool call processing
|
||||||
for tool_call in msg["tool_calls"]:
|
for tool_call in msg["tool_calls"]:
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ from abc import ABC, abstractmethod
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from app.config.config import settings
|
from app.config.config import settings
|
||||||
from app.utils.uploader import ImageUploaderFactory
|
|
||||||
from app.log.logger import get_openai_logger
|
from app.log.logger import get_openai_logger
|
||||||
from app.utils.helpers import is_image_upload_configured
|
from app.utils.helpers import is_image_upload_configured
|
||||||
|
from app.utils.uploader import ImageUploaderFactory
|
||||||
|
|
||||||
logger = get_openai_logger()
|
logger = get_openai_logger()
|
||||||
|
|
||||||
@@ -33,7 +33,11 @@ class GeminiResponseHandler(ResponseHandler):
|
|||||||
self.thinking_status = False
|
self.thinking_status = False
|
||||||
|
|
||||||
def handle_response(
|
def handle_response(
|
||||||
self, response: Dict[str, Any], model: str, stream: bool = False, usage_metadata: Optional[Dict[str, Any]] = None
|
self,
|
||||||
|
response: Dict[str, Any],
|
||||||
|
model: str,
|
||||||
|
stream: bool = False,
|
||||||
|
usage_metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
if stream:
|
if stream:
|
||||||
return _handle_gemini_stream_response(response, model, stream)
|
return _handle_gemini_stream_response(response, model, stream)
|
||||||
@@ -41,7 +45,10 @@ class GeminiResponseHandler(ResponseHandler):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_openai_stream_response(
|
def _handle_openai_stream_response(
|
||||||
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
response: Dict[str, Any],
|
||||||
|
model: str,
|
||||||
|
finish_reason: str,
|
||||||
|
usage_metadata: Optional[Dict[str, Any]],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
choices = []
|
choices = []
|
||||||
candidates = response.get("candidates", [])
|
candidates = response.get("candidates", [])
|
||||||
@@ -55,15 +62,15 @@ def _handle_openai_stream_response(
|
|||||||
if not text and not tool_calls and not reasoning_content:
|
if not text and not tool_calls and not reasoning_content:
|
||||||
delta = {}
|
delta = {}
|
||||||
else:
|
else:
|
||||||
delta = {"content": text, "reasoning_content": reasoning_content, "role": "assistant"}
|
delta = {
|
||||||
|
"content": text,
|
||||||
|
"reasoning_content": reasoning_content,
|
||||||
|
"role": "assistant",
|
||||||
|
}
|
||||||
if tool_calls:
|
if tool_calls:
|
||||||
delta["tool_calls"] = tool_calls
|
delta["tool_calls"] = tool_calls
|
||||||
|
|
||||||
choice = {
|
choice = {"index": index, "delta": delta, "finish_reason": finish_reason}
|
||||||
"index": index,
|
|
||||||
"delta": delta,
|
|
||||||
"finish_reason": finish_reason
|
|
||||||
}
|
|
||||||
choices.append(choice)
|
choices.append(choice)
|
||||||
|
|
||||||
template_chunk = {
|
template_chunk = {
|
||||||
@@ -74,12 +81,19 @@ def _handle_openai_stream_response(
|
|||||||
"choices": choices,
|
"choices": choices,
|
||||||
}
|
}
|
||||||
if usage_metadata:
|
if usage_metadata:
|
||||||
template_chunk["usage"] = {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)}
|
template_chunk["usage"] = {
|
||||||
|
"prompt_tokens": usage_metadata.get("promptTokenCount", 0),
|
||||||
|
"completion_tokens": usage_metadata.get("candidatesTokenCount", 0),
|
||||||
|
"total_tokens": usage_metadata.get("totalTokenCount", 0),
|
||||||
|
}
|
||||||
return template_chunk
|
return template_chunk
|
||||||
|
|
||||||
|
|
||||||
def _handle_openai_normal_response(
|
def _handle_openai_normal_response(
|
||||||
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
response: Dict[str, Any],
|
||||||
|
model: str,
|
||||||
|
finish_reason: str,
|
||||||
|
usage_metadata: Optional[Dict[str, Any]],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
choices = []
|
choices = []
|
||||||
candidates = response.get("candidates", [])
|
candidates = response.get("candidates", [])
|
||||||
@@ -106,7 +120,11 @@ def _handle_openai_normal_response(
|
|||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": choices,
|
"choices": choices,
|
||||||
"usage": {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)},
|
"usage": {
|
||||||
|
"prompt_tokens": usage_metadata.get("promptTokenCount", 0),
|
||||||
|
"completion_tokens": usage_metadata.get("candidatesTokenCount", 0),
|
||||||
|
"total_tokens": usage_metadata.get("totalTokenCount", 0),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -127,8 +145,12 @@ class OpenAIResponseHandler(ResponseHandler):
|
|||||||
usage_metadata: Optional[Dict[str, Any]] = None,
|
usage_metadata: Optional[Dict[str, Any]] = None,
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if stream:
|
if stream:
|
||||||
return _handle_openai_stream_response(response, model, finish_reason, usage_metadata)
|
return _handle_openai_stream_response(
|
||||||
return _handle_openai_normal_response(response, model, finish_reason, usage_metadata)
|
response, model, finish_reason, usage_metadata
|
||||||
|
)
|
||||||
|
return _handle_openai_normal_response(
|
||||||
|
response, model, finish_reason, usage_metadata
|
||||||
|
)
|
||||||
|
|
||||||
def handle_image_chat_response(
|
def handle_image_chat_response(
|
||||||
self, image_str: str, model: str, stream=False, finish_reason="stop"
|
self, image_str: str, model: str, stream=False, finish_reason="stop"
|
||||||
@@ -264,10 +286,6 @@ def _has_inline_image_part(response: Dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_image_data(part: dict) -> str:
|
def _extract_image_data(part: dict) -> str:
|
||||||
# Return empty string if no uploader is configured
|
|
||||||
if not is_image_upload_configured():
|
|
||||||
return ""
|
|
||||||
|
|
||||||
image_uploader = None
|
image_uploader = None
|
||||||
if settings.UPLOAD_PROVIDER == "smms":
|
if settings.UPLOAD_PROVIDER == "smms":
|
||||||
image_uploader = ImageUploaderFactory.create(
|
image_uploader = ImageUploaderFactory.create(
|
||||||
@@ -287,13 +305,17 @@ def _extract_image_data(part: dict) -> str:
|
|||||||
current_date = time.strftime("%Y/%m/%d")
|
current_date = time.strftime("%Y/%m/%d")
|
||||||
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||||
base64_data = part["inlineData"]["data"]
|
base64_data = part["inlineData"]["data"]
|
||||||
|
mime_type = part["inlineData"]["mimeType"]
|
||||||
# 将base64_data转成bytes数组
|
# 将base64_data转成bytes数组
|
||||||
|
# Return empty string if no uploader is configured
|
||||||
|
if not is_image_upload_configured(settings):
|
||||||
|
return f"\n\n\n\n"
|
||||||
bytes_data = base64.b64decode(base64_data)
|
bytes_data = base64.b64decode(base64_data)
|
||||||
upload_response = image_uploader.upload(bytes_data, filename)
|
upload_response = image_uploader.upload(bytes_data, filename)
|
||||||
if upload_response.success:
|
if upload_response.success:
|
||||||
text = f"\n\n\n\n"
|
text = f"\n\n\n\n"
|
||||||
else:
|
else:
|
||||||
text = ""
|
text = f"\n\n\n\n"
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -339,7 +361,7 @@ def _handle_gemini_stream_response(
|
|||||||
response: Dict[str, Any], model: str, stream: bool
|
response: Dict[str, Any], model: str, stream: bool
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
# Early return raw Gemini response if no uploader configured and contains inline images
|
# Early return raw Gemini response if no uploader configured and contains inline images
|
||||||
if not is_image_upload_configured() and _has_inline_image_part(response):
|
if not is_image_upload_configured(settings) and _has_inline_image_part(response):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
text, reasoning_content, tool_calls, thought = _extract_result(
|
text, reasoning_content, tool_calls, thought = _extract_result(
|
||||||
@@ -360,7 +382,7 @@ def _handle_gemini_normal_response(
|
|||||||
response: Dict[str, Any], model: str, stream: bool
|
response: Dict[str, Any], model: str, stream: bool
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
# Early return raw Gemini response if no uploader configured and contains inline images
|
# Early return raw Gemini response if no uploader configured and contains inline images
|
||||||
if not is_image_upload_configured() and _has_inline_image_part(response):
|
if not is_image_upload_configured(settings) and _has_inline_image_part(response):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
text, reasoning_content, tool_calls, thought = _extract_result(
|
text, reasoning_content, tool_calls, thought = _extract_result(
|
||||||
@@ -371,7 +393,7 @@ def _handle_gemini_normal_response(
|
|||||||
parts = tool_calls
|
parts = tool_calls
|
||||||
else:
|
else:
|
||||||
if thought is not None:
|
if thought is not None:
|
||||||
parts.append({"text": reasoning_content,"thought": thought})
|
parts.append({"text": reasoning_content, "thought": thought})
|
||||||
part = {"text": text}
|
part = {"text": text}
|
||||||
parts.append(part)
|
parts.append(part)
|
||||||
content = {"parts": parts, "role": "model"}
|
content = {"parts": parts, "role": "model"}
|
||||||
|
|||||||
+28
-8
@@ -1,9 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import platform
|
import platform
|
||||||
import sys
|
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from app.utils.helpers import redact_key_for_logging as _redact_key_for_logging
|
|
||||||
|
|
||||||
# ANSI转义序列颜色代码
|
# ANSI转义序列颜色代码
|
||||||
COLORS = {
|
COLORS = {
|
||||||
@@ -15,7 +14,6 @@ COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Windows系统启用ANSI支持
|
# Windows系统启用ANSI支持
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
import ctypes
|
import ctypes
|
||||||
@@ -46,14 +44,16 @@ class AccessLogFormatter(logging.Formatter):
|
|||||||
|
|
||||||
# API key patterns to match in URLs
|
# API key patterns to match in URLs
|
||||||
API_KEY_PATTERNS = [
|
API_KEY_PATTERNS = [
|
||||||
r'\bAIza[0-9A-Za-z_-]{35}', # Google API keys (like Gemini)
|
r"\bAIza[0-9A-Za-z_-]{35}", # Google API keys (like Gemini)
|
||||||
r'\bsk-[0-9A-Za-z_-]{20,}', # OpenAI and general sk- prefixed keys
|
r"\bsk-[0-9A-Za-z_-]{20,}", # OpenAI and general sk- prefixed keys
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Compile regex patterns for better performance
|
# Compile regex patterns for better performance
|
||||||
self.compiled_patterns = [re.compile(pattern) for pattern in self.API_KEY_PATTERNS]
|
self.compiled_patterns = [
|
||||||
|
re.compile(pattern) for pattern in self.API_KEY_PATTERNS
|
||||||
|
]
|
||||||
|
|
||||||
def format(self, record):
|
def format(self, record):
|
||||||
# Format the record normally first
|
# Format the record normally first
|
||||||
@@ -68,9 +68,10 @@ class AccessLogFormatter(logging.Formatter):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
for pattern in self.compiled_patterns:
|
for pattern in self.compiled_patterns:
|
||||||
|
|
||||||
def replace_key(match):
|
def replace_key(match):
|
||||||
key = match.group(0)
|
key = match.group(0)
|
||||||
return _redact_key_for_logging(key)
|
return redact_key_for_logging(key)
|
||||||
|
|
||||||
message = pattern.sub(replace_key, message)
|
message = pattern.sub(replace_key, message)
|
||||||
|
|
||||||
@@ -78,11 +79,31 @@ class AccessLogFormatter(logging.Formatter):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log the error but don't expose the original message in case it contains keys
|
# Log the error but don't expose the original message in case it contains keys
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.error(f"Error redacting API keys in access log: {e}")
|
logger.error(f"Error redacting API keys in access log: {e}")
|
||||||
return "[LOG_REDACTION_ERROR]"
|
return "[LOG_REDACTION_ERROR]"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_key_for_logging(key: str) -> str:
|
||||||
|
"""
|
||||||
|
Redacts API key for secure logging by showing only first and last 6 characters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: API key to redact
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Redacted key in format "first6...last6" or descriptive placeholder for edge cases
|
||||||
|
"""
|
||||||
|
if not key:
|
||||||
|
return key
|
||||||
|
|
||||||
|
if len(key) <= 12:
|
||||||
|
return f"{key[:3]}...{key[-3:]}"
|
||||||
|
else:
|
||||||
|
return f"{key[:6]}...{key[-6:]}"
|
||||||
|
|
||||||
|
|
||||||
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
|
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
|
||||||
FORMATTER = ColoredFormatter(
|
FORMATTER = ColoredFormatter(
|
||||||
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
|
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
|
||||||
@@ -326,4 +347,3 @@ def setup_access_logging():
|
|||||||
access_logger.propagate = False
|
access_logger.propagate = False
|
||||||
|
|
||||||
return access_logger
|
return access_logger
|
||||||
|
|
||||||
|
|||||||
@@ -285,7 +285,9 @@ class OpenAIChatService:
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||||
"""创建聊天完成"""
|
"""创建聊天完成"""
|
||||||
messages, instruction = self.message_converter.convert(request.messages)
|
messages, instruction = self.message_converter.convert(
|
||||||
|
request.messages, request.model
|
||||||
|
)
|
||||||
|
|
||||||
payload = _build_payload(request, messages, instruction)
|
payload = _build_payload(request, messages, instruction)
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ from app.config.config import settings
|
|||||||
from app.core.constants import VALID_IMAGE_RATIOS
|
from app.core.constants import VALID_IMAGE_RATIOS
|
||||||
from app.domain.openai_models import ImageGenerationRequest
|
from app.domain.openai_models import ImageGenerationRequest
|
||||||
from app.log.logger import get_image_create_logger
|
from app.log.logger import get_image_create_logger
|
||||||
from app.utils.uploader import ImageUploaderFactory
|
|
||||||
from app.utils.helpers import is_image_upload_configured
|
from app.utils.helpers import is_image_upload_configured
|
||||||
|
from app.utils.uploader import ImageUploaderFactory
|
||||||
|
|
||||||
logger = get_image_create_logger()
|
logger = get_image_create_logger()
|
||||||
|
|
||||||
@@ -99,7 +99,10 @@ class ImageCreateService:
|
|||||||
image_uploader = None
|
image_uploader = None
|
||||||
|
|
||||||
# Return base64 if explicitly requested or if no uploader is configured
|
# Return base64 if explicitly requested or if no uploader is configured
|
||||||
if request.response_format == "b64_json" or not is_image_upload_configured():
|
if (
|
||||||
|
request.response_format == "b64_json"
|
||||||
|
or not is_image_upload_configured(settings)
|
||||||
|
):
|
||||||
base64_image = base64.b64encode(image_data).decode("utf-8")
|
base64_image = base64.b64encode(image_data).decode("utf-8")
|
||||||
images_data.append(
|
images_data.append(
|
||||||
{"b64_json": base64_image, "revised_prompt": request.prompt}
|
{"b64_json": base64_image, "revised_prompt": request.prompt}
|
||||||
|
|||||||
+42
-32
@@ -1,14 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
通用工具函数模块
|
通用工具函数模块
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import base64
|
|
||||||
import requests
|
|
||||||
from typing import Dict, Any, List, Optional, Tuple
|
|
||||||
from pathlib import Path
|
|
||||||
import logging
|
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.config.config import Settings
|
||||||
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
||||||
|
|
||||||
helper_logger = logging.getLogger("app.utils")
|
helper_logger = logging.getLogger("app.utils")
|
||||||
@@ -28,12 +31,14 @@ def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
|
|||||||
tuple: (mime_type, encoded_data)
|
tuple: (mime_type, encoded_data)
|
||||||
"""
|
"""
|
||||||
# 检查字符串是否以 "data:" 格式开始
|
# 检查字符串是否以 "data:" 格式开始
|
||||||
if base64_string.startswith('data:'):
|
if base64_string.startswith("data:"):
|
||||||
# 提取 MIME 类型和数据
|
# 提取 MIME 类型和数据
|
||||||
pattern = DATA_URL_PATTERN
|
pattern = DATA_URL_PATTERN
|
||||||
match = re.match(pattern, base64_string)
|
match = re.match(pattern, base64_string)
|
||||||
if match:
|
if match:
|
||||||
mime_type = "image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
|
mime_type = (
|
||||||
|
"image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
|
||||||
|
)
|
||||||
encoded_data = match.group(2)
|
encoded_data = match.group(2)
|
||||||
return mime_type, encoded_data
|
return mime_type, encoded_data
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ def convert_image_to_base64(url: str) -> str:
|
|||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# 将图片内容转换为base64
|
# 将图片内容转换为base64
|
||||||
img_data = base64.b64encode(response.content).decode('utf-8')
|
img_data = base64.b64encode(response.content).decode("utf-8")
|
||||||
return img_data
|
return img_data
|
||||||
else:
|
else:
|
||||||
raise Exception(f"Failed to fetch image: {response.status_code}")
|
raise Exception(f"Failed to fetch image: {response.status_code}")
|
||||||
@@ -77,7 +82,9 @@ def format_json_response(data: Dict[str, Any], indent: int = 2) -> str:
|
|||||||
return json.dumps(data, indent=indent, ensure_ascii=False)
|
return json.dumps(data, indent=indent, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def parse_prompt_parameters(prompt: str, default_ratio: str = "1:1") -> Tuple[str, int, str]:
|
def parse_prompt_parameters(
|
||||||
|
prompt: str, default_ratio: str = "1:1"
|
||||||
|
) -> Tuple[str, int, str]:
|
||||||
"""
|
"""
|
||||||
从prompt中解析参数
|
从prompt中解析参数
|
||||||
|
|
||||||
@@ -97,22 +104,22 @@ def parse_prompt_parameters(prompt: str, default_ratio: str = "1:1") -> Tuple[st
|
|||||||
aspect_ratio = default_ratio
|
aspect_ratio = default_ratio
|
||||||
|
|
||||||
# 解析n参数
|
# 解析n参数
|
||||||
n_match = re.search(r'{n:(\d+)}', prompt)
|
n_match = re.search(r"{n:(\d+)}", prompt)
|
||||||
if n_match:
|
if n_match:
|
||||||
n = int(n_match.group(1))
|
n = int(n_match.group(1))
|
||||||
if n < 1 or n > 4:
|
if n < 1 or n > 4:
|
||||||
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
|
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
|
||||||
prompt = prompt.replace(n_match.group(0), '').strip()
|
prompt = prompt.replace(n_match.group(0), "").strip()
|
||||||
|
|
||||||
# 解析ratio参数
|
# 解析ratio参数
|
||||||
ratio_match = re.search(r'{ratio:(\d+:\d+)}', prompt)
|
ratio_match = re.search(r"{ratio:(\d+:\d+)}", prompt)
|
||||||
if ratio_match:
|
if ratio_match:
|
||||||
aspect_ratio = ratio_match.group(1)
|
aspect_ratio = ratio_match.group(1)
|
||||||
if aspect_ratio not in VALID_IMAGE_RATIOS:
|
if aspect_ratio not in VALID_IMAGE_RATIOS:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(VALID_IMAGE_RATIOS)}"
|
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(VALID_IMAGE_RATIOS)}"
|
||||||
)
|
)
|
||||||
prompt = prompt.replace(ratio_match.group(0), '').strip()
|
prompt = prompt.replace(ratio_match.group(0), "").strip()
|
||||||
|
|
||||||
return prompt, n, aspect_ratio
|
return prompt, n, aspect_ratio
|
||||||
|
|
||||||
@@ -143,17 +150,16 @@ def is_valid_api_key(key: str) -> bool:
|
|||||||
bool: 如果密钥格式有效则返回True
|
bool: 如果密钥格式有效则返回True
|
||||||
"""
|
"""
|
||||||
# 检查Gemini API密钥格式
|
# 检查Gemini API密钥格式
|
||||||
if key.startswith('AIza'):
|
if key.startswith("AIza"):
|
||||||
return len(key) >= 30
|
return len(key) >= 30
|
||||||
|
|
||||||
# 检查OpenAI API密钥格式
|
# 检查OpenAI API密钥格式
|
||||||
if key.startswith('sk-'):
|
if key.startswith("sk-"):
|
||||||
return len(key) >= 30
|
return len(key) >= 30
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def redact_key_for_logging(key: str) -> str:
|
def redact_key_for_logging(key: str) -> str:
|
||||||
"""
|
"""
|
||||||
Redacts API key for secure logging by showing only first and last 6 characters.
|
Redacts API key for secure logging by showing only first and last 6 characters.
|
||||||
@@ -177,26 +183,28 @@ def get_current_version(default_version: str = "0.0.0") -> str:
|
|||||||
"""Reads the current version from the VERSION file."""
|
"""Reads the current version from the VERSION file."""
|
||||||
version_file = VERSION_FILE_PATH
|
version_file = VERSION_FILE_PATH
|
||||||
try:
|
try:
|
||||||
with version_file.open('r', encoding='utf-8') as f:
|
with version_file.open("r", encoding="utf-8") as f:
|
||||||
version = f.read().strip()
|
version = f.read().strip()
|
||||||
if not version:
|
if not version:
|
||||||
helper_logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
|
helper_logger.warning(
|
||||||
|
f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'."
|
||||||
|
)
|
||||||
return default_version
|
return default_version
|
||||||
return version
|
return version
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
helper_logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
|
helper_logger.warning(
|
||||||
|
f"VERSION file not found at '{version_file}'. Using default version '{default_version}'."
|
||||||
|
)
|
||||||
return default_version
|
return default_version
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
helper_logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
|
helper_logger.error(
|
||||||
|
f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'."
|
||||||
|
)
|
||||||
return default_version
|
return default_version
|
||||||
|
|
||||||
|
|
||||||
def is_image_upload_configured() -> bool:
|
def is_image_upload_configured(settings: Settings) -> bool:
|
||||||
"""Return True only if a valid upload provider is selected and all required settings for that provider are present. Uses lazy import to avoid circular imports."""
|
"""Return True only if a valid upload provider is selected and all required settings for that provider are present."""
|
||||||
try:
|
|
||||||
from app.config.config import settings # local import to avoid circular dependency at module import time
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
provider = (getattr(settings, "UPLOAD_PROVIDER", "") or "").strip().lower()
|
provider = (getattr(settings, "UPLOAD_PROVIDER", "") or "").strip().lower()
|
||||||
if provider == "smms":
|
if provider == "smms":
|
||||||
@@ -204,8 +212,10 @@ def is_image_upload_configured() -> bool:
|
|||||||
if provider == "picgo":
|
if provider == "picgo":
|
||||||
return bool(getattr(settings, "PICGO_API_KEY", ""))
|
return bool(getattr(settings, "PICGO_API_KEY", ""))
|
||||||
if provider == "cloudflare_imgbed":
|
if provider == "cloudflare_imgbed":
|
||||||
return all([
|
return all(
|
||||||
getattr(settings, "CLOUDFLARE_IMGBED_URL", ""),
|
[
|
||||||
getattr(settings, "CLOUDFLARE_IMGBED_AUTH_CODE", ""),
|
getattr(settings, "CLOUDFLARE_IMGBED_URL", ""),
|
||||||
])
|
getattr(settings, "CLOUDFLARE_IMGBED_AUTH_CODE", ""),
|
||||||
|
]
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|||||||
Reference in New Issue
Block a user