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:
sligter
2026-03-06 23:29:46 +08:00
parent 346f43eba4
commit 4d5ed16cc2
14 changed files with 853 additions and 110 deletions
+3
View File
@@ -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
+3
View File
@@ -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=
+3
View File
@@ -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
View File
@@ -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
+77 -14
View File
@@ -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()
+18
View File
@@ -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
View File
@@ -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",
)
+25
View File
@@ -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
View File
@@ -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()
+218 -9
View File
@@ -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;
}
}
});
});
+13 -1
View File
@@ -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
+53 -1
View File
@@ -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)
+34
View File
@@ -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()