mirror of
https://github.com/sligter/LandPPT.git
synced 2026-08-31 01:43:15 +08:00
feat: Enhance OpenAI integration with responses API and reasoning capabilities
- Added configuration options for using the OpenAI responses API and enabling reasoning in AIConfig. - Implemented logic to handle responses API and reasoning in the landppt_api and related services. - Updated the FastAPI application to support new configuration options and adjust request payloads accordingly. - Enhanced the web interface to allow users to toggle responses API and reasoning settings. - Added utility functions for extracting response data and usage metrics from OpenAI API responses. - Introduced a lifespan context manager for managing application startup and shutdown processes. - Created tests for the lifespan functionality to ensure proper handling of startup and shutdown events.
This commit is contained in:
@@ -30,6 +30,9 @@ TEMPLATE_GENERATION_MODEL_NAME=
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
OPENAI_MODEL=gpt-5
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_USE_RESPONSES_API=false
|
||||
OPENAI_ENABLE_REASONING=false
|
||||
OPENAI_REASONING_EFFORT=medium
|
||||
|
||||
# OpenAI-Compatible Providers (optional)
|
||||
# DeepSeek
|
||||
|
||||
@@ -283,6 +283,9 @@ DEFAULT_AI_PROVIDER=openai # openai / deepseek / kimi / minimax / anthropic / g
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=gpt-4o
|
||||
OPENAI_USE_RESPONSES_API=false # 使用 OpenAI 官方 /v1/responses 接口
|
||||
OPENAI_ENABLE_REASONING=false # 启用 OpenAI reasoning 参数
|
||||
OPENAI_REASONING_EFFORT=medium # low / medium / high
|
||||
|
||||
# OpenAI兼容提供商(通过 Base URL + API Key 接入)
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
@@ -272,6 +272,9 @@ DEFAULT_AI_PROVIDER=openai # openai / deepseek / kimi / minimax / anthropic / g
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=gpt-4o
|
||||
OPENAI_USE_RESPONSES_API=false # Use the official OpenAI /v1/responses endpoint
|
||||
OPENAI_ENABLE_REASONING=false # Enable OpenAI reasoning parameters
|
||||
OPENAI_REASONING_EFFORT=medium # low / medium / high
|
||||
|
||||
# OpenAI-Compatible providers (via Base URL + API Key)
|
||||
DEEPSEEK_API_KEY=
|
||||
|
||||
+243
-16
@@ -60,6 +60,159 @@ class OpenAIProvider(AIProvider):
|
||||
|
||||
return openai_message
|
||||
|
||||
@staticmethod
|
||||
def _is_truthy(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"true", "1", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
def _should_use_responses_api(self, config: Dict[str, Any]) -> bool:
|
||||
use_responses_api = self._is_truthy(config.get("use_responses_api"))
|
||||
if use_responses_api and not hasattr(self.client, "responses"):
|
||||
raise RuntimeError("Installed openai SDK does not support the Responses API")
|
||||
return use_responses_api
|
||||
|
||||
def _normalize_reasoning_effort(self, effort: Any, use_responses_api: bool) -> str:
|
||||
normalized = str(effort or "medium").strip().lower()
|
||||
if normalized in {"none", "minimal", "low", "medium", "high", "xhigh"}:
|
||||
return normalized
|
||||
return "medium"
|
||||
|
||||
def _build_reasoning_config(self, config: Dict[str, Any], use_responses_api: bool) -> Dict[str, Any]:
|
||||
if not self._is_truthy(config.get("enable_reasoning")):
|
||||
return {}
|
||||
|
||||
effort = self._normalize_reasoning_effort(
|
||||
config.get("reasoning_effort"),
|
||||
use_responses_api,
|
||||
)
|
||||
if use_responses_api:
|
||||
return {"reasoning": {"effort": effort}}
|
||||
return {"reasoning_effort": effort}
|
||||
|
||||
def _convert_message_to_responses_input(self, message: AIMessage) -> Dict[str, Any]:
|
||||
"""Convert AIMessage to the OpenAI Responses API input format."""
|
||||
response_message: Dict[str, Any] = {"role": message.role.value}
|
||||
|
||||
if isinstance(message.content, str):
|
||||
response_message["content"] = message.content
|
||||
return response_message
|
||||
|
||||
if isinstance(message.content, list):
|
||||
content_parts = []
|
||||
for part in message.content:
|
||||
if isinstance(part, TextContent):
|
||||
content_parts.append({
|
||||
"type": "input_text",
|
||||
"text": part.text
|
||||
})
|
||||
elif isinstance(part, ImageContent):
|
||||
image_url = part.image_url.get("url", "")
|
||||
if image_url:
|
||||
content_parts.append({
|
||||
"type": "input_image",
|
||||
"image_url": image_url,
|
||||
"detail": "auto"
|
||||
})
|
||||
response_message["content"] = content_parts or ""
|
||||
return response_message
|
||||
|
||||
response_message["content"] = str(message.content)
|
||||
return response_message
|
||||
|
||||
def _build_responses_request(self, messages: List[AIMessage], config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"model": config.get("model", self.model),
|
||||
"input": [self._convert_message_to_responses_input(msg) for msg in messages],
|
||||
"temperature": config.get("temperature", 0.7),
|
||||
"top_p": config.get("top_p", 1.0),
|
||||
}
|
||||
payload.update(self._build_reasoning_config(config, use_responses_api=True))
|
||||
|
||||
max_tokens = config.get("max_tokens")
|
||||
if max_tokens is not None:
|
||||
payload["max_output_tokens"] = max_tokens
|
||||
|
||||
model_name = str(payload.get("model") or "")
|
||||
reasoning = payload.get("reasoning") or {}
|
||||
if model_name.startswith("gpt-5") and reasoning.get("effort") not in {None, "none"}:
|
||||
payload.pop("temperature", None)
|
||||
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _extract_responses_usage(response: Any) -> Dict[str, int]:
|
||||
usage = getattr(response, "usage", None)
|
||||
if not usage:
|
||||
return {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
}
|
||||
|
||||
return {
|
||||
"prompt_tokens": int(getattr(usage, "input_tokens", 0) or 0),
|
||||
"completion_tokens": int(getattr(usage, "output_tokens", 0) or 0),
|
||||
"total_tokens": int(getattr(usage, "total_tokens", 0) or 0)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_responses_finish_reason(response: Any) -> str:
|
||||
incomplete_details = getattr(response, "incomplete_details", None)
|
||||
if incomplete_details and getattr(incomplete_details, "reason", None):
|
||||
return str(incomplete_details.reason)
|
||||
|
||||
status = getattr(response, "status", None)
|
||||
if not status or str(status) == "completed":
|
||||
return "stop"
|
||||
|
||||
return str(status)
|
||||
|
||||
def _filter_stream_chunk(
|
||||
self,
|
||||
chunk_content: str,
|
||||
buffer: str,
|
||||
in_think_tag: bool,
|
||||
) -> tuple[str, str, bool]:
|
||||
"""Filter think-tag content from streaming chunks while preserving partial tags."""
|
||||
buffer += chunk_content
|
||||
processed_content = ""
|
||||
remaining_buffer = buffer
|
||||
|
||||
while remaining_buffer:
|
||||
lowered = remaining_buffer.lower()
|
||||
|
||||
if not in_think_tag:
|
||||
think_start = lowered.find("<think")
|
||||
if think_start == -1:
|
||||
processed_content += remaining_buffer
|
||||
remaining_buffer = ""
|
||||
break
|
||||
|
||||
processed_content += remaining_buffer[:think_start]
|
||||
tag_end = lowered.find(">", think_start)
|
||||
if tag_end == -1:
|
||||
remaining_buffer = remaining_buffer[think_start:]
|
||||
break
|
||||
|
||||
in_think_tag = True
|
||||
remaining_buffer = remaining_buffer[tag_end + 1:]
|
||||
continue
|
||||
|
||||
think_end = lowered.find("</think>")
|
||||
if think_end == -1:
|
||||
remaining_buffer = ""
|
||||
break
|
||||
|
||||
in_think_tag = False
|
||||
remaining_buffer = remaining_buffer[think_end + len("</think>"):]
|
||||
|
||||
return processed_content, remaining_buffer, in_think_tag
|
||||
|
||||
def _filter_think_content(self, content: str) -> str:
|
||||
"""
|
||||
Filter out content within think tags in all forms
|
||||
@@ -102,6 +255,33 @@ class OpenAIProvider(AIProvider):
|
||||
|
||||
config = self._merge_config(**kwargs)
|
||||
|
||||
if self._should_use_responses_api(config):
|
||||
try:
|
||||
response = await self.client.responses.create(
|
||||
**self._build_responses_request(messages, config)
|
||||
)
|
||||
filtered_content = self._filter_think_content(getattr(response, "output_text", ""))
|
||||
|
||||
return AIResponse(
|
||||
content=filtered_content,
|
||||
model=response.model,
|
||||
usage=self._extract_responses_usage(response),
|
||||
finish_reason=self._extract_responses_finish_reason(response),
|
||||
metadata={"provider": "openai", "api_mode": "responses"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "Expecting value" in error_msg:
|
||||
logger.error(f"OpenAI Responses API JSON parsing error: {error_msg}. This usually indicates the API returned malformed JSON.")
|
||||
elif "timeout" in error_msg.lower():
|
||||
logger.error(f"OpenAI Responses API timeout error: {error_msg}")
|
||||
elif "rate limit" in error_msg.lower():
|
||||
logger.error(f"OpenAI Responses API rate limit error: {error_msg}")
|
||||
else:
|
||||
logger.error(f"OpenAI Responses API error: {error_msg}")
|
||||
raise
|
||||
|
||||
# Convert messages to OpenAI format with multimodal support
|
||||
openai_messages = [
|
||||
self._convert_message_to_openai(msg)
|
||||
@@ -109,13 +289,20 @@ class OpenAIProvider(AIProvider):
|
||||
]
|
||||
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=config.get("model", self.model),
|
||||
messages=openai_messages,
|
||||
# max_tokens=config.get("max_tokens", 2000),
|
||||
temperature=config.get("temperature", 0.7),
|
||||
top_p=config.get("top_p", 1.0)
|
||||
)
|
||||
request_payload = {
|
||||
"model": config.get("model", self.model),
|
||||
"messages": openai_messages,
|
||||
# "max_tokens": config.get("max_tokens", 2000),
|
||||
"temperature": config.get("temperature", 0.7),
|
||||
"top_p": config.get("top_p", 1.0),
|
||||
}
|
||||
request_payload.update(self._build_reasoning_config(config, use_responses_api=False))
|
||||
|
||||
model_name = str(request_payload.get("model") or "")
|
||||
if model_name.startswith("gpt-5") and "chat" not in model_name and "reasoning_effort" in request_payload:
|
||||
request_payload.pop("temperature", None)
|
||||
|
||||
response = await self.client.chat.completions.create(**request_payload)
|
||||
|
||||
choice = response.choices[0]
|
||||
# Filter out think content from the response
|
||||
@@ -130,7 +317,7 @@ class OpenAIProvider(AIProvider):
|
||||
"total_tokens": response.usage.total_tokens
|
||||
},
|
||||
finish_reason=choice.finish_reason,
|
||||
metadata={"provider": "openai"}
|
||||
metadata={"provider": "openai", "api_mode": "chat_completions"}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -158,6 +345,39 @@ class OpenAIProvider(AIProvider):
|
||||
|
||||
config = self._merge_config(**kwargs)
|
||||
|
||||
if self._should_use_responses_api(config):
|
||||
try:
|
||||
stream = await self.client.responses.create(
|
||||
**self._build_responses_request(messages, config),
|
||||
stream=True
|
||||
)
|
||||
|
||||
buffer = ""
|
||||
in_think_tag = False
|
||||
|
||||
async for event in stream:
|
||||
if getattr(event, "type", None) != "response.output_text.delta":
|
||||
continue
|
||||
|
||||
chunk_content = getattr(event, "delta", "")
|
||||
if not chunk_content:
|
||||
continue
|
||||
|
||||
processed_content, buffer, in_think_tag = self._filter_stream_chunk(
|
||||
chunk_content,
|
||||
buffer,
|
||||
in_think_tag,
|
||||
)
|
||||
|
||||
if not in_think_tag and processed_content:
|
||||
yield processed_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"OpenAI Responses streaming error: {e}")
|
||||
raise
|
||||
|
||||
return
|
||||
|
||||
# Convert messages to OpenAI format with multimodal support
|
||||
openai_messages = [
|
||||
self._convert_message_to_openai(msg)
|
||||
@@ -165,14 +385,21 @@ class OpenAIProvider(AIProvider):
|
||||
]
|
||||
|
||||
try:
|
||||
stream = await self.client.chat.completions.create(
|
||||
model=config.get("model", self.model),
|
||||
messages=openai_messages,
|
||||
# max_tokens=config.get("max_tokens", 2000),
|
||||
temperature=config.get("temperature", 0.7),
|
||||
top_p=config.get("top_p", 1.0),
|
||||
stream=True
|
||||
)
|
||||
request_payload = {
|
||||
"model": config.get("model", self.model),
|
||||
"messages": openai_messages,
|
||||
# "max_tokens": config.get("max_tokens", 2000),
|
||||
"temperature": config.get("temperature", 0.7),
|
||||
"top_p": config.get("top_p", 1.0),
|
||||
"stream": True,
|
||||
}
|
||||
request_payload.update(self._build_reasoning_config(config, use_responses_api=False))
|
||||
|
||||
model_name = str(request_payload.get("model") or "")
|
||||
if model_name.startswith("gpt-5") and "chat" not in model_name and "reasoning_effort" in request_payload:
|
||||
request_payload.pop("temperature", None)
|
||||
|
||||
stream = await self.client.chat.completions.create(**request_payload)
|
||||
|
||||
buffer = ""
|
||||
in_think_tag = False
|
||||
|
||||
@@ -61,6 +61,41 @@ def filter_think_tags(content: str) -> str:
|
||||
return filtered_content
|
||||
|
||||
|
||||
def _is_truthy_config_value(value) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"true", "1", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _extract_responses_output_text(response_data: dict) -> str:
|
||||
output_text = response_data.get("output_text")
|
||||
if isinstance(output_text, str) and output_text:
|
||||
return output_text
|
||||
|
||||
texts = []
|
||||
for item in response_data.get("output", []) or []:
|
||||
if not isinstance(item, dict) or item.get("type") != "message":
|
||||
continue
|
||||
for content in item.get("content", []) or []:
|
||||
if isinstance(content, dict) and content.get("type") == "output_text" and content.get("text"):
|
||||
texts.append(content["text"])
|
||||
|
||||
return "".join(texts)
|
||||
|
||||
|
||||
def _extract_responses_usage(response_data: dict) -> dict:
|
||||
usage = response_data.get("usage") or {}
|
||||
return {
|
||||
"prompt_tokens": int(usage.get("input_tokens") or 0),
|
||||
"completion_tokens": int(usage.get("output_tokens") or 0),
|
||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||
}
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
file_processor = FileProcessor()
|
||||
@@ -194,6 +229,15 @@ async def test_ai_provider(provider_name: str, request: Request):
|
||||
base_url = body.get('base_url')
|
||||
api_key = body.get('api_key')
|
||||
model = body.get('model', 'gpt-4o')
|
||||
use_responses_api = _is_truthy_config_value(
|
||||
body.get('use_responses_api', getattr(ai_config, 'openai_use_responses_api', False))
|
||||
)
|
||||
enable_reasoning = _is_truthy_config_value(
|
||||
body.get('enable_reasoning', getattr(ai_config, 'openai_enable_reasoning', False))
|
||||
)
|
||||
reasoning_effort = str(
|
||||
body.get('reasoning_effort', getattr(ai_config, 'openai_reasoning_effort', 'medium')) or 'medium'
|
||||
).strip().lower()
|
||||
|
||||
if base_url and api_key:
|
||||
# Use frontend provided config for OpenAI
|
||||
@@ -203,7 +247,7 @@ async def test_ai_provider(provider_name: str, request: Request):
|
||||
if not base_url.endswith('/v1'):
|
||||
base_url = base_url.rstrip('/') + '/v1'
|
||||
|
||||
chat_url = f"{base_url}/chat/completions"
|
||||
request_url = f"{base_url}/responses" if use_responses_api else f"{base_url}/chat/completions"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
headers = {
|
||||
@@ -211,29 +255,48 @@ async def test_ai_provider(provider_name: str, request: Request):
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Say 'Hello, I am working!' in exactly 5 words."
|
||||
}
|
||||
],
|
||||
"temperature": 0
|
||||
}
|
||||
if use_responses_api:
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": "Say 'Hello, I am working!' in exactly 5 words.",
|
||||
"max_output_tokens": 32
|
||||
}
|
||||
if enable_reasoning:
|
||||
payload["reasoning"] = {"effort": reasoning_effort}
|
||||
else:
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Say 'Hello, I am working!' in exactly 5 words."
|
||||
}
|
||||
]
|
||||
}
|
||||
if enable_reasoning:
|
||||
payload["reasoning_effort"] = reasoning_effort
|
||||
|
||||
async with session.post(chat_url, headers=headers, json=payload, timeout=30) as response:
|
||||
async with session.post(request_url, headers=headers, json=payload, timeout=30) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
# Apply think tag filtering to the response
|
||||
raw_content = data['choices'][0]['message']['content']
|
||||
raw_content = (
|
||||
_extract_responses_output_text(data)
|
||||
if use_responses_api
|
||||
else data['choices'][0]['message']['content']
|
||||
)
|
||||
filtered_content = filter_think_tags(raw_content)
|
||||
return {
|
||||
"provider": provider_name,
|
||||
"status": "success",
|
||||
"model": model,
|
||||
"api_mode": "responses" if use_responses_api else "chat_completions",
|
||||
"response_preview": filtered_content,
|
||||
"usage": data.get('usage', {})
|
||||
"usage": (
|
||||
_extract_responses_usage(data)
|
||||
if use_responses_api
|
||||
else data.get('usage', {})
|
||||
)
|
||||
}
|
||||
else:
|
||||
error_text = await response.text()
|
||||
|
||||
@@ -37,6 +37,9 @@ class AIConfig(BaseSettings):
|
||||
openai_api_key: Optional[str] = Field(default=None, env="OPENAI_API_KEY")
|
||||
openai_base_url: str = Field(default="https://api.openai.com/v1", env="OPENAI_BASE_URL")
|
||||
openai_model: str = Field(default="gpt-3.5-turbo", env="OPENAI_MODEL")
|
||||
openai_use_responses_api: bool = Field(default=False, env="OPENAI_USE_RESPONSES_API")
|
||||
openai_enable_reasoning: bool = Field(default=False, env="OPENAI_ENABLE_REASONING")
|
||||
openai_reasoning_effort: str = Field(default="medium", env="OPENAI_REASONING_EFFORT")
|
||||
|
||||
# OpenAI-Compatible Providers
|
||||
deepseek_api_key: Optional[str] = Field(default=None, env="DEEPSEEK_API_KEY")
|
||||
@@ -256,6 +259,9 @@ class AIConfig(BaseSettings):
|
||||
"api_key": self.openai_api_key,
|
||||
"base_url": self.openai_base_url,
|
||||
"model": self.openai_model,
|
||||
"use_responses_api": self.openai_use_responses_api,
|
||||
"enable_reasoning": self.openai_enable_reasoning,
|
||||
"reasoning_effort": self.openai_reasoning_effort,
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": self.temperature,
|
||||
"top_p": self.top_p,
|
||||
@@ -404,6 +410,18 @@ def reload_ai_config():
|
||||
ai_config.openai_model = os.environ.get('OPENAI_MODEL', ai_config.openai_model)
|
||||
ai_config.openai_base_url = os.environ.get('OPENAI_BASE_URL', ai_config.openai_base_url)
|
||||
ai_config.openai_api_key = os.environ.get('OPENAI_API_KEY', ai_config.openai_api_key)
|
||||
ai_config.openai_use_responses_api = os.environ.get(
|
||||
'OPENAI_USE_RESPONSES_API',
|
||||
str(ai_config.openai_use_responses_api),
|
||||
).lower() == 'true'
|
||||
ai_config.openai_enable_reasoning = os.environ.get(
|
||||
'OPENAI_ENABLE_REASONING',
|
||||
str(ai_config.openai_enable_reasoning),
|
||||
).lower() == 'true'
|
||||
ai_config.openai_reasoning_effort = os.environ.get(
|
||||
'OPENAI_REASONING_EFFORT',
|
||||
ai_config.openai_reasoning_effort,
|
||||
)
|
||||
ai_config.anthropic_api_key = os.environ.get('ANTHROPIC_API_KEY', ai_config.anthropic_api_key)
|
||||
ai_config.anthropic_base_url = os.environ.get('ANTHROPIC_BASE_URL', ai_config.anthropic_base_url)
|
||||
ai_config.anthropic_model = os.environ.get('ANTHROPIC_MODEL', ai_config.anthropic_model)
|
||||
|
||||
+66
-50
@@ -1,83 +1,95 @@
|
||||
"""
|
||||
Main FastAPI application entry point
|
||||
Main FastAPI application entry point.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
import uvicorn
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from .api.openai_compat import router as openai_router
|
||||
from .api.landppt_api import router as landppt_router
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from .api.config_api import router as config_router
|
||||
from .api.database_api import router as database_router
|
||||
from .api.global_master_template_api import router as template_api_router
|
||||
from .api.config_api import router as config_router
|
||||
from .api.image_api import router as image_router
|
||||
|
||||
from .web import router as web_router
|
||||
from .api.landppt_api import router as landppt_router
|
||||
from .api.openai_compat import router as openai_router
|
||||
from .auth import auth_router, create_auth_middleware
|
||||
from .database.database import init_db
|
||||
from .database.create_default_template import ensure_default_templates_exist_first_time
|
||||
from .database.database import close_db, init_db
|
||||
from .web import router as web_router
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Disable SQLAlchemy verbose logging completely
|
||||
logging.getLogger('sqlalchemy').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.engine.Engine').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.pool').setLevel(logging.WARNING)
|
||||
logging.getLogger('sqlalchemy.dialects').setLevel(logging.WARNING)
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="LandPPT API",
|
||||
description="AI-powered PPT generation platform with OpenAI-compatible API",
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc"
|
||||
)
|
||||
logging.getLogger("sqlalchemy").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine.Engine").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.pool").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.dialects").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize database on startup"""
|
||||
async def startup_application() -> None:
|
||||
"""Initialize application resources on startup."""
|
||||
try:
|
||||
# Check if database file exists before initialization
|
||||
import os
|
||||
db_file_path = "landppt.db" # 默认数据库文件路径
|
||||
db_file_path = "landppt.db"
|
||||
db_exists = os.path.exists(db_file_path)
|
||||
|
||||
logger.info("Initializing database...")
|
||||
await init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Only import templates if database file didn't exist before (first time setup)
|
||||
if not db_exists:
|
||||
logger.info("First time setup detected - importing templates from examples...")
|
||||
template_ids = await ensure_default_templates_exist_first_time()
|
||||
logger.info(f"Template initialization completed. {len(template_ids)} templates available.")
|
||||
logger.info("Template initialization completed. %s templates available.", len(template_ids))
|
||||
else:
|
||||
logger.info("Database already exists - skipping template import")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize application: {e}")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to initialize application: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""Clean up database connections on shutdown"""
|
||||
async def shutdown_application() -> None:
|
||||
"""Clean up application resources on shutdown."""
|
||||
try:
|
||||
logger.info("Shutting down application...")
|
||||
await close_db()
|
||||
logger.info("Application shutdown complete")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during shutdown: {e}")
|
||||
except Exception as exc:
|
||||
logger.error("Error during shutdown: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
await startup_application()
|
||||
try:
|
||||
yield
|
||||
except asyncio.CancelledError:
|
||||
# Uvicorn/Starlette may cancel the lifespan task during Windows
|
||||
# shutdown or auto-reload teardown. Treat that as graceful exit.
|
||||
pass
|
||||
finally:
|
||||
await shutdown_application()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="LandPPT API",
|
||||
description="AI-powered PPT generation platform with OpenAI-compatible API",
|
||||
version="0.1.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
@@ -112,35 +124,39 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
temp_dir = os.path.join(os.getcwd(), "temp")
|
||||
if os.path.exists(temp_dir):
|
||||
app.mount("/temp", StaticFiles(directory=temp_dir), name="temp")
|
||||
logger.info(f"Mounted temp directory: {temp_dir}")
|
||||
logger.info("Mounted temp directory: %s", temp_dir)
|
||||
else:
|
||||
logger.warning(f"Temp directory not found: {temp_dir}")
|
||||
logger.warning("Temp directory not found: %s", temp_dir)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root():
|
||||
"""Root endpoint - redirect to dashboard"""
|
||||
"""Root endpoint - redirect to dashboard."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
return RedirectResponse(url="/dashboard", status_code=302)
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
"""Serve favicon"""
|
||||
"""Serve favicon."""
|
||||
favicon_path = os.path.join(os.path.dirname(__file__), "web", "static", "images", "favicon.svg")
|
||||
if os.path.exists(favicon_path):
|
||||
return FileResponse(favicon_path, media_type="image/svg+xml")
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
raise HTTPException(status_code=404, detail="Favicon not found")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "service": "LandPPT API"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"src.landppt.main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info"
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
@@ -50,6 +50,9 @@ class ConfigService:
|
||||
"openai_api_key": {"type": "password", "category": "ai_providers"},
|
||||
"openai_base_url": {"type": "url", "category": "ai_providers", "default": "https://api.openai.com/v1"},
|
||||
"openai_model": {"type": "select", "category": "ai_providers", "default": "gpt-4.1"},
|
||||
"openai_use_responses_api": {"type": "boolean", "category": "ai_providers", "default": "false"},
|
||||
"openai_enable_reasoning": {"type": "boolean", "category": "ai_providers", "default": "false"},
|
||||
"openai_reasoning_effort": {"type": "select", "category": "ai_providers", "default": "medium"},
|
||||
|
||||
# OpenAI-Compatible Providers
|
||||
"deepseek_api_key": {"type": "password", "category": "ai_providers"},
|
||||
@@ -223,6 +226,22 @@ class ConfigService:
|
||||
"siliconflow_steps": {"type": "number", "category": "image_service", "default": 20},
|
||||
"siliconflow_guidance_scale": {"type": "number", "category": "image_service", "default": 7.5},
|
||||
}
|
||||
self._ensure_runtime_schema_extensions()
|
||||
|
||||
def _ensure_runtime_schema_extensions(self) -> None:
|
||||
"""Backfill recently added config keys for long-lived service instances."""
|
||||
self.config_schema.setdefault(
|
||||
"openai_use_responses_api",
|
||||
{"type": "boolean", "category": "ai_providers", "default": "false"},
|
||||
)
|
||||
self.config_schema.setdefault(
|
||||
"openai_enable_reasoning",
|
||||
{"type": "boolean", "category": "ai_providers", "default": "false"},
|
||||
)
|
||||
self.config_schema.setdefault(
|
||||
"openai_reasoning_effort",
|
||||
{"type": "select", "category": "ai_providers", "default": "medium"},
|
||||
)
|
||||
|
||||
|
||||
def _migrate_legacy_ai_defaults(self) -> None:
|
||||
@@ -258,6 +277,7 @@ class ConfigService:
|
||||
|
||||
def get_all_config(self) -> Dict[str, Any]:
|
||||
"""Get all configuration values"""
|
||||
self._ensure_runtime_schema_extensions()
|
||||
config = {}
|
||||
|
||||
for key, schema in self.config_schema.items():
|
||||
@@ -289,6 +309,7 @@ class ConfigService:
|
||||
|
||||
def get_config_by_category(self, category: str) -> Dict[str, Any]:
|
||||
"""Get configuration values by category"""
|
||||
self._ensure_runtime_schema_extensions()
|
||||
config = {}
|
||||
|
||||
for key, schema in self.config_schema.items():
|
||||
@@ -321,6 +342,7 @@ class ConfigService:
|
||||
|
||||
def update_config(self, config: Dict[str, Any]) -> bool:
|
||||
"""Update configuration values"""
|
||||
self._ensure_runtime_schema_extensions()
|
||||
try:
|
||||
for key, value in config.items():
|
||||
if key in self.config_schema:
|
||||
@@ -499,10 +521,12 @@ class ConfigService:
|
||||
|
||||
def get_config_schema(self) -> Dict[str, Any]:
|
||||
"""Get configuration schema"""
|
||||
self._ensure_runtime_schema_extensions()
|
||||
return self.config_schema
|
||||
|
||||
def validate_config(self, config: Dict[str, Any]) -> Dict[str, List[str]]:
|
||||
"""Validate configuration values"""
|
||||
self._ensure_runtime_schema_extensions()
|
||||
errors = {}
|
||||
|
||||
for key, value in config.items():
|
||||
@@ -589,4 +613,5 @@ config_service = ConfigService()
|
||||
|
||||
def get_config_service() -> ConfigService:
|
||||
"""Get config service instance"""
|
||||
config_service._ensure_runtime_schema_extensions()
|
||||
return config_service
|
||||
|
||||
@@ -284,6 +284,20 @@ class EnhancedPPTService(PPTService):
|
||||
os.environ["OPENAI_BASE_URL"] = provider_config["base_url"]
|
||||
if provider_config.get("model"):
|
||||
os.environ["OPENAI_MODEL"] = provider_config["model"]
|
||||
raw_use_responses_api = provider_config.get("use_responses_api", False) if current_provider == "openai" else False
|
||||
if isinstance(raw_use_responses_api, str):
|
||||
use_responses_api = raw_use_responses_api.strip().lower() in ("true", "1", "yes", "on")
|
||||
else:
|
||||
use_responses_api = bool(raw_use_responses_api)
|
||||
os.environ["OPENAI_USE_RESPONSES_API"] = "true" if use_responses_api else "false"
|
||||
raw_enable_reasoning = provider_config.get("enable_reasoning", False) if current_provider == "openai" else False
|
||||
if isinstance(raw_enable_reasoning, str):
|
||||
enable_reasoning = raw_enable_reasoning.strip().lower() in ("true", "1", "yes", "on")
|
||||
else:
|
||||
enable_reasoning = bool(raw_enable_reasoning)
|
||||
os.environ["OPENAI_ENABLE_REASONING"] = "true" if enable_reasoning else "false"
|
||||
os.environ["OPENAI_REASONING_EFFORT"] = str(provider_config.get("reasoning_effort", "medium") or "medium")
|
||||
logger.info(f"summeryanyfile OpenAI compatibility mode: use_responses_api={use_responses_api}")
|
||||
|
||||
logger.info(f"已配置summeryanyfile OpenAI兼容API: provider={current_provider}, model={provider_config.get('model')}, base_url={provider_config.get('base_url')}")
|
||||
|
||||
|
||||
+83
-19
@@ -320,6 +320,41 @@ async def web_image_generation_test(
|
||||
})
|
||||
|
||||
|
||||
def _is_truthy_config_value(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"true", "1", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _extract_responses_output_text(response_data: Dict[str, Any]) -> str:
|
||||
output_text = response_data.get("output_text")
|
||||
if isinstance(output_text, str) and output_text:
|
||||
return output_text
|
||||
|
||||
texts: List[str] = []
|
||||
for item in response_data.get("output", []) or []:
|
||||
if not isinstance(item, dict) or item.get("type") != "message":
|
||||
continue
|
||||
for content in item.get("content", []) or []:
|
||||
if isinstance(content, dict) and content.get("type") == "output_text" and content.get("text"):
|
||||
texts.append(content["text"])
|
||||
|
||||
return "".join(texts)
|
||||
|
||||
|
||||
def _extract_responses_usage(response_data: Dict[str, Any]) -> Dict[str, int]:
|
||||
usage = response_data.get("usage") or {}
|
||||
return {
|
||||
"prompt_tokens": int(usage.get("input_tokens") or 0),
|
||||
"completion_tokens": int(usage.get("output_tokens") or 0),
|
||||
"total_tokens": int(usage.get("total_tokens") or 0),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/ai/providers/openai/models")
|
||||
async def get_openai_models(
|
||||
request: Request,
|
||||
@@ -404,8 +439,15 @@ async def test_openai_provider_proxy(
|
||||
base_url = data.get('base_url', 'https://api.openai.com/v1')
|
||||
api_key = data.get('api_key', '')
|
||||
model = data.get('model', 'gpt-4o')
|
||||
use_responses_api = _is_truthy_config_value(data.get('use_responses_api', False))
|
||||
enable_reasoning = _is_truthy_config_value(data.get('enable_reasoning', False))
|
||||
reasoning_effort = str(data.get('reasoning_effort', 'medium') or 'medium').strip().lower()
|
||||
|
||||
logger.info(f"Frontend requested test with: base_url={base_url}, model={model}")
|
||||
logger.info(
|
||||
f"Frontend requested test with: base_url={base_url}, model={model}, "
|
||||
f"use_responses_api={use_responses_api}, enable_reasoning={enable_reasoning}, "
|
||||
f"reasoning_effort={reasoning_effort}"
|
||||
)
|
||||
|
||||
if not api_key:
|
||||
return {"success": False, "error": "API Key is required"}
|
||||
@@ -414,8 +456,8 @@ async def test_openai_provider_proxy(
|
||||
if not base_url.endswith('/v1'):
|
||||
base_url = base_url.rstrip('/') + '/v1'
|
||||
|
||||
chat_url = f"{base_url}/chat/completions"
|
||||
logger.info(f"Testing OpenAI provider at: {chat_url}")
|
||||
request_url = f"{base_url}/responses" if use_responses_api else f"{base_url}/chat/completions"
|
||||
logger.info(f"Testing OpenAI provider at: {request_url}")
|
||||
|
||||
# Make test request to OpenAI API using frontend provided credentials
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@@ -424,19 +466,44 @@ async def test_openai_provider_proxy(
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Say 'Hello, I am working!' in exactly 5 words."
|
||||
}
|
||||
]
|
||||
}
|
||||
if use_responses_api:
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": "Say 'Hello, I am working!' in exactly 5 words.",
|
||||
"max_output_tokens": 32
|
||||
}
|
||||
if enable_reasoning:
|
||||
payload["reasoning"] = {"effort": reasoning_effort}
|
||||
else:
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Say 'Hello, I am working!' in exactly 5 words."
|
||||
}
|
||||
]
|
||||
}
|
||||
if enable_reasoning:
|
||||
payload["reasoning_effort"] = reasoning_effort
|
||||
|
||||
async with session.post(chat_url, headers=headers, json=payload, timeout=30) as response:
|
||||
async with session.post(request_url, headers=headers, json=payload, timeout=30) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
response_preview = (
|
||||
_extract_responses_output_text(data)
|
||||
if use_responses_api
|
||||
else data['choices'][0]['message']['content']
|
||||
)
|
||||
usage = (
|
||||
_extract_responses_usage(data)
|
||||
if use_responses_api
|
||||
else data.get('usage', {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
})
|
||||
)
|
||||
|
||||
logger.info(f"Test successful for {base_url} with model {model}")
|
||||
|
||||
@@ -446,12 +513,9 @@ async def test_openai_provider_proxy(
|
||||
"status": "success", # Add status field for compatibility
|
||||
"provider": "openai",
|
||||
"model": model,
|
||||
"response_preview": data['choices'][0]['message']['content'],
|
||||
"usage": data.get('usage', {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
})
|
||||
"api_mode": "responses" if use_responses_api else "chat_completions",
|
||||
"response_preview": response_preview,
|
||||
"usage": usage
|
||||
}
|
||||
else:
|
||||
error_text = await response.text()
|
||||
|
||||
@@ -198,6 +198,60 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if provider == "openai" %}
|
||||
<div class="form-group provider-toggle-option">
|
||||
<label class="provider-toggle-label">
|
||||
<span class="toggle-switch provider-toggle-switch">
|
||||
<input type="checkbox" name="openai_use_responses_api"
|
||||
{% if current_config.get('openai_use_responses_api', false) %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
<span class="provider-toggle-copy">
|
||||
<span class="provider-toggle-title">启用 /v1/responses 接口</span>
|
||||
<small class="provider-toggle-hint">
|
||||
勾选后将使用 OpenAI 官方 Responses API,保存后立即生效。
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group provider-toggle-option">
|
||||
<label class="provider-toggle-label">
|
||||
<span class="toggle-switch provider-toggle-switch">
|
||||
<input type="checkbox" name="openai_enable_reasoning"
|
||||
onchange="syncOpenAIReasoningControls()"
|
||||
{% if current_config.get('openai_enable_reasoning', false) %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
<span class="provider-toggle-copy">
|
||||
<span class="provider-toggle-title">启用思考模式</span>
|
||||
<small class="provider-toggle-hint">
|
||||
开启后会附带 OpenAI reasoning 参数;关闭时保持模型默认行为。
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group provider-reasoning-field" data-openai-reasoning-field>
|
||||
<label>推理程度</label>
|
||||
<select name="openai_reasoning_effort" data-openai-reasoning-select>
|
||||
<option value="none"
|
||||
{% if current_config.get('openai_reasoning_effort', 'medium') == 'none' %}selected{% endif %}>none</option>
|
||||
<option value="minimal"
|
||||
{% if current_config.get('openai_reasoning_effort', 'medium') == 'minimal' %}selected{% endif %}>minimal</option>
|
||||
<option value="low"
|
||||
{% if current_config.get('openai_reasoning_effort', 'medium') == 'low' %}selected{% endif %}>low</option>
|
||||
<option value="medium"
|
||||
{% if current_config.get('openai_reasoning_effort', 'medium') == 'medium' %}selected{% endif %}>medium</option>
|
||||
<option value="high"
|
||||
{% if current_config.get('openai_reasoning_effort', 'medium') == 'high' %}selected{% endif %}>high</option>
|
||||
<option value="xhigh"
|
||||
{% if current_config.get('openai_reasoning_effort', 'medium') == 'xhigh' %}selected{% endif %}>xhigh</option>
|
||||
</select>
|
||||
<small class="provider-toggle-hint">
|
||||
仅对支持推理参数的 OpenAI 模型生效;不支持时测试或调用会返回参数错误。
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif provider == "anthropic" %}
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
@@ -1502,7 +1556,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -1512,7 +1567,8 @@
|
||||
}
|
||||
|
||||
.model-roles-wrapper {
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -1721,10 +1777,89 @@
|
||||
color: rgba(17, 17, 17, 0.72);
|
||||
}
|
||||
|
||||
.provider-accordion__fields .provider-toggle-option {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: -4px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(17, 17, 17, 0.1);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 248, 248, 0.96) 100%);
|
||||
}
|
||||
|
||||
.provider-accordion__fields .form-group .provider-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.provider-toggle-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-toggle-title {
|
||||
font-size: 0.98rem;
|
||||
font-weight: 650;
|
||||
color: #161616;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.provider-toggle-hint {
|
||||
margin: 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.provider-accordion__fields .provider-toggle-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.provider-accordion__fields .provider-toggle-switch input[type="checkbox"] {
|
||||
width: 0;
|
||||
height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.provider-accordion__fields .provider-toggle-switch .toggle-slider:before {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
}
|
||||
|
||||
.provider-accordion__fields .provider-toggle-switch input:checked+.toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.provider-accordion__model-row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.provider-reasoning-field {
|
||||
grid-column: 1 / -1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.provider-reasoning-field.is-disabled {
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.provider-reasoning-field select:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.provider-accordion__fields {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -2714,6 +2849,13 @@
|
||||
.main-toggle-option {
|
||||
padding: 16px;
|
||||
}
|
||||
.provider-accordion__fields .form-group .provider-toggle-label {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.provider-accordion__fields .provider-toggle-option {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* 复选框和单选框响应式 */
|
||||
.form-group input[type="checkbox"],
|
||||
@@ -3004,6 +3146,23 @@
|
||||
initModelRoleProviderListeners();
|
||||
});
|
||||
|
||||
function isConfigTruthy(value) {
|
||||
return value === true || value === 1 || value === 'true' || value === '1' || value === 'yes' || value === 'on';
|
||||
}
|
||||
|
||||
function syncOpenAIReasoningControls() {
|
||||
const checkbox = document.querySelector('input[name="openai_enable_reasoning"]');
|
||||
const reasoningField = document.querySelector('[data-openai-reasoning-field]');
|
||||
const reasoningSelect = document.querySelector('[data-openai-reasoning-select]');
|
||||
if (!checkbox || !reasoningField || !reasoningSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = checkbox.checked;
|
||||
reasoningSelect.disabled = !enabled;
|
||||
reasoningField.classList.toggle('is-disabled', !enabled);
|
||||
}
|
||||
|
||||
// provider accordion actions are handled via stopPropagation on buttons/radios
|
||||
|
||||
// 初始化搜索选择框
|
||||
@@ -3128,6 +3287,10 @@
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.name !== 'default_provider') {
|
||||
// 获取输入的值,不管是否为空
|
||||
if (input.type === 'checkbox') {
|
||||
config[input.name] = input.checked;
|
||||
return;
|
||||
}
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
config[input.name] = value;
|
||||
@@ -3187,6 +3350,22 @@
|
||||
let apiKey = config[`${provider}_api_key`];
|
||||
let baseUrl = config[`${provider}_base_url`];
|
||||
let model = config[`${provider}_model`];
|
||||
const hasResponsesToggle = Object.prototype.hasOwnProperty.call(config, 'openai_use_responses_api');
|
||||
let useResponsesApi = false;
|
||||
if (provider === 'openai' && hasResponsesToggle) {
|
||||
const rawResponsesValue = config['openai_use_responses_api'];
|
||||
useResponsesApi = isConfigTruthy(rawResponsesValue);
|
||||
}
|
||||
const hasReasoningToggle = Object.prototype.hasOwnProperty.call(config, 'openai_enable_reasoning');
|
||||
let enableReasoning = false;
|
||||
if (provider === 'openai' && hasReasoningToggle) {
|
||||
enableReasoning = isConfigTruthy(config['openai_enable_reasoning']);
|
||||
}
|
||||
const hasReasoningEffort = Object.prototype.hasOwnProperty.call(config, 'openai_reasoning_effort');
|
||||
let reasoningEffort = 'medium';
|
||||
if (provider === 'openai' && hasReasoningEffort && config['openai_reasoning_effort']) {
|
||||
reasoningEffort = String(config['openai_reasoning_effort']).trim().toLowerCase();
|
||||
}
|
||||
|
||||
// 如果前端没有填写API Key,尝试从后端获取
|
||||
if (!apiKey || !baseUrl || !model) {
|
||||
@@ -3202,6 +3381,16 @@
|
||||
if (!model && configResult.success && configResult.config && configResult.config[`${provider}_model`]) {
|
||||
model = configResult.config[`${provider}_model`];
|
||||
}
|
||||
if (provider === 'openai' && !hasResponsesToggle && configResult.success && configResult.config) {
|
||||
const rawResponsesValue = configResult.config['openai_use_responses_api'];
|
||||
useResponsesApi = isConfigTruthy(rawResponsesValue);
|
||||
}
|
||||
if (provider === 'openai' && !hasReasoningToggle && configResult.success && configResult.config) {
|
||||
enableReasoning = isConfigTruthy(configResult.config['openai_enable_reasoning']);
|
||||
}
|
||||
if (provider === 'openai' && !hasReasoningEffort && configResult.success && configResult.config && configResult.config['openai_reasoning_effort']) {
|
||||
reasoningEffort = String(configResult.config['openai_reasoning_effort']).trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3234,7 +3423,10 @@
|
||||
body: JSON.stringify({
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
model: model
|
||||
model: model,
|
||||
use_responses_api: provider === 'openai' ? useResponsesApi : false,
|
||||
enable_reasoning: provider === 'openai' ? enableReasoning : false,
|
||||
reasoning_effort: provider === 'openai' ? reasoningEffort : 'medium'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -3779,9 +3971,12 @@
|
||||
|
||||
inputs.forEach(input => {
|
||||
const configKey = input.name;
|
||||
if (currentConfig[configKey]) {
|
||||
if (currentConfig[configKey] !== undefined) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = currentConfig[configKey] === 'true';
|
||||
const normalized = typeof currentConfig[configKey] === 'string'
|
||||
? currentConfig[configKey].toLowerCase()
|
||||
: currentConfig[configKey];
|
||||
input.checked = normalized === true || normalized === 1 || normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
||||
} else if (input.type === 'password') {
|
||||
// 对于密码字段,如果有配置值且输入框为空,则显示配置值
|
||||
if (!input.value && currentConfig[configKey]) {
|
||||
@@ -3800,6 +3995,8 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
syncOpenAIReasoningControls();
|
||||
}
|
||||
|
||||
// 切换模型任务配置显示
|
||||
@@ -4220,12 +4417,18 @@
|
||||
const inputs = card.querySelectorAll('input, select');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value) {
|
||||
if (input.name) {
|
||||
// 跳过 default_provider radio 按钮,因为它通过专门的 API 处理
|
||||
if (input.name === 'default_provider') {
|
||||
return;
|
||||
}
|
||||
config[input.name] = input.value;
|
||||
if (input.type === 'checkbox') {
|
||||
config[input.name] = input.checked;
|
||||
return;
|
||||
}
|
||||
if (input.value) {
|
||||
config[input.name] = input.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4409,12 +4612,18 @@
|
||||
document.querySelectorAll('.provider-config-card').forEach(card => {
|
||||
const inputs = card.querySelectorAll('input, select');
|
||||
inputs.forEach(input => {
|
||||
if (input.name && input.value) {
|
||||
if (input.name) {
|
||||
// 跳过 default_provider radio 按钮,因为它通过专门的 API 处理
|
||||
if (input.name === 'default_provider') {
|
||||
return;
|
||||
}
|
||||
allConfig[input.name] = input.value;
|
||||
if (input.type === 'checkbox') {
|
||||
allConfig[input.name] = input.checked;
|
||||
return;
|
||||
}
|
||||
if (input.value) {
|
||||
allConfig[input.name] = input.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ class Settings:
|
||||
# API配置
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
openai_use_responses_api: bool = False
|
||||
openai_enable_reasoning: bool = False
|
||||
openai_reasoning_effort: str = "medium"
|
||||
anthropic_api_key: Optional[str] = None
|
||||
azure_openai_api_key: Optional[str] = None
|
||||
azure_openai_endpoint: Optional[str] = None
|
||||
@@ -75,6 +78,9 @@ class Settings:
|
||||
kwargs["api_key"] = self.openai_api_key
|
||||
if self.openai_base_url:
|
||||
kwargs["base_url"] = self.openai_base_url
|
||||
kwargs["use_responses_api"] = self.openai_use_responses_api
|
||||
kwargs["enable_reasoning"] = self.openai_enable_reasoning
|
||||
kwargs["reasoning_effort"] = self.openai_reasoning_effort
|
||||
elif self.llm_provider == "anthropic":
|
||||
if self.anthropic_api_key:
|
||||
kwargs["api_key"] = self.anthropic_api_key
|
||||
@@ -177,6 +183,9 @@ def load_settings(
|
||||
env_mappings = {
|
||||
"OPENAI_API_KEY": "openai_api_key",
|
||||
"OPENAI_BASE_URL": "openai_base_url",
|
||||
"OPENAI_USE_RESPONSES_API": "openai_use_responses_api",
|
||||
"OPENAI_ENABLE_REASONING": "openai_enable_reasoning",
|
||||
"OPENAI_REASONING_EFFORT": "openai_reasoning_effort",
|
||||
"OPENAI_MODEL": "llm_model", # 支持OPENAI_MODEL环境变量
|
||||
"ANTHROPIC_API_KEY": "anthropic_api_key",
|
||||
"AZURE_OPENAI_API_KEY": "azure_openai_api_key",
|
||||
@@ -211,7 +220,7 @@ def load_settings(
|
||||
except ValueError:
|
||||
logger.warning(f"无效的浮点值 {env_key}={env_value}")
|
||||
continue
|
||||
elif attr_name == "debug_mode":
|
||||
elif attr_name in {"debug_mode", "openai_use_responses_api", "openai_enable_reasoning"}:
|
||||
env_value = env_value.lower() in ("true", "1", "yes", "on")
|
||||
|
||||
setattr(settings, attr_name, env_value)
|
||||
@@ -250,6 +259,9 @@ def create_env_template():
|
||||
"""创建环境变量模板文件"""
|
||||
template_content = """# LLM API Keys
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
# OPENAI_USE_RESPONSES_API=false
|
||||
# OPENAI_ENABLE_REASONING=false
|
||||
# OPENAI_REASONING_EFFORT=medium
|
||||
# OPENAI_BASE_URL=https://api.openai.com/v1 # 自定义OpenAI API端点(可选)
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
|
||||
@@ -304,6 +304,23 @@ class LLMManager:
|
||||
|
||||
def __init__(self):
|
||||
self._llm_cache: Dict[str, BaseChatModel] = {}
|
||||
|
||||
@staticmethod
|
||||
def _is_truthy(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"true", "1", "yes", "on"}
|
||||
return bool(value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_reasoning_effort(effort: Any, use_responses_api: bool) -> str:
|
||||
normalized = str(effort or "medium").strip().lower()
|
||||
if normalized in {"none", "minimal", "low", "medium", "high", "xhigh"}:
|
||||
return normalized
|
||||
return "medium"
|
||||
|
||||
def get_llm(
|
||||
self,
|
||||
@@ -331,6 +348,13 @@ class LLMManager:
|
||||
ImportError: 缺少必要的依赖
|
||||
"""
|
||||
cache_key = f"{provider}:{model}:{temperature}:{max_tokens}"
|
||||
if provider == "openai":
|
||||
cache_key = (
|
||||
f"{cache_key}:base_url={kwargs.get('base_url') or os.getenv('OPENAI_BASE_URL') or ''}"
|
||||
f":responses={self._is_truthy(kwargs['use_responses_api']) if 'use_responses_api' in kwargs else self._is_truthy(os.getenv('OPENAI_USE_RESPONSES_API'))}"
|
||||
f":reasoning={self._is_truthy(kwargs['enable_reasoning']) if 'enable_reasoning' in kwargs else self._is_truthy(os.getenv('OPENAI_ENABLE_REASONING'))}"
|
||||
f":reasoning_effort={kwargs.get('reasoning_effort') or os.getenv('OPENAI_REASONING_EFFORT') or 'medium'}"
|
||||
)
|
||||
|
||||
if cache_key in self._llm_cache:
|
||||
return self._llm_cache[cache_key]
|
||||
@@ -390,6 +414,22 @@ class LLMManager:
|
||||
|
||||
# 处理自定义base_url
|
||||
base_url = kwargs.get("base_url") or os.getenv("OPENAI_BASE_URL")
|
||||
raw_use_responses_api = (
|
||||
kwargs["use_responses_api"]
|
||||
if "use_responses_api" in kwargs
|
||||
else os.getenv("OPENAI_USE_RESPONSES_API")
|
||||
)
|
||||
use_responses_api = self._is_truthy(raw_use_responses_api)
|
||||
raw_enable_reasoning = (
|
||||
kwargs["enable_reasoning"]
|
||||
if "enable_reasoning" in kwargs
|
||||
else os.getenv("OPENAI_ENABLE_REASONING")
|
||||
)
|
||||
enable_reasoning = self._is_truthy(raw_enable_reasoning)
|
||||
reasoning_effort = self._normalize_reasoning_effort(
|
||||
kwargs.get("reasoning_effort") or os.getenv("OPENAI_REASONING_EFFORT"),
|
||||
use_responses_api,
|
||||
)
|
||||
|
||||
# 构建参数
|
||||
openai_kwargs = {
|
||||
@@ -397,7 +437,13 @@ class LLMManager:
|
||||
"temperature": temperature,
|
||||
# "max_tokens": max_tokens,
|
||||
"api_key": api_key,
|
||||
"use_responses_api": use_responses_api,
|
||||
}
|
||||
if enable_reasoning:
|
||||
if use_responses_api:
|
||||
openai_kwargs["reasoning"] = {"effort": reasoning_effort}
|
||||
else:
|
||||
openai_kwargs["reasoning_effort"] = reasoning_effort
|
||||
|
||||
# 添加base_url(如果提供)
|
||||
if base_url:
|
||||
@@ -405,7 +451,13 @@ class LLMManager:
|
||||
logger.info(f"使用自定义OpenAI端点: {base_url}")
|
||||
|
||||
# 添加其他参数(排除已处理的)
|
||||
excluded_keys = {"api_key", "base_url"}
|
||||
excluded_keys = {
|
||||
"api_key",
|
||||
"base_url",
|
||||
"use_responses_api",
|
||||
"enable_reasoning",
|
||||
"reasoning_effort",
|
||||
}
|
||||
openai_kwargs.update({k: v for k, v in kwargs.items() if k not in excluded_keys})
|
||||
|
||||
return ChatOpenAI(**openai_kwargs)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from dotenv import load_dotenv
|
||||
import pytest
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parents[1] / ".env")
|
||||
os.environ["DEBUG"] = "false"
|
||||
|
||||
from landppt import main as main_module
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_swallows_cancelled_error_and_runs_shutdown():
|
||||
startup = AsyncMock()
|
||||
shutdown = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(main_module, "startup_application", new=startup),
|
||||
patch.object(main_module, "shutdown_application", new=shutdown),
|
||||
):
|
||||
context_manager = main_module.lifespan(main_module.app)
|
||||
await context_manager.__aenter__()
|
||||
suppressed = await context_manager.__aexit__(
|
||||
asyncio.CancelledError,
|
||||
asyncio.CancelledError(),
|
||||
None,
|
||||
)
|
||||
|
||||
assert suppressed is True
|
||||
startup.assert_awaited_once()
|
||||
shutdown.assert_awaited_once()
|
||||
Reference in New Issue
Block a user