Add Anthropic reasoning controls and completion fixes

- Pass Anthropic reasoning effort through env, settings, tests, and UI
- Allow max reasoning effort for OpenAI and Anthropic provider tests
- Open generated scripts directly and download narration without popup tabs
This commit is contained in:
sligter
2026-08-02 13:54:10 +08:00
parent a6d73b83a7
commit 564c9006ee
21 changed files with 278 additions and 111 deletions
+2
View File
@@ -40,6 +40,8 @@ OPENAI_REASONING_EFFORT=medium
# Get your API key from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here
ANTHROPIC_MODEL=claude-3-haiku-20240307
ANTHROPIC_ENABLE_REASONING=false
ANTHROPIC_REASONING_EFFORT=high
# Google Gemini Configuration
# Get your API key from: https://aistudio.google.com/app/apikey
+26 -1
View File
@@ -104,7 +104,7 @@ def strip_think_content(content: str) -> str:
class OpenAIProvider(AIProvider):
"""OpenAI API provider"""
SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
@@ -576,8 +576,12 @@ class AzureOpenAIProvider(OpenAIProvider):
class AnthropicProvider(AIProvider):
"""Anthropic Claude API provider"""
SUPPORTED_REASONING_EFFORTS = {"low", "medium", "high", "xhigh", "max"}
def __init__(self, config: Dict[str, Any]):
super().__init__(config)
self.enable_reasoning = OpenAIProvider._coerce_bool(config.get("enable_reasoning"))
self.reasoning_effort = self._normalize_reasoning_effort(config.get("reasoning_effort")) or "high"
try:
import anthropic
base_url = config.get("base_url")
@@ -604,6 +608,24 @@ class AnthropicProvider(AIProvider):
logger.warning("Anthropic library not installed. Install with: pip install anthropic")
self.client = None
@classmethod
def _normalize_reasoning_effort(cls, value: Any) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip().lower()
return normalized if normalized in cls.SUPPORTED_REASONING_EFFORTS else None
def _apply_reasoning_config(self, request_kwargs: Dict[str, Any], config: Dict[str, Any]) -> None:
if not OpenAIProvider._coerce_bool(config.get("enable_reasoning", self.enable_reasoning)):
return
effort = (
self._normalize_reasoning_effort(config.get("reasoning_effort"))
or self.reasoning_effort
or "high"
)
request_kwargs["output_config"] = {"effort": effort}
def _convert_message_to_anthropic(self, message: AIMessage) -> Dict[str, Any]:
"""Convert AIMessage to Anthropic format, supporting multimodal content"""
if message.role == MessageRole.TOOL:
@@ -769,6 +791,8 @@ class AnthropicProvider(AIProvider):
if name:
request_kwargs["tool_choice"] = {"type": "tool", "name": name}
self._apply_reasoning_config(request_kwargs, config)
try:
response = await self.client.messages.create(**request_kwargs)
content, tool_calls, finish_reason, usage = self._extract_anthropic_response(response)
@@ -866,6 +890,7 @@ class AnthropicProvider(AIProvider):
}
if system_message:
body["system"] = system_message
self._apply_reasoning_config(body, config)
# Try both authentication methods
auth_methods = [
+12
View File
@@ -76,6 +76,8 @@ class AIConfig(BaseSettings):
anthropic_api_key: Optional[str] = Field(default=None, env="ANTHROPIC_API_KEY")
anthropic_base_url: str = Field(default="https://api.anthropic.com", env="ANTHROPIC_BASE_URL")
anthropic_model: str = Field(default="claude-3-haiku-20240307", env="ANTHROPIC_MODEL")
anthropic_enable_reasoning: bool = Field(default=False, env="ANTHROPIC_ENABLE_REASONING")
anthropic_reasoning_effort: str = Field(default="high", env="ANTHROPIC_REASONING_EFFORT")
# Google Gemini Configuration
google_api_key: Optional[str] = Field(default=None, env="GOOGLE_API_KEY")
@@ -298,6 +300,8 @@ class AIConfig(BaseSettings):
"api_key": self.anthropic_api_key,
"base_url": self.anthropic_base_url,
"model": self.anthropic_model,
"enable_reasoning": self.anthropic_enable_reasoning,
"reasoning_effort": self.anthropic_reasoning_effort,
"max_tokens": self.max_tokens,
"temperature": self.temperature,
"top_p": self.top_p,
@@ -424,6 +428,14 @@ def reload_ai_config():
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)
ai_config.anthropic_enable_reasoning = os.environ.get(
'ANTHROPIC_ENABLE_REASONING',
str(ai_config.anthropic_enable_reasoning),
).lower() == 'true'
ai_config.anthropic_reasoning_effort = os.environ.get(
'ANTHROPIC_REASONING_EFFORT',
ai_config.anthropic_reasoning_effort,
)
ai_config.google_api_key = os.environ.get('GOOGLE_API_KEY', ai_config.google_api_key)
ai_config.google_base_url = os.environ.get('GOOGLE_BASE_URL', ai_config.google_base_url)
ai_config.google_model = os.environ.get('GOOGLE_MODEL', ai_config.google_model)
+2
View File
@@ -45,6 +45,8 @@ class ConfigService:
"anthropic_api_key": {"type": "password", "category": "ai_providers"},
"anthropic_base_url": {"type": "url", "category": "ai_providers", "default": "https://api.anthropic.com"},
"anthropic_model": {"type": "select", "category": "ai_providers", "default": "claude-3.5-haiku-20240307"},
"anthropic_enable_reasoning": {"type": "boolean", "category": "ai_providers", "default": "false"},
"anthropic_reasoning_effort": {"type": "select", "category": "ai_providers", "default": "high"},
"google_api_key": {"type": "password", "category": "ai_providers"},
"google_base_url": {"type": "url", "category": "ai_providers", "default": "https://generativelanguage.googleapis.com"},
@@ -51,6 +51,8 @@ class DatabaseConfigService:
"anthropic_api_key": {"type": "password", "category": "ai_providers"},
"anthropic_base_url": {"type": "url", "category": "ai_providers", "default": "https://api.anthropic.com"},
"anthropic_model": {"type": "select", "category": "ai_providers", "default": "claude-3.5-haiku-20240307"},
"anthropic_enable_reasoning": {"type": "boolean", "category": "ai_providers", "default": "false"},
"anthropic_reasoning_effort": {"type": "select", "category": "ai_providers", "default": "high"},
"google_api_key": {"type": "password", "category": "ai_providers"},
"google_base_url": {"type": "url", "category": "ai_providers", "default": "https://generativelanguage.googleapis.com"},
@@ -705,6 +707,8 @@ def _build_user_ai_provider_config(
"api_key": user_config.get("anthropic_api_key"),
"base_url": user_config.get("anthropic_base_url"),
"model": user_config.get("anthropic_model"),
"enable_reasoning": user_config.get("anthropic_enable_reasoning"),
"reasoning_effort": user_config.get("anthropic_reasoning_effort"),
"llm_timeout_seconds": user_config.get("llm_timeout_seconds"),
},
"google": {
+5 -2
View File
@@ -94,12 +94,15 @@ def build_anthropic_messages_url(base_url: str) -> str:
return f"{normalize_anthropic_test_base_url(base_url)}/messages"
def build_anthropic_test_payload(prompt: str) -> Dict[str, Any]:
def build_anthropic_test_payload(prompt: str, reasoning_effort: str = "") -> Dict[str, Any]:
"""Anthropic 测试使用官方 messages 协议。"""
return {
payload = {
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
}
if reasoning_effort:
payload["output_config"] = {"effort": reasoning_effort}
return payload
def extract_anthropic_test_result(data: Dict[str, Any]) -> Tuple[str, Dict[str, int]]:
@@ -122,6 +122,8 @@ class RuntimeConfigService:
elif provider == 'anthropic':
api_key = user_config.get('anthropic_api_key')
base_url = user_config.get('anthropic_base_url')
enable_reasoning = bool(user_config.get('anthropic_enable_reasoning'))
reasoning_effort = str(user_config.get('anthropic_reasoning_effort') or 'high')
elif provider == 'google' or provider == 'gemini':
api_key = user_config.get('google_api_key')
base_url = user_config.get('google_base_url')
+29 -21
View File
@@ -78,6 +78,18 @@ def _coerce_bool(value: Any) -> bool:
return str(value).strip().lower() in {"1", "true", "yes", "on"}
_REASONING_EFFORTS = {
"openai": {"none", "minimal", "low", "medium", "high", "xhigh", "max"},
"anthropic": {"low", "medium", "high", "xhigh", "max"},
}
def _normalize_reasoning_effort(provider: str, value: Any) -> str:
default = "high" if provider == "anthropic" else "medium"
normalized = str(value or default).strip().lower()
return normalized if normalized in _REASONING_EFFORTS.get(provider, set()) else default
@router.get("/home", response_class=HTMLResponse)
async def web_home(
request: Request,
@@ -218,6 +230,8 @@ async def get_ai_providers_config(
"anthropic_api_key": current_config.get("anthropic_api_key", ""),
"anthropic_base_url": current_config.get("anthropic_base_url", ""),
"anthropic_model": current_config.get("anthropic_model", ""),
"anthropic_enable_reasoning": current_config.get("anthropic_enable_reasoning", False),
"anthropic_reasoning_effort": current_config.get("anthropic_reasoning_effort", "high"),
"google_api_key": current_config.get("google_api_key", ""),
"google_base_url": current_config.get("google_base_url", ""),
"google_model": current_config.get("google_model", ""),
@@ -611,10 +625,6 @@ async def test_provider_connection(
else:
config = await config_service.get_all_config(user_id=user.id)
def _normalize_reasoning_effort(value: Any) -> str:
normalized = str(value or "medium").strip().lower()
return normalized if normalized in {"none", "minimal", "low", "medium", "high", "xhigh"} else "medium"
api_key = data.get('api_key') or config.get(f"{provider}_api_key", "")
base_url = data.get('base_url') or config.get(f"{provider}_base_url", "")
model = data.get('model') or config.get(f"{provider}_model", "")
@@ -626,12 +636,12 @@ async def test_provider_connection(
enable_reasoning = (
_coerce_bool(data.get("enable_reasoning"))
if "enable_reasoning" in data
else _coerce_bool(config.get("openai_enable_reasoning"))
else _coerce_bool(config.get(f"{provider}_enable_reasoning"))
)
reasoning_effort = (
_normalize_reasoning_effort(data.get("reasoning_effort"))
_normalize_reasoning_effort(provider, data.get("reasoning_effort"))
if "reasoning_effort" in data
else _normalize_reasoning_effort(config.get("openai_reasoning_effort"))
else _normalize_reasoning_effort(provider, config.get(f"{provider}_reasoning_effort"))
)
if not api_key:
@@ -731,7 +741,10 @@ async def test_provider_connection(
if provider == "anthropic":
request_url = build_anthropic_messages_url(base_url)
payload = build_anthropic_test_payload("Hi")
payload = build_anthropic_test_payload(
"Hi",
reasoning_effort if enable_reasoning else "",
)
payload["model"] = model
logger.info("Testing %s connection at: %s (transport=anthropic_messages)", provider, request_url)
@@ -934,9 +947,7 @@ async def test_openai_provider_proxy(
model = data.get('model', 'gpt-4o')
use_responses_api = str(data.get("use_responses_api", "")).strip().lower() in {"1", "true", "yes", "on"}
enable_reasoning = str(data.get("enable_reasoning", "")).strip().lower() in {"1", "true", "yes", "on"}
reasoning_effort = str(data.get("reasoning_effort") or "medium").strip().lower()
if reasoning_effort not in {"none", "minimal", "low", "medium", "high", "xhigh"}:
reasoning_effort = "medium"
reasoning_effort = _normalize_reasoning_effort("openai", data.get("reasoning_effort"))
logger.info(f"Frontend requested test with: base_url={base_url}, model={model}")
@@ -1060,6 +1071,8 @@ async def test_anthropic_provider_proxy(
base_url = data.get('base_url', 'https://api.anthropic.com')
api_key = data.get('api_key', '')
model = data.get('model', 'claude-3-5-sonnet-20241022')
enable_reasoning = _coerce_bool(data.get("enable_reasoning"))
reasoning_effort = _normalize_reasoning_effort("anthropic", data.get("reasoning_effort"))
logger.info(f"Frontend requested Anthropic test with: base_url={base_url}, model={model}")
@@ -1086,16 +1099,11 @@ async def test_anthropic_provider_proxy(
'anthropic-version': '2023-06-01'
}
payload = {
"model": model,
"messages": [
{
"role": "user",
"content": "Say 'Hello, I am working!' in exactly 5 words."
}
],
"temperature": 0
}
payload = build_anthropic_test_payload(
"Say 'Hello, I am working!' in exactly 5 words.",
reasoning_effort if enable_reasoning else "",
)
payload["model"] = model
async with session.post(
messages_url,
@@ -280,10 +280,6 @@ function getNarrationLanguage() {
async function exportNarrationVideo() {
try {
// Open a blank tab synchronously (user gesture) to avoid popup blockers when we later
// navigate to the download URL after async task completion.
const downloadWindow = window.open('about:blank', '_blank');
const lang = getNarrationLanguage();
const fps = getNarrationFps();
const renderMode = getNarrationRenderMode();
@@ -322,27 +318,10 @@ function getNarrationLanguage() {
closeProgressToast(progressToast);
const downloadUrl = taskData.download_url || `/api/landppt/tasks/${taskId}/download`;
if (downloadWindow && !downloadWindow.closed) {
try {
downloadWindow.location.href = downloadUrl;
setTimeout(() => {
try { downloadWindow.close(); } catch (_) { }
}, 3000);
} catch (_) {
// Fall back to same-tab download trigger
if (typeof triggerFileDownload === 'function') {
triggerFileDownload(downloadUrl);
} else {
window.location.href = downloadUrl;
}
}
if (typeof triggerFileDownload === 'function') {
triggerFileDownload(downloadUrl);
} else {
// Pop-up blocked: use same-tab download trigger.
if (typeof triggerFileDownload === 'function') {
triggerFileDownload(downloadUrl);
} else {
window.location.href = downloadUrl;
}
window.location.href = downloadUrl;
}
showNotification('讲解视频导出完成,已开始下载', 'success');
} catch (error) {
@@ -410,8 +410,7 @@
setTimeout(() => {
closeProgressToast(progressToast);
showNotification('演讲稿生成完成!', 'success');
console.log('Starting to poll for speech scripts...');
pollForSpeechScripts();
showCurrentSpeechScripts();
}, 500);
return true;
}
@@ -465,14 +464,11 @@
clearInterval(interval);
updateProgressToast(progressToast, progress.message, 100);
// Close progress toast and start polling for data
// Close progress toast and display the committed scripts
setTimeout(() => {
closeProgressToast(progressToast);
showNotification('演讲稿生成完成!', 'success');
// 使用轮询机制等待数据库数据可用
console.log('Starting to poll for speech scripts...');
pollForSpeechScripts();
showCurrentSpeechScripts();
}, 500);
} else if (progress.status === 'failed') {
@@ -716,7 +712,7 @@
generation_type: generationType,
slide_indices: slideIndices,
customization: customization,
language: (document.getElementById('speechLanguage')?.value || 'zh')
language: languageValue
})
});
@@ -973,49 +973,6 @@
}
}
async function pollForSpeechScripts() {
const maxAttempts = 30; // 最多尝试30次
const pollInterval = 1000; // 每1000ms查询一次
let attempts = 0;
const poll = async () => {
attempts++;
console.log(`Polling attempt ${attempts}/${maxAttempts}...`);
try {
const lang = getSpeechLanguage();
const response = await fetch(`/api/projects/${window.landpptEditorConfig.projectId}/speech-scripts?language=${encodeURIComponent(lang)}`);
const result = await response.json();
if (result.success && result.scripts && result.scripts.length > 0) {
console.log(`Found ${result.scripts.length} scripts, displaying...`);
// 找到数据,显示演讲稿
showCurrentSpeechScripts();
return;
}
// 如果还没到最大尝试次数,继续轮询
if (attempts < maxAttempts) {
setTimeout(poll, pollInterval);
} else {
console.warn('Polling timeout: No scripts found after max attempts');
showNotification('获取演讲稿超时,请手动刷新页面后查看', 'warning');
}
} catch (error) {
console.error('Polling error:', error);
// 出错继续尝试
if (attempts < maxAttempts) {
setTimeout(poll, pollInterval);
} else {
showNotification('获取演讲稿失败,请刷新页面重试', 'error');
}
}
};
// 开始轮询
poll();
}
async function showCurrentSpeechScripts() {
try {
// Close current dialog
@@ -178,6 +178,7 @@
<option value="medium" {% if openai_reasoning_effort == 'medium' %}selected{% endif %}>medium</option>
<option value="high" {% if openai_reasoning_effort == 'high' %}selected{% endif %}>high</option>
<option value="xhigh" {% if openai_reasoning_effort == 'xhigh' %}selected{% endif %}>xhigh</option>
<option value="max" {% if openai_reasoning_effort == 'max' %}selected{% endif %}>max</option>
</select>
<div style="margin-top: 6px; color: #777; font-size: 0.82em;">
不同 OpenAI 模型支持的档位不同;若模型不支持,测试或调用会返回参数错误。
@@ -215,6 +216,33 @@
placeholder="https://api.anthropic.com"
style="width: 100%; padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em;">
</div>
<div class="form-group" style="margin: 0;">
<label
style="display: inline-flex; align-items: center; gap: 10px; cursor: pointer; font-size: 0.9em; color: #333;">
<input type="checkbox" name="anthropic_enable_reasoning" {% if
current_config.get('anthropic_enable_reasoning') %}checked{% endif %}>
<span>启用推理程度参数</span>
</label>
<div style="margin-top: 6px; color: #777; font-size: 0.82em;">
开启后会在 Anthropic 请求中附带 output_config.effort;关闭时保持模型默认行为。
</div>
</div>
<div class="form-group" style="margin: 0;">
<label
style="font-size: 0.85em; color: #555; margin-bottom: 4px; display: block;">推理程度</label>
{% set anthropic_reasoning_effort = current_config.get('anthropic_reasoning_effort', 'high') %}
<select name="anthropic_reasoning_effort" {% if not current_config.get('anthropic_enable_reasoning') %}disabled{% endif %}
style="width: 100%; padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 0.9em; background: #fff;">
<option value="low" {% if anthropic_reasoning_effort == 'low' %}selected{% endif %}>low</option>
<option value="medium" {% if anthropic_reasoning_effort == 'medium' %}selected{% endif %}>medium</option>
<option value="high" {% if anthropic_reasoning_effort == 'high' %}selected{% endif %}>high</option>
<option value="xhigh" {% if anthropic_reasoning_effort == 'xhigh' %}selected{% endif %}>xhigh</option>
<option value="max" {% if anthropic_reasoning_effort == 'max' %}selected{% endif %}>max</option>
</select>
<div style="margin-top: 6px; color: #777; font-size: 0.82em;">
不同 Anthropic 模型支持的档位不同;若模型不支持,测试或调用会返回参数错误。
</div>
</div>
<div class="form-group" style="margin: 0;">
<label
style="font-size: 0.85em; color: #555; margin-bottom: 4px; display: block;">默认模型</label>
@@ -12,6 +12,7 @@
// 检查图片服务状态
checkImageServiceStatus();
initOpenAIReasoningControls();
initAnthropicReasoningControls();
pruneEmptyProviderFormGroups();
bindSearxngHostSync();
});
@@ -280,6 +281,24 @@
syncOpenAIReasoningControls();
}
function syncAnthropicReasoningControls() {
const reasoningToggle = document.querySelector('input[name="anthropic_enable_reasoning"]');
const reasoningEffortSelect = document.querySelector('select[name="anthropic_reasoning_effort"]');
if (!reasoningToggle || !reasoningEffortSelect) return;
reasoningEffortSelect.disabled = !reasoningToggle.checked;
reasoningEffortSelect.style.opacity = reasoningToggle.checked ? '1' : '0.65';
}
function initAnthropicReasoningControls() {
const reasoningToggle = document.querySelector('input[name="anthropic_enable_reasoning"]');
if (reasoningToggle && reasoningToggle.dataset.bound !== 'true') {
reasoningToggle.addEventListener('change', syncAnthropicReasoningControls);
reasoningToggle.dataset.bound = 'true';
}
syncAnthropicReasoningControls();
}
function pruneEmptyProviderFormGroups() {
document.querySelectorAll('.provider-accordion-item .provider-content .form-group').forEach(group => {
const hasControl = group.querySelector('input, select, textarea, .model-select-wrapper, .model-dropdown');
@@ -361,6 +380,7 @@
enhanceOpenAIProviderUI();
syncOpenAIReasoningControls();
syncAnthropicReasoningControls();
pruneEmptyProviderFormGroups();
}
@@ -1622,6 +1642,15 @@
if (reasoningEffortInput?.value) {
payload.reasoning_effort = reasoningEffortInput.value;
}
} else if (provider === 'anthropic') {
const reasoningToggleInput = document.querySelector('input[name="anthropic_enable_reasoning"]');
if (reasoningToggleInput) {
payload.enable_reasoning = reasoningToggleInput.checked;
}
const reasoningEffortInput = document.querySelector('select[name="anthropic_reasoning_effort"]');
if (reasoningEffortInput?.value) {
payload.reasoning_effort = reasoningEffortInput.value;
}
}
const response = await fetch('/api/ai/providers/test', {
@@ -763,14 +763,14 @@
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.tools.js?v=20260402-split"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.imageLibrary.js?v=20260402-split"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.bulletEnhance.js?v=20260402-split"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.speechScriptsDialog.js?v=20260402-split"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.speechScriptsManage.js?v=20260509-custom-tts-api-v1"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.speechScriptsDialog.js?v=20260802-auto-result-v1"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.speechScriptsManage.js?v=20260802-auto-result-v1"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.aiOptimize.js?v=20260614-modalui3"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportBase.js?v=20260425-layer-clip-v21"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportIcons.js?v=20260402-split"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportRender.js?v=20260709-hybrid-raster-v2"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.exportPptx.js?v=20260707-hybrid-raster-v1"></script>
<script src="/static/js/pages/project/slides_editor/projectEditorNarration.js?v=20260509-custom-tts-api-v1"></script>
<script src="/static/js/pages/project/slides_editor/projectEditorNarration.js?v=20260802-auto-result-v1"></script>
<script src="/static/js/pages/project/slides_editor/projectSlidesEditor.init.js?v=20260402-split"></script>
<!-- Export PPTX Progress Overlay -->
+25 -3
View File
@@ -38,6 +38,8 @@ class Settings:
openai_enable_reasoning: bool = False
openai_reasoning_effort: str = "medium"
anthropic_api_key: Optional[str] = None
anthropic_enable_reasoning: bool = False
anthropic_reasoning_effort: str = "high"
azure_openai_api_key: Optional[str] = None
azure_openai_endpoint: Optional[str] = None
azure_openai_api_version: str = "2024-02-15-preview"
@@ -68,8 +70,16 @@ class Settings:
max_tokens=self.max_tokens,
target_language=target_language,
use_responses_api=self.openai_use_responses_api if self.llm_provider == "openai" else False,
enable_reasoning=self.openai_enable_reasoning if self.llm_provider == "openai" else False,
reasoning_effort=self.openai_reasoning_effort if self.llm_provider == "openai" else "medium",
enable_reasoning=(
self.openai_enable_reasoning if self.llm_provider == "openai"
else self.anthropic_enable_reasoning if self.llm_provider == "anthropic"
else False
),
reasoning_effort=(
self.openai_reasoning_effort if self.llm_provider == "openai"
else self.anthropic_reasoning_effort if self.llm_provider == "anthropic"
else "medium"
),
)
def get_llm_kwargs(self) -> Dict[str, Any]:
@@ -89,6 +99,9 @@ class Settings:
elif self.llm_provider == "anthropic":
if self.anthropic_api_key:
kwargs["api_key"] = self.anthropic_api_key
if self.anthropic_enable_reasoning:
kwargs["enable_reasoning"] = True
kwargs["reasoning_effort"] = self.anthropic_reasoning_effort
# 检查环境变量中的ANTHROPIC_BASE_URL
anthropic_base_url = os.getenv("ANTHROPIC_BASE_URL")
if anthropic_base_url:
@@ -193,6 +206,8 @@ def load_settings(
"OPENAI_REASONING_EFFORT": "openai_reasoning_effort",
"OPENAI_MODEL": "llm_model", # 支持OPENAI_MODEL环境变量
"ANTHROPIC_API_KEY": "anthropic_api_key",
"ANTHROPIC_ENABLE_REASONING": "anthropic_enable_reasoning",
"ANTHROPIC_REASONING_EFFORT": "anthropic_reasoning_effort",
"AZURE_OPENAI_API_KEY": "azure_openai_api_key",
"AZURE_OPENAI_ENDPOINT": "azure_openai_endpoint",
"AZURE_OPENAI_API_VERSION": "azure_openai_api_version",
@@ -225,7 +240,12 @@ def load_settings(
except ValueError:
logger.warning(f"无效的浮点值 {env_key}={env_value}")
continue
elif attr_name in {"debug_mode", "openai_use_responses_api", "openai_enable_reasoning"}:
elif attr_name in {
"debug_mode",
"openai_use_responses_api",
"openai_enable_reasoning",
"anthropic_enable_reasoning",
}:
env_value = env_value.lower() in ("true", "1", "yes", "on")
setattr(settings, attr_name, env_value)
@@ -266,6 +286,8 @@ def create_env_template():
OPENAI_API_KEY=your_openai_api_key_here
# OPENAI_BASE_URL=https://api.openai.com/v1 # 自定义OpenAI API端点(可选)
ANTHROPIC_API_KEY=your_anthropic_api_key_here
ANTHROPIC_ENABLE_REASONING=false
ANTHROPIC_REASONING_EFFORT=high
# Azure OpenAI (if using Azure)
AZURE_OPENAI_API_KEY=your_azure_openai_api_key_here
@@ -0,0 +1,60 @@
import sys
import types
import pytest
from landppt.ai.base import AIMessage, MessageRole
from landppt.ai.providers import AnthropicProvider
from landppt.services.db_config_service import _build_user_ai_provider_config
@pytest.mark.asyncio
async def test_anthropic_reasoning_effort_reaches_messages_request(monkeypatch):
calls = []
class FakeMessages:
async def create(self, **kwargs):
calls.append(kwargs)
return types.SimpleNamespace(
model=kwargs["model"],
content=[types.SimpleNamespace(type="text", text="ok")],
stop_reason="end_turn",
usage=types.SimpleNamespace(input_tokens=1, output_tokens=1),
)
class FakeAsyncAnthropic:
def __init__(self, **kwargs):
self.messages = FakeMessages()
monkeypatch.setitem(
sys.modules,
"anthropic",
types.SimpleNamespace(AsyncAnthropic=FakeAsyncAnthropic),
)
config = _build_user_ai_provider_config(
{
"default_ai_provider": "anthropic",
"anthropic_api_key": "test-key",
"anthropic_base_url": "https://api.anthropic.com",
"anthropic_model": "claude-opus-4-6",
"anthropic_enable_reasoning": True,
"anthropic_reasoning_effort": "max",
}
)
config.pop("provider_name")
provider = AnthropicProvider(config)
await provider.chat_completion(
[AIMessage(role=MessageRole.USER, content="hello")],
tools=[{"name": "noop", "input_schema": {"type": "object"}}],
)
assert AnthropicProvider.SUPPORTED_REASONING_EFFORTS == {
"low",
"medium",
"high",
"xhigh",
"max",
}
assert calls[0]["output_config"] == {"effort": "max"}
+32
View File
@@ -0,0 +1,32 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _read(relative_path: str) -> str:
return (ROOT / relative_path).read_text(encoding="utf-8")
def test_generated_content_is_opened_or_downloaded_on_completion():
speech = _read(
"src/landppt/web/static/js/pages/project/slides_editor/"
"projectSlidesEditor.speechScriptsDialog.js"
)
narration = _read(
"src/landppt/web/static/js/pages/project/slides_editor/"
"projectEditorNarration.js"
)
speech_completion = speech.split("function startProgressTracking(", 1)[1].split(
"function startSingleScriptProgressTracking(", 1
)[0]
video_export = narration.split("async function exportNarrationVideo()", 1)[1].split(
"async function exportNarrationAudio()", 1
)[0]
assert speech_completion.count("showCurrentSpeechScripts();") == 2
assert "pollForSpeechScripts" not in speech_completion
assert "language: languageValue" in speech
assert "triggerFileDownload(downloadUrl);" in video_export
assert "window.open('about:blank'" not in video_export
+2 -2
View File
@@ -56,8 +56,8 @@ def test_narration_audio_export_ui_is_wired():
assert "triggerFileDownload(downloadUrl)" in narration_js_text
assert "导出讲解音频" in speech_manage_js_text
assert 'onclick="exportNarrationAudio()"' in speech_manage_js_text
assert "projectEditorNarration.js?v=20260509-custom-tts-api-v1" in template_text
assert "projectSlidesEditor.speechScriptsManage.js?v=20260509-custom-tts-api-v1" in template_text
assert "projectEditorNarration.js?v=20260802-auto-result-v1" in template_text
assert "projectSlidesEditor.speechScriptsManage.js?v=20260802-auto-result-v1" in template_text
def test_xiaomimimo_tts_provider_is_wired():
+3 -3
View File
@@ -106,10 +106,10 @@ async def test_openai_provider_chat_completion_uses_responses_api(monkeypatch):
{
"api_key": "test-key",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4.1",
"model": "gpt-5.6",
"use_responses_api": True,
"enable_reasoning": True,
"reasoning_effort": "high",
"reasoning_effort": "max",
}
)
@@ -123,7 +123,7 @@ async def test_openai_provider_chat_completion_uses_responses_api(monkeypatch):
assert response.metadata["transport"] == "responses"
assert len(instances) == 1
assert instances[0].responses.create_calls[0]["input"][0]["content"] == "hello"
assert instances[0].responses.create_calls[0]["reasoning"] == {"effort": "high"}
assert instances[0].responses.create_calls[0]["reasoning"] == {"effort": "max"}
assert "max_output_tokens" not in instances[0].responses.create_calls[0]
+6
View File
@@ -36,6 +36,12 @@ def test_anthropic_test_payload_does_not_set_output_token_limit():
assert "max_tokens" not in payload
def test_anthropic_test_payload_sets_reasoning_effort():
payload = build_anthropic_test_payload("Hi", "xhigh")
assert payload["output_config"] == {"effort": "xhigh"}
def test_extract_google_test_result_reads_text_and_usage():
response_text, usage = extract_google_test_result(
{
+1 -1
View File
@@ -162,4 +162,4 @@ def test_speech_script_humanize_route_and_ui_are_wired():
assert "showProgressToast(`正在处理第${slideIndex + 1}页演讲稿人话化...`, 0)" in js_text
assert "speech-script-humanize-btn" in js_text
assert "speechHumanizeAllBtn" in js_text
assert "projectSlidesEditor.speechScriptsManage.js?v=20260509-custom-tts-api-v1" in template_text
assert "projectSlidesEditor.speechScriptsManage.js?v=20260802-auto-result-v1" in template_text