Files
Artemis-Eapil/apps/admin_console/services/model_settings_service.py
T

259 lines
11 KiB
Python

"""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,
}