283 lines
12 KiB
Python
283 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)
|
|
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.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,
|
|
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,
|
|
}
|