feat(config): default to WebSocket and migrate saved HTTP settings

This commit is contained in:
chenmingxuan committed 2026-11-04 17:45:52 +08:00
1 parent 2a3f43fb60
commit 04022f2eaf
18 files changed
+244 -58

No files matched your search

+4 -2
View File
@@ -9,8 +9,10 @@ OPENAI_TIMEOUT_SECONDS=300
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。 # 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。 # 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
OPENAI_USE_SYSTEM_PROXY=false OPENAI_USE_SYSTEM_PROXY=false
# http 保持兼容;websocket 复用连接并按已验证的响应链增量发送,要求网关支持 Responses WebSocket。 # 默认 websocket,要求网关支持 Responses WebSocket;不支持时可在网页切回 http。
OPENAI_TRANSPORT=http OPENAI_TRANSPORT=websocket
# 内部迁移标记。升级时一次性将旧 HTTP 配置切换为 WebSocket;保留此标记以记住后续手动选择。
ARTEMIS_TRANSPORT_DEFAULT_VERSION=1
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。 # 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120 ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
+1 -1
View File
@@ -61,7 +61,7 @@ cd Artemis-Eapil
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。 也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
模型配置的“传输协议”可选择 **WebSocket(增量传输)**,或设置 `OPENAI_TRANSPORT=websocket`。Flash、Pro、探索及辅助模型的异步请求复用 Responses 连接,历史一致时只上传新增输入;网关需支持 `/v1/responses` 的 WebSocket 升级与响应链。默认 HTTP 保持兼容,详细行为与流量诊断见[传输说明](docs/ai-automation.md#responses-websocket-增量传输)。 模型传输默认 **WebSocket(增量传输)**。升级后首次启动会将旧的全局 HTTP 配置一并迁移为 WebSocket;之后仍可在“模型配置 → 传输协议”手动切回 HTTP,后续启动保留手动选择。Flash、Pro、探索及辅助模型的异步请求复用 Responses 连接,历史一致时只上传新增输入;网关需支持 `/v1/responses` 的 WebSocket 升级与响应链。详细行为与流量诊断见[传输说明](docs/ai-automation.md#responses-websocket-增量传输)。
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。 “功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
@@ -1,20 +1,18 @@
"""Persist the single Responses endpoint without returning credentials to the browser.""" """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 typing import Literal
from urllib.parse import urlsplit from urllib.parse import urlsplit
from pydantic import BaseModel, Field, SecretStr, field_validator from pydantic import BaseModel, Field, SecretStr, field_validator
from artemis.config.agent import load_agent_config 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.paths import get_env_file
from artemis.config.settings import settings from artemis.config.settings import settings
_write_lock = threading.Lock()
class ModelSettingsUpdate(BaseModel): class ModelSettingsUpdate(BaseModel):
base_url: str = "https://api-n-cd.playones.com/v1" base_url: str = "https://api-n-cd.playones.com/v1"
@@ -118,6 +116,8 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
value = getattr(update, field_name) value = getattr(update, field_name)
if value is not None: if value is not None:
values[env_name] = str(value) 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(): if update.api_key and update.api_key.get_secret_value().strip():
values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip() values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip()
persist_environment(values) persist_environment(values)
@@ -142,31 +142,7 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
def persist_environment(values: dict[str, str]) -> None: def persist_environment(values: dict[str, str]) -> None:
with _write_lock: write_environment(get_env_file(), values)
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): class CapabilitySettings(BaseModel):
@@ -30,29 +30,29 @@ describe('持久化模型与功能设置', () => {
http.expectNone('/api/system/model-settings'); http.expectNone('/api/system/model-settings');
})); }));
it('加载、测试和保存 WebSocket 选择,旧配置默认 HTTP', fakeAsync(() => { it('缺少协议的配置默认 WebSocket,允许手动切回 HTTP', fakeAsync(() => {
const fixture = TestBed.createComponent(ModelSettingsComponent); const fixture = TestBed.createComponent(ModelSettingsComponent);
fixture.detectChanges(); fixture.detectChanges();
http.expectOne('/api/system/model-settings').flush(config); http.expectOne('/api/system/model-settings').flush(config);
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
const component = fixture.componentInstance; const component = fixture.componentInstance;
expect(component.transport).toBe('http'); expect(component.transport).toBe('websocket');
const selector: HTMLSelectElement = fixture.nativeElement.querySelector('#model-transport'); const selector: HTMLSelectElement = fixture.nativeElement.querySelector('#model-transport');
selector.value = 'websocket'; selector.value = 'http';
selector.dispatchEvent(new Event('change')); selector.dispatchEvent(new Event('change'));
tick(); tick();
fixture.detectChanges(); fixture.detectChanges();
component.test(); component.test();
const check = http.expectOne('/api/system/model-settings/test'); const check = http.expectOne('/api/system/model-settings/test');
expect(check.request.body.transport).toBe('websocket'); expect(check.request.body.transport).toBe('http');
check.flush({message: 'Responses WEBSOCKET 连接测试成功'}); check.flush({message: 'Responses HTTP 连接测试成功'});
component.save(); component.save();
const save = http.expectOne('/api/system/model-settings'); const save = http.expectOne('/api/system/model-settings');
expect(save.request.body.transport).toBe('websocket'); expect(save.request.body.transport).toBe('http');
expect(save.request.body.api_key).toBeUndefined(); expect(save.request.body.api_key).toBeUndefined();
save.flush({...config, transport: 'websocket'}); save.flush({...config, transport: 'http'});
expect(component.transport).toBe('websocket'); expect(component.transport).toBe('http');
})); }));
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => { it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
@@ -109,7 +109,7 @@ interface ModelSettings {
`] `]
}) })
export class ModelSettingsComponent implements OnInit { export class ModelSettingsComponent implements OnInit {
transport: 'http' | 'websocket' = 'http'; transport: 'http' | 'websocket' = 'websocket';
readonly reasoningValues = REASONING_VALUES; readonly reasoningValues = REASONING_VALUES;
readonly reasoningLabels = REASONING_LABELS; readonly reasoningLabels = REASONING_LABELS;
private http = inject(HttpClient); private http = inject(HttpClient);
@@ -144,7 +144,7 @@ export class ModelSettingsComponent implements OnInit {
} }
private apply(config: ModelSettings): void { private apply(config: ModelSettings): void {
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds; this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
this.transport = config.transport ?? 'http'; this.transport = config.transport ?? 'websocket';
this.modelPresets = config.model_presets; this.modelPresets = config.model_presets;
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path); this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60; this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
+64
View File
@@ -0,0 +1,64 @@
"""Atomic environment persistence and the one-time WebSocket default migration."""
import os
from pathlib import Path
import tempfile
from dotenv import dotenv_values
from filelock import FileLock
TRANSPORT_DEFAULT_VERSION_KEY = "ARTEMIS_TRANSPORT_DEFAULT_VERSION"
def _lock(path: Path) -> FileLock:
path.parent.mkdir(parents=True, exist_ok=True)
return FileLock(str(path) + ".artemis.lock", timeout=5)
def _write(path: Path, values: dict[str, str]) -> None:
original = path.read_text(encoding="utf-8") if path.exists() else ""
kept = [
line
for line in original.splitlines()
if line.strip().removeprefix("export ").split("=", 1)[0].strip() not in values
]
for key, value in values.items():
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
kept.append(f"{key}='{escaped}'")
fd, temp_name = tempfile.mkstemp(prefix=".env-", dir=path.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream:
stream.write("\n".join(kept) + "\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(temp_name, path)
finally:
Path(temp_name).unlink(missing_ok=True)
def persist_environment(path: Path, values: dict[str, str]) -> None:
with _lock(path):
_write(path, values)
os.environ.update(values)
def _transport_default_is_current(path: Path) -> bool:
saved = dotenv_values(path, interpolate=False) if path.exists() else {}
try:
return int(saved.get(TRANSPORT_DEFAULT_VERSION_KEY) or "0") >= 1
except ValueError:
return False
def migrate_transport_default(path: Path) -> bool:
"""Migrate unversioned HTTP preferences once, preserving later opt-outs."""
# Completed migrations need no write access, including read-only deployments.
if _transport_default_is_current(path):
return False
with _lock(path):
if _transport_default_is_current(path):
return False
values = {"OPENAI_TRANSPORT": "websocket", TRANSPORT_DEFAULT_VERSION_KEY: "1"}
_write(path, values)
os.environ.update(values)
return True
+10 -3
View File
@@ -19,6 +19,7 @@ from pathlib import Path
from typing import Literal from typing import Literal
from dotenv import load_dotenv from dotenv import load_dotenv
from filelock import Timeout
from pydantic import Field, SecretStr, model_validator from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -32,6 +33,7 @@ from artemis.config.constants import (
ENV_OPENAI_API_KEY, ENV_OPENAI_API_KEY,
ENV_VISION_API_KEY, ENV_VISION_API_KEY,
) )
from artemis.config.environment import migrate_transport_default
from artemis.config.paths import ( from artemis.config.paths import (
GLOBAL_APP_DIR, GLOBAL_APP_DIR,
get_data_engine_db_path, get_data_engine_db_path,
@@ -43,13 +45,18 @@ from artemis.utils.logger import get_logger
# Installed wheels load .env from the user directory, outside site-packages. # Installed wheels load .env from the user directory, outside site-packages.
_canonical_env = get_env_file() _canonical_env = get_env_file()
logger = get_logger(__name__)
try:
if migrate_transport_default(_canonical_env):
logger.info("已将全局模型传输协议迁移为 WebSocket;之后可在模型配置中切回 HTTP。")
except (OSError, Timeout):
os.environ["OPENAI_TRANSPORT"] = "websocket"
logger.warning("无法保存 WebSocket 默认迁移;本次使用 WebSocket,下次启动重试。")
load_dotenv(dotenv_path=_canonical_env, verbose=True) load_dotenv(dotenv_path=_canonical_env, verbose=True)
_global_env = GLOBAL_APP_DIR / ".env" _global_env = GLOBAL_APP_DIR / ".env"
if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve(): if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve():
load_dotenv(dotenv_path=_global_env, verbose=True) load_dotenv(dotenv_path=_global_env, verbose=True)
logger = get_logger(__name__)
def is_placeholder_key(val: str | SecretStr | None) -> bool: def is_placeholder_key(val: str | SecretStr | None) -> bool:
"""Check if an API credential is empty or an unconfigured placeholder value.""" """Check if an API credential is empty or an unconfigured placeholder value."""
@@ -110,7 +117,7 @@ class Settings(BaseSettings):
OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1" OPENAI_BASE_URL: str | None = "https://api-n-cd.playones.com/v1"
OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1) OPENAI_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0) OPENAI_TIMEOUT_SECONDS: float = Field(default=300.0, gt=0)
OPENAI_TRANSPORT: Literal["http", "websocket"] = "http" OPENAI_TRANSPORT: Literal["http", "websocket"] = "websocket"
# Environment only; deliberately absent from the saved UI/task configuration. # Environment only; deliberately absent from the saved UI/task configuration.
OPENAI_USE_SYSTEM_PROXY: bool = False OPENAI_USE_SYSTEM_PROXY: bool = False
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10) OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
+1 -1
View File
@@ -2,7 +2,7 @@
{ {
"default": { "default": {
"provider": "openai", "provider": "openai",
"transport": "${env:OPENAI_TRANSPORT:-http}", "transport": "${env:OPENAI_TRANSPORT:-websocket}",
"model": "${env:OPENAI_MODEL:-gpt-6-luna}", "model": "${env:OPENAI_MODEL:-gpt-6-luna}",
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}", "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}", "max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
+1 -1
View File
@@ -2,7 +2,7 @@
{ {
"default": { "default": {
"provider": "openai", "provider": "openai",
"transport": "${env:OPENAI_TRANSPORT:-http}", "transport": "${env:OPENAI_TRANSPORT:-websocket}",
"model": "${env:OPENAI_MODEL:-gpt-6-luna}", "model": "${env:OPENAI_MODEL:-gpt-6-luna}",
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}", "timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}", "max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
+3 -1
View File
@@ -171,7 +171,9 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶
## Responses WebSocket 增量传输 ## Responses WebSocket 增量传输
网页“模型配置 → 传输协议”选择 `WebSocket(增量传输)`,或在 `.env` 设置 `OPENAI_TRANSPORT=websocket`。网页保存对新任务生效;直接编辑 `.env` 后重启相应服务。默认 `http`,旧配置不变。JSON 模型/节点配置可用 `transport` 单独覆盖;MCP 使用 `mobile_config(section="model", changes={"transport":"websocket"}, persist_defaults=true)` 保存,仍须用户明确要求修改默认值。 模型传输默认 `websocket`。首次启动时,未带迁移标记的全局配置(包括已保存的 `OPENAI_TRANSPORT=http`)统一切换为 WebSocket,并在安装模式对应的 `.env` 原子写入 `ARTEMIS_TRANSPORT_DEFAULT_VERSION=1`。迁移只调整协议及此内部标记,保留凭据、接口地址、其他设置和注释;跨进程文件锁避免启动与网页保存相互覆盖。保存失败保留原文件、明确告警,本次进程仍使用 WebSocket,下次启动重试。
之后可在“模型配置 → 传输协议”手动切回 `HTTP(兼容模式)`;保存会保留迁移标记,不会在重启时再次改成 WebSocket。直接编辑 `.env` 时保留 `ARTEMIS_TRANSPORT_DEFAULT_VERSION=1`,设置 `OPENAI_TRANSPORT=http` 后重启即可切回。网页保存对新任务生效;升级迁移不会改动正在运行的任务。JSON 模型/节点配置仍可用 `transport` 单独覆盖。MCP 使用 `mobile_config(section="model", changes={"transport":"websocket"}, persist_defaults=true)` 保存,仍须用户明确要求修改默认值。
异步任务请求在现有 SDK 序列化与完整性保护之下接入 `/v1/responses` WebSocket。首轮发送完整上下文;同一任务(或探索)、角色、模型、路由头及凭据范围内复用连接。只在当前输入严格包含上一轮输入和完整输出、生成选项一致时,发送 `previous_response_id` 与新增输入。仅忽略消息 ID、状态、输出概率等不影响历史语义的响应字段,不按文本相似度截取。工具结果保留 `call_id`,文本、图片、结构化输出与 usage 继续走原有解析和安全检查。同步底层模型调用仍使用 HTTP;Flash、Pro、探索和辅助模型运行路径均为异步。 异步任务请求在现有 SDK 序列化与完整性保护之下接入 `/v1/responses` WebSocket。首轮发送完整上下文;同一任务(或探索)、角色、模型、路由头及凭据范围内复用连接。只在当前输入严格包含上一轮输入和完整输出、生成选项一致时,发送 `previous_response_id` 与新增输入。仅忽略消息 ID、状态、输出概率等不影响历史语义的响应字段,不按文本相似度截取。工具结果保留 `call_id`,文本、图片、结构化输出与 usage 继续走原有解析和安全检查。同步底层模型调用仍使用 HTTP;Flash、Pro、探索和辅助模型运行路径均为异步。
+1 -1
View File
@@ -16,7 +16,7 @@
## 模式、模型与配置 ## 模式、模型与配置
网页模型配置和 `mobile_config(section="model")` 提供 `transport=http/websocket`。用户明确要求保存默认值时可设置 `changes={"transport":"websocket"}, persist_defaults=true`;新任务的 Flash、Pro、探索和辅助请求共用此选择。WebSocket 在历史一致时仅上传新增输入,网关须支持 Responses WebSocket;压缩、重连会发送完整上下文,上传减少不能当作 token 减少或缓存命中。不支持协议时应查看错误并确认网关能力,不自动改写用户配置。 网页模型配置和 `mobile_config(section="model")` 提供 `transport=http/websocket`,默认 WebSocket;首次升级启动也会将旧全局 HTTP 配置一次性迁移为 WebSocket,之后手动切回 HTTP 会被保留。用户明确要求保存默认值时可设置 `changes={"transport":"websocket"}, persist_defaults=true`;新任务的 Flash、Pro、探索和辅助请求共用此选择。WebSocket 在历史一致时仅上传新增输入,网关须支持 Responses WebSocket;压缩、重连会发送完整上下文,上传减少不能当作 token 减少或缓存命中。不支持协议时应查看错误并确认网关能力,不自动改写用户配置。
- `mobile_run_task.model` 取 `Flash` / `Pro`。API 模型名放 `runtime_overrides.model`;思考强度放 `runtime_overrides.reasoning_effort`。批次使用 `task_config.profile` 选择模式,`task_config.model` 选择 API 模型。 - `mobile_run_task.model` 取 `Flash` / `Pro`。API 模型名放 `runtime_overrides.model`;思考强度放 `runtime_overrides.reasoning_effort`。批次使用 `task_config.profile` 选择模式,`task_config.model` 选择 API 模型。
- 探索独立配置,默认 `gpt-6.1-terra/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。 - 探索独立配置,默认 `gpt-6.1-terra/high`,支持 `gpt-6-astra` 及已配置网关支持的自定义模型。执行、示范分析、提炼共用该探索配置;不改变 Flash/Pro 默认值。
@@ -9,6 +9,7 @@ from apps.admin_console.services import model_settings_service as service
from artemis.config.settings import Settings, settings from artemis.config.settings import Settings, settings
from artemis.config.agent import load_agent_config from artemis.config.agent import load_agent_config
from artemis.config.llm import parse_llm_config from artemis.config.llm import parse_llm_config
from artemis.config.environment import migrate_transport_default, TRANSPORT_DEFAULT_VERSION_KEY
@pytest.fixture @pytest.fixture
@@ -45,6 +46,7 @@ def isolated_settings(monkeypatch, tmp_path):
for key in keys: for key in keys:
monkeypatch.setattr(settings, key, getattr(settings, key)) monkeypatch.setattr(settings, key, getattr(settings, key))
monkeypatch.delenv(key, raising=False) monkeypatch.delenv(key, raising=False)
monkeypatch.delenv(TRANSPORT_DEFAULT_VERSION_KEY, raising=False)
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("existing-test-secret")) monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("existing-test-secret"))
return env return env
@@ -86,6 +88,16 @@ def test_save_reload_and_blank_key_preserves_secret(isolated_settings):
assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret" assert dotenv_values(isolated_settings)["OPENAI_API_KEY"] == "new-test-secret"
def test_saved_http_opt_out_survives_restart_migration(isolated_settings):
result = service.save_model_settings(
service.ModelSettingsUpdate(model="test", transport="http")
)
assert result["transport"] == "http"
assert dotenv_values(isolated_settings)[TRANSPORT_DEFAULT_VERSION_KEY] == "1"
assert migrate_transport_default(isolated_settings) is False
assert Settings(_env_file=isolated_settings).OPENAI_TRANSPORT == "http"
def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeypatch): def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeypatch):
monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
expected = "https://api-n-cd.playones.com/v1" expected = "https://api-n-cd.playones.com/v1"
@@ -231,9 +243,7 @@ def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings): def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
previous = settings.OPENAI_MODEL previous = settings.OPENAI_MODEL
monkeypatch.setattr( monkeypatch.setattr(os, "replace", lambda *args: (_ for _ in ()).throw(OSError("read only")))
service.os, "replace", lambda *args: (_ for _ in ()).throw(OSError("read only"))
)
with pytest.raises(OSError): with pytest.raises(OSError):
service.save_model_settings(service.ModelSettingsUpdate(model="not-saved")) service.save_model_settings(service.ModelSettingsUpdate(model="not-saved"))
assert settings.OPENAI_MODEL == previous assert settings.OPENAI_MODEL == previous
+117
View File
@@ -0,0 +1,117 @@
import json
import os
from pathlib import Path
import subprocess
import sys
from dotenv import dotenv_values
import pytest
from artemis.config.environment import (
migrate_transport_default,
persist_environment,
TRANSPORT_DEFAULT_VERSION_KEY,
)
@pytest.fixture(autouse=True)
def isolate_transport(monkeypatch):
monkeypatch.setenv("OPENAI_TRANSPORT", "http")
monkeypatch.delenv(TRANSPORT_DEFAULT_VERSION_KEY, raising=False)
@pytest.mark.parametrize("old", ["", "OPENAI_TRANSPORT=http\n", "OPENAI_TRANSPORT=websocket\n"])
def test_upgrade_migrates_legacy_preferences_once_and_preserves_config(tmp_path, old):
path = tmp_path / ".env"
preserved = (
"# 用户注释\nOPENAI_API_KEY='test-secret'\nOPENAI_BASE_URL=https://test.invalid/v1\n"
)
path.write_text(preserved + old, encoding="utf-8")
assert migrate_transport_default(path) is True
saved = dotenv_values(path)
assert saved["OPENAI_TRANSPORT"] == os.environ["OPENAI_TRANSPORT"] == "websocket"
assert saved[TRANSPORT_DEFAULT_VERSION_KEY] == "1"
assert path.read_text(encoding="utf-8").startswith(preserved)
previous = path.read_bytes()
assert migrate_transport_default(path) is False
assert path.read_bytes() == previous
persist_environment(path, {"OPENAI_TRANSPORT": "http"})
assert migrate_transport_default(path) is False
assert dotenv_values(path)["OPENAI_TRANSPORT"] == "http"
def test_failed_replace_preserves_original_and_can_retry(tmp_path, monkeypatch):
from artemis.config import environment
path = tmp_path / ".env"
original = "OPENAI_TRANSPORT=http\nUNRELATED=keep\n"
path.write_text(original, encoding="utf-8")
def fail(*_):
raise OSError("simulated disk error")
with monkeypatch.context() as patch:
patch.setattr(environment.os, "replace", fail)
with pytest.raises(OSError):
migrate_transport_default(path)
assert path.read_text(encoding="utf-8") == original
assert os.environ["OPENAI_TRANSPORT"] == "http"
assert not list(tmp_path.glob(".env-*"))
assert migrate_transport_default(path) is True
@pytest.mark.parametrize("version", [1, 2])
def test_completed_migration_preserves_http_without_write_access(tmp_path, monkeypatch, version):
from artemis.config import environment
path = tmp_path / ".env"
path.write_text(
f"OPENAI_TRANSPORT=http\n{TRANSPORT_DEFAULT_VERSION_KEY}={version}\n", encoding="utf-8"
)
def denied(*_, **__):
raise PermissionError("read-only deployment")
monkeypatch.setattr(environment, "FileLock", denied)
assert migrate_transport_default(path) is False
assert dotenv_values(path)["OPENAI_TRANSPORT"] == "http"
@pytest.mark.parametrize("new_install", [False, True])
def test_fresh_process_defaults_and_saved_opt_out(tmp_path, new_install):
path = tmp_path / ".env"
if not new_install:
path.write_text("OPENAI_TRANSPORT=http\n", encoding="utf-8")
env = {
k: v
for k, v in os.environ.items()
if k not in {"OPENAI_TRANSPORT", TRANSPORT_DEFAULT_VERSION_KEY}
}
env["ARTEMIS_APP_DIR"] = str(tmp_path)
env["PYTHONUTF8"] = "1"
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[3])
code = """
import json
from artemis.config.settings import settings
from artemis.config.llm import LLM, parse_llm_config
from artemis.llm.router import ModelEndpoint
print(json.dumps([settings.OPENAI_TRANSPORT, LLM().transport, ModelEndpoint().transport, parse_llm_config().operator.transport]))
"""
def start():
result = subprocess.run(
[sys.executable, "-c", code],
cwd=tmp_path,
env=env,
capture_output=True,
text=True,
encoding="utf-8",
timeout=20,
check=True,
)
return json.loads(result.stdout.splitlines()[-1])
assert start() == ["websocket"] * 4
assert dotenv_values(path)[TRANSPORT_DEFAULT_VERSION_KEY] == "1"
persist_environment(path, {"OPENAI_TRANSPORT": "http"})
assert start() == ["http"] * 4
+1 -1
View File
@@ -123,7 +123,7 @@ async def transport_case(monkeypatch, tmp_path, request):
) )
llm_service._ENDPOINT_BREAKER._states.clear() llm_service._ENDPOINT_BREAKER._states.clear()
llm_service._NON_STREAMING_ENDPOINTS.clear() llm_service._NON_STREAMING_ENDPOINTS.clear()
endpoint = ModelEndpoint(model_name="test-model", max_retries=0) endpoint = ModelEndpoint(model_name="test-model", transport="http", max_retries=0)
model = ModelFactory.create_model(endpoint) model = ModelFactory.create_model(endpoint)
assert isinstance(model, ChatOpenAI) assert isinstance(model, ChatOpenAI)
yield SimpleNamespace( yield SimpleNamespace(
+1 -1
View File
@@ -17,7 +17,7 @@ async def test_sync_and_async_clients_follow_env_only_proxy_policy(monkeypatch,
monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False) monkeypatch.delenv("ARTEMIS_FAKE_LLM", raising=False)
monkeypatch.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", use_proxy) monkeypatch.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", use_proxy)
model = ModelFactory.create_model( model = ModelFactory.create_model(
ModelEndpoint(api_key="test-only", api_base="https://example.invalid/v1") ModelEndpoint(api_key="test-only", api_base="https://example.invalid/v1", transport="http")
) )
try: try:
for client in (model.http_client, model.http_async_client): for client in (model.http_client, model.http_async_client):
+3
View File
@@ -43,6 +43,9 @@ def transport_model(monkeypatch):
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
import openai import openai
# These fixtures exercise the explicitly selected HTTP compatibility path.
monkeypatch.setattr(settings, "OPENAI_TRANSPORT", "http")
requests = [] requests = []
outputs = [] outputs = []
+3 -1
View File
@@ -460,7 +460,8 @@ async def test_idle_connections_are_closed_and_removed(wire, monkeypatch):
async def test_cancel_waiting_turn_keeps_active_chain_and_idle_cleanup(wire, monkeypatch): async def test_cancel_waiting_turn_keeps_active_chain_and_idle_cleanup(wire, monkeypatch):
from artemis.llm import websocket from artemis.llm import websocket
monkeypatch.setattr(websocket, "IDLE_SECONDS", 0.05) # Only shorten the final idle interval; scheduler latency must not break the first chain.
monkeypatch.setattr(websocket, "IDLE_SECONDS", 60)
wire.gate = asyncio.Event() wire.gate = asyncio.Event()
async with model(wire) as raw: async with model(wire) as raw:
history = [HumanMessage(content="First")] history = [HumanMessage(content="First")]
@@ -475,6 +476,7 @@ async def test_cancel_waiting_turn_keeps_active_chain_and_idle_cleanup(wire, mon
await queued await queued
wire.gate.set() wire.gate.set()
result = await first result = await first
monkeypatch.setattr(websocket, "IDLE_SECONDS", 0.01)
await raw.ainvoke( await raw.ainvoke(
[*history, result, HumanMessage(content="Next")], prompt_cache_key="task:operator" [*history, result, HumanMessage(content="Next")], prompt_cache_key="task:operator"
) )
+4 -1
View File
@@ -124,7 +124,10 @@ async def case(monkeypatch, tmp_path):
def wrapper(timeout=30, max_retries=0): def wrapper(timeout=30, max_retries=0):
endpoint = ModelEndpoint( endpoint = ModelEndpoint(
model_name="test-model", timeout_seconds=timeout, max_retries=max_retries model_name="test-model",
transport="http",
timeout_seconds=timeout,
max_retries=max_retries,
) )
model = ModelFactory.create_model(endpoint) model = ModelFactory.create_model(endpoint)
assert isinstance(model, ChatOpenAI) assert isinstance(model, ChatOpenAI)