"""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.1-sol", "gpt-6-astra", "gpt-5.6-luna", "gpt-6-luna", "mimo-v2.6-flash", "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 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, 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_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}