"""Persist the single Responses endpoint without returning credentials to the browser.""" 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.environment import ( persist_environment as write_environment, TRANSPORT_DEFAULT_VERSION_KEY, ) from artemis.config.paths import get_env_file from artemis.config.settings import settings 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) transport: Literal["http", "websocket"] | None = None 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, "transport": settings.OPENAI_TRANSPORT, "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-terra", "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 = { "transport": ("OPENAI_TRANSPORT", str), "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.transport is not None: values[TRANSPORT_DEFAULT_VERSION_KEY] = "1" 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: write_environment(get_env_file(), values) 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, transport=update.transport or settings.OPENAI_TRANSPORT, reasoning_effort=None if update.reasoning_effort == "default" else update.reasoning_effort, ) model = ModelFactory.create_model(endpoint) try: result = await model.ainvoke([HumanMessage(content="Reply with OK only.")]) finally: await model.http_async_client.aclose() model.http_client.close() if not result.content: raise ValueError("Responses 接口返回空内容,请检查模型配置") return { "success": True, "message": f"Responses {endpoint.transport.upper()} 连接测试成功", "model": update.model, }