174 lines
6.8 KiB
Python
174 lines
6.8 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.paths import get_env_file
|
|
from artemis.config.settings import settings
|
|
|
|
_write_lock = threading.Lock()
|
|
|
|
|
|
class ModelSettingsUpdate(BaseModel):
|
|
base_url: str = "https://api.openai.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)
|
|
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.openai.com/v1")
|
|
return value
|
|
|
|
@field_validator("model")
|
|
@classmethod
|
|
def validate_model(cls, value: str) -> str:
|
|
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.openai.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,
|
|
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
|
"model_presets": ["gpt-5.6-luna", "mimo-v2.6-flash"],
|
|
"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,
|
|
}
|
|
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
|
|
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):
|
|
video_enabled: bool = False
|
|
outputter_enabled: bool = True
|
|
step_summarizer_enabled: bool = True
|
|
|
|
|
|
def get_capability_settings() -> CapabilitySettings:
|
|
return CapabilitySettings(
|
|
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
|
|
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
|
|
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
|
|
)
|
|
|
|
|
|
def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
|
|
values = {
|
|
"ARTEMIS_VIDEO_ENABLED": update.video_enabled,
|
|
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
|
|
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
|
|
}
|
|
persist_environment({key: str(value).lower() for key, value in values.items()})
|
|
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}
|