Default Flash final review to off and persist the setting across the UI, MCP, SDK and queued tasks. Keep execution separate from test verdicts. Bundle UI fonts and licenses to remove Google Fonts from startup builds. Align backend/frontend versions and document installation and upgrades.
270 lines
12 KiB
Python
270 lines
12 KiB
Python
"""Persist the single Responses endpoint without returning credentials to the browser."""
|
|
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
from urllib.parse import urlsplit
|
|
|
|
from pydantic import BaseModel, Field, SecretStr, field_validator
|
|
|
|
from artemis.config.agent import load_agent_config
|
|
from artemis.config.paths import get_env_file
|
|
from artemis.config.settings import settings
|
|
|
|
_write_lock = threading.Lock()
|
|
|
|
|
|
class ModelSettingsUpdate(BaseModel):
|
|
base_url: str = "https://api-n-cd.playones.com/v1"
|
|
model: str = Field(min_length=1, max_length=200)
|
|
api_key: SecretStr | None = None # Omitted/blank keeps the existing key.
|
|
timeout_seconds: float = Field(default=300, gt=0, le=3600)
|
|
max_retries: int = Field(default=5, ge=0, le=10)
|
|
retry_interval_seconds: float = Field(default=10, ge=0, le=300)
|
|
rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600)
|
|
tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200)
|
|
tool_fallback_reasoning_effort: (
|
|
Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None
|
|
) = None
|
|
tool_fallback_max_retries: int | None = Field(default=None, ge=0, le=10)
|
|
tool_fallback_no_progress_attempts: int | None = Field(default=None, ge=1, le=10)
|
|
reasoning_effort: Literal[
|
|
"default", "none", "minimal", "low", "medium", "high", "xhigh", "max"
|
|
] = "high"
|
|
|
|
@field_validator("base_url")
|
|
@classmethod
|
|
def validate_base_url(cls, value: str) -> str:
|
|
value = value.strip().rstrip("/")
|
|
parsed = urlsplit(value)
|
|
if (
|
|
parsed.scheme not in ("https", "http")
|
|
or not parsed.hostname
|
|
or parsed.username
|
|
or parsed.password
|
|
or parsed.query
|
|
or parsed.fragment
|
|
):
|
|
raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段")
|
|
if value.endswith(("/responses", "/chat/completions")):
|
|
raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1")
|
|
return value
|
|
|
|
@field_validator("model", "tool_fallback_model")
|
|
@classmethod
|
|
def validate_model(cls, value: str | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
value = value.strip()
|
|
if not value or any(c in value for c in "\r\n\x00"):
|
|
raise ValueError("模型名称不能为空或包含换行")
|
|
return value
|
|
|
|
|
|
def get_model_settings() -> dict:
|
|
return {
|
|
"provider": "openai",
|
|
"api_mode": "responses",
|
|
"base_url": settings.OPENAI_BASE_URL or "https://api-n-cd.playones.com/v1",
|
|
"model": settings.OPENAI_MODEL,
|
|
"api_key_configured": bool(settings.OPENAI_API_KEY),
|
|
"timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS,
|
|
"max_retries": settings.OPENAI_MAX_RETRIES,
|
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
|
"rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS,
|
|
"tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL,
|
|
"tool_fallback_reasoning_effort": settings.OPENAI_TOOL_FALLBACK_REASONING_EFFORT,
|
|
"tool_fallback_max_retries": settings.OPENAI_TOOL_FALLBACK_MAX_RETRIES,
|
|
"tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS,
|
|
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
|
"model_presets": [
|
|
"gpt-6-luna",
|
|
"gpt-6.1-sol",
|
|
"gpt-6-astra",
|
|
"mimo-v2.6-pro",
|
|
],
|
|
"env_path": str(get_env_file()),
|
|
}
|
|
|
|
|
|
def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
|
if update.api_key and any(c in update.api_key.get_secret_value() for c in "\r\n\x00"):
|
|
raise ValueError("API Key 不能包含换行")
|
|
values = {
|
|
"OPENAI_BASE_URL": update.base_url,
|
|
"OPENAI_MODEL": update.model,
|
|
"OPENAI_TIMEOUT_SECONDS": str(update.timeout_seconds),
|
|
"OPENAI_MAX_RETRIES": str(update.max_retries),
|
|
"OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds),
|
|
"OPENAI_REASONING_EFFORT": update.reasoning_effort,
|
|
}
|
|
optional_settings = {
|
|
"rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float),
|
|
"tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str),
|
|
"tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str),
|
|
"tool_fallback_max_retries": ("OPENAI_TOOL_FALLBACK_MAX_RETRIES", int),
|
|
"tool_fallback_no_progress_attempts": (
|
|
"ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS",
|
|
int,
|
|
),
|
|
}
|
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
|
value = getattr(update, field_name)
|
|
if value is not None:
|
|
values[env_name] = str(value)
|
|
if update.api_key and update.api_key.get_secret_value().strip():
|
|
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
|
|
persist_environment(values)
|
|
settings.OPENAI_BASE_URL = update.base_url
|
|
settings.OPENAI_MODEL = update.model
|
|
settings.OPENAI_TIMEOUT_SECONDS = update.timeout_seconds
|
|
settings.OPENAI_MAX_RETRIES = update.max_retries
|
|
settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds
|
|
settings.OPENAI_REASONING_EFFORT = update.reasoning_effort
|
|
for field_name, (env_name, expected_type) in optional_settings.items():
|
|
value = getattr(update, field_name)
|
|
if value is not None:
|
|
setattr(settings, env_name, expected_type(value))
|
|
if "OPENAI_API_KEY" in values:
|
|
settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"])
|
|
from artemis.llm.router import ModelFactory
|
|
from apps.admin_console.services.model_service import ModelService
|
|
|
|
ModelFactory._cache.clear()
|
|
ModelService._llm_info_cache = None
|
|
return get_model_settings()
|
|
|
|
|
|
def persist_environment(values: dict[str, str]) -> None:
|
|
with _write_lock:
|
|
env_path = get_env_file()
|
|
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
original = env_path.read_text(encoding="utf-8") if env_path.exists() else ""
|
|
# Preserve comments and unrelated variables; quote values to avoid dotenv interpolation.
|
|
kept = [
|
|
line
|
|
for line in original.splitlines()
|
|
if line.removeprefix("export ").split("=", 1)[0].strip() not in values
|
|
]
|
|
for key, value in values.items():
|
|
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
|
|
kept.append(f"{key}='{escaped}'")
|
|
content = "\n".join(kept) + "\n"
|
|
fd, temp_name = tempfile.mkstemp(prefix=".env-", dir=env_path.parent)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
|
|
stream.write(content)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
os.replace(temp_name, env_path)
|
|
finally:
|
|
Path(temp_name).unlink(missing_ok=True)
|
|
for key, value in values.items():
|
|
os.environ[key] = value
|
|
|
|
|
|
class CapabilitySettings(BaseModel):
|
|
adb_auto_connect: bool = Field(default_factory=lambda: settings.ARTEMIS_ADB_AUTO_CONNECT)
|
|
adb_discovery_interval_seconds: float = Field(
|
|
default_factory=lambda: settings.ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS, ge=1, le=300
|
|
)
|
|
adb_discovery_timeout_seconds: float = Field(
|
|
default_factory=lambda: settings.ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS, gt=0, le=30
|
|
)
|
|
video_enabled: bool = False
|
|
recording_enabled: bool = False
|
|
recording: dict = Field(default_factory=dict)
|
|
outputter_enabled: bool = True
|
|
flash_final_check_enabled: bool = Field(
|
|
default_factory=lambda: load_agent_config().flash.final_check_enabled
|
|
)
|
|
step_summarizer_enabled: bool = True
|
|
screenshot_limit_enabled: bool = True
|
|
screenshot_max_long_edge: int = 2048
|
|
task_timeout_seconds: int = Field(
|
|
default_factory=lambda: settings.ARTEMIS_TASK_TIMEOUT_SECONDS, ge=30, le=86400
|
|
)
|
|
observation_timeout_seconds: float = Field(
|
|
default_factory=lambda: settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS, gt=0, le=300
|
|
)
|
|
observation_max_attempts: int = Field(
|
|
default_factory=lambda: settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS, ge=1, le=5
|
|
)
|
|
device_command_timeout_seconds: float = Field(
|
|
default_factory=lambda: settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, gt=0, le=60
|
|
)
|
|
action_failure_attempts: int = Field(
|
|
default_factory=lambda: settings.ARTEMIS_ACTION_FAILURE_ATTEMPTS, ge=1, le=10
|
|
)
|
|
worker_cleanup_timeout_seconds: float = Field(
|
|
default_factory=lambda: settings.ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS, gt=0, le=300
|
|
)
|
|
|
|
|
|
def get_capability_settings() -> CapabilitySettings:
|
|
screenshot = load_agent_config().screenshot
|
|
return CapabilitySettings(
|
|
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
|
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
|
|
recording=load_agent_config().recording.model_dump(),
|
|
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
|
flash_final_check_enabled=load_agent_config().flash.final_check_enabled,
|
|
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
|
screenshot_limit_enabled=screenshot.enabled,
|
|
screenshot_max_long_edge=screenshot.max_long_edge,
|
|
)
|
|
|
|
|
|
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
|
from artemis.config.agent import RecordingConfig
|
|
import json
|
|
|
|
recording = RecordingConfig.model_validate(
|
|
{**load_agent_config().recording.model_dump(), **update.recording}
|
|
).model_dump()
|
|
values = {
|
|
"ARTEMIS_ADB_AUTO_CONNECT": update.adb_auto_connect,
|
|
"ARTEMIS_ADB_DISCOVERY_INTERVAL_SECONDS": update.adb_discovery_interval_seconds,
|
|
"ARTEMIS_ADB_DISCOVERY_TIMEOUT_SECONDS": update.adb_discovery_timeout_seconds,
|
|
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
|
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
|
|
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
|
"ARTEMIS_FLASH_FINAL_CHECK_ENABLED": update.flash_final_check_enabled,
|
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
|
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
|
|
"ARTEMIS_TASK_TIMEOUT_SECONDS": update.task_timeout_seconds,
|
|
"ARTEMIS_OBSERVATION_TIMEOUT_SECONDS": update.observation_timeout_seconds,
|
|
"ARTEMIS_OBSERVATION_MAX_ATTEMPTS": update.observation_max_attempts,
|
|
"ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS": update.device_command_timeout_seconds,
|
|
"ARTEMIS_ACTION_FAILURE_ATTEMPTS": update.action_failure_attempts,
|
|
"ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS": update.worker_cleanup_timeout_seconds,
|
|
}
|
|
persist_environment({key: str(value).lower() for key, value in values.items()})
|
|
persist_environment({"ARTEMIS_RECORDING_CONFIG": json.dumps(recording)})
|
|
settings.ARTEMIS_RECORDING_CONFIG = recording
|
|
for key, value in values.items():
|
|
setattr(settings, key, value)
|
|
return get_capability_settings()
|
|
|
|
|
|
async def test_model_settings(update: ModelSettingsUpdate) -> dict:
|
|
from langchain_core.messages import HumanMessage
|
|
from artemis.llm.router import ModelEndpoint, ModelFactory
|
|
|
|
key = update.api_key.get_secret_value().strip() if update.api_key else None
|
|
endpoint = ModelEndpoint(
|
|
model_name=update.model,
|
|
api_key=key or None,
|
|
api_base=update.base_url,
|
|
timeout_seconds=update.timeout_seconds,
|
|
max_retries=0,
|
|
reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort,
|
|
)
|
|
model = ModelFactory.create_model(endpoint)
|
|
result = await model.ainvoke([HumanMessage(content="Reply with OK only.")])
|
|
if not result.content:
|
|
raise ValueError("Responses 接口返回空内容,请检查模型配置")
|
|
return {"success": True, "message": "Responses 连接测试成功", "model": update.model}
|