feat(config): default to WebSocket and migrate saved HTTP settings
This commit is contained in:
1 parent
2a3f43fb60
commit
04022f2eaf
18 files changed
+244
-58
No files matched your search
+4
-2
@@ -9,8 +9,10 @@ OPENAI_TIMEOUT_SECONDS=300
|
||||
# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。
|
||||
# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。
|
||||
OPENAI_USE_SYSTEM_PROXY=false
|
||||
# http 保持兼容;websocket 复用连接并按已验证的响应链增量发送,要求网关支持 Responses WebSocket。
|
||||
OPENAI_TRANSPORT=http
|
||||
# 默认 websocket,要求网关支持 Responses WebSocket;不支持时可在网页切回 http。
|
||||
OPENAI_TRANSPORT=websocket
|
||||
# 内部迁移标记。升级时一次性将旧 HTTP 配置切换为 WebSocket;保留此标记以记住后续手动选择。
|
||||
ARTEMIS_TRANSPORT_DEFAULT_VERSION=1
|
||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||
|
||||
@@ -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`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
|
||||
|
||||
模型配置的“传输协议”可选择 **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`,重启后对新任务生效。
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
"""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.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
|
||||
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
|
||||
class ModelSettingsUpdate(BaseModel):
|
||||
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)
|
||||
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)
|
||||
@@ -142,31 +142,7 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict:
|
||||
|
||||
|
||||
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
|
||||
write_environment(get_env_file(), values)
|
||||
|
||||
|
||||
class CapabilitySettings(BaseModel):
|
||||
|
||||
@@ -30,29 +30,29 @@ describe('持久化模型与功能设置', () => {
|
||||
http.expectNone('/api/system/model-settings');
|
||||
}));
|
||||
|
||||
it('加载、测试和保存 WebSocket 选择,旧配置默认 HTTP', fakeAsync(() => {
|
||||
it('缺少协议的配置默认 WebSocket,允许手动切回 HTTP', fakeAsync(() => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/model-settings').flush(config);
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.transport).toBe('http');
|
||||
expect(component.transport).toBe('websocket');
|
||||
const selector: HTMLSelectElement = fixture.nativeElement.querySelector('#model-transport');
|
||||
selector.value = 'websocket';
|
||||
selector.value = 'http';
|
||||
selector.dispatchEvent(new Event('change'));
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
component.test();
|
||||
const check = http.expectOne('/api/system/model-settings/test');
|
||||
expect(check.request.body.transport).toBe('websocket');
|
||||
check.flush({message: 'Responses WEBSOCKET 连接测试成功'});
|
||||
expect(check.request.body.transport).toBe('http');
|
||||
check.flush({message: 'Responses HTTP 连接测试成功'});
|
||||
component.save();
|
||||
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();
|
||||
save.flush({...config, transport: 'websocket'});
|
||||
expect(component.transport).toBe('websocket');
|
||||
save.flush({...config, transport: 'http'});
|
||||
expect(component.transport).toBe('http');
|
||||
}));
|
||||
|
||||
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
|
||||
|
||||
@@ -109,7 +109,7 @@ interface ModelSettings {
|
||||
`]
|
||||
})
|
||||
export class ModelSettingsComponent implements OnInit {
|
||||
transport: 'http' | 'websocket' = 'http';
|
||||
transport: 'http' | 'websocket' = 'websocket';
|
||||
readonly reasoningValues = REASONING_VALUES;
|
||||
readonly reasoningLabels = REASONING_LABELS;
|
||||
private http = inject(HttpClient);
|
||||
@@ -144,7 +144,7 @@ export class ModelSettingsComponent implements OnInit {
|
||||
}
|
||||
private apply(config: ModelSettings): void {
|
||||
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.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;
|
||||
|
||||
@@ -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
|
||||
@@ -19,6 +19,7 @@ from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from filelock import Timeout
|
||||
from pydantic import Field, SecretStr, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -32,6 +33,7 @@ from artemis.config.constants import (
|
||||
ENV_OPENAI_API_KEY,
|
||||
ENV_VISION_API_KEY,
|
||||
)
|
||||
from artemis.config.environment import migrate_transport_default
|
||||
from artemis.config.paths import (
|
||||
GLOBAL_APP_DIR,
|
||||
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.
|
||||
_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)
|
||||
_global_env = GLOBAL_APP_DIR / ".env"
|
||||
if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve():
|
||||
load_dotenv(dotenv_path=_global_env, verbose=True)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def is_placeholder_key(val: str | SecretStr | None) -> bool:
|
||||
"""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_MODEL: str = Field(default=DEFAULT_MODEL, min_length=1)
|
||||
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.
|
||||
OPENAI_USE_SYSTEM_PROXY: bool = False
|
||||
OPENAI_MAX_RETRIES: int = Field(default=5, ge=0, le=10)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"default": {
|
||||
"provider": "openai",
|
||||
"transport": "${env:OPENAI_TRANSPORT:-http}",
|
||||
"transport": "${env:OPENAI_TRANSPORT:-websocket}",
|
||||
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
"default": {
|
||||
"provider": "openai",
|
||||
"transport": "${env:OPENAI_TRANSPORT:-http}",
|
||||
"transport": "${env:OPENAI_TRANSPORT:-websocket}",
|
||||
"model": "${env:OPENAI_MODEL:-gpt-6-luna}",
|
||||
"timeout": "${env:OPENAI_TIMEOUT_SECONDS:-300}",
|
||||
"max_retries": "${env:OPENAI_MAX_RETRIES:-5}",
|
||||
|
||||
@@ -171,7 +171,9 @@ Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶
|
||||
|
||||
## 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、探索和辅助模型运行路径均为异步。
|
||||
|
||||
|
||||
@@ -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 模型。
|
||||
- 探索独立配置,默认 `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.agent import load_agent_config
|
||||
from artemis.config.llm import parse_llm_config
|
||||
from artemis.config.environment import migrate_transport_default, TRANSPORT_DEFAULT_VERSION_KEY
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -45,6 +46,7 @@ def isolated_settings(monkeypatch, tmp_path):
|
||||
for key in keys:
|
||||
monkeypatch.setattr(settings, key, getattr(settings, key))
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
monkeypatch.delenv(TRANSPORT_DEFAULT_VERSION_KEY, raising=False)
|
||||
monkeypatch.setattr(settings, "OPENAI_API_KEY", SecretStr("existing-test-secret"))
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
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):
|
||||
previous = settings.OPENAI_MODEL
|
||||
monkeypatch.setattr(
|
||||
service.os, "replace", lambda *args: (_ for _ in ()).throw(OSError("read only"))
|
||||
)
|
||||
monkeypatch.setattr(os, "replace", lambda *args: (_ for _ in ()).throw(OSError("read only")))
|
||||
with pytest.raises(OSError):
|
||||
service.save_model_settings(service.ModelSettingsUpdate(model="not-saved"))
|
||||
assert settings.OPENAI_MODEL == previous
|
||||
|
||||
@@ -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
|
||||
@@ -123,7 +123,7 @@ async def transport_case(monkeypatch, tmp_path, request):
|
||||
)
|
||||
llm_service._ENDPOINT_BREAKER._states.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)
|
||||
assert isinstance(model, ChatOpenAI)
|
||||
yield SimpleNamespace(
|
||||
|
||||
@@ -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.setattr(settings, "OPENAI_USE_SYSTEM_PROXY", use_proxy)
|
||||
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:
|
||||
for client in (model.http_client, model.http_async_client):
|
||||
|
||||
@@ -43,6 +43,9 @@ def transport_model(monkeypatch):
|
||||
from langchain_openai import ChatOpenAI
|
||||
import openai
|
||||
|
||||
# These fixtures exercise the explicitly selected HTTP compatibility path.
|
||||
monkeypatch.setattr(settings, "OPENAI_TRANSPORT", "http")
|
||||
|
||||
requests = []
|
||||
outputs = []
|
||||
|
||||
|
||||
@@ -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):
|
||||
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()
|
||||
async with model(wire) as raw:
|
||||
history = [HumanMessage(content="First")]
|
||||
@@ -475,6 +476,7 @@ async def test_cancel_waiting_turn_keeps_active_chain_and_idle_cleanup(wire, mon
|
||||
await queued
|
||||
wire.gate.set()
|
||||
result = await first
|
||||
monkeypatch.setattr(websocket, "IDLE_SECONDS", 0.01)
|
||||
await raw.ainvoke(
|
||||
[*history, result, HumanMessage(content="Next")], prompt_cache_key="task:operator"
|
||||
)
|
||||
|
||||
@@ -124,7 +124,10 @@ async def case(monkeypatch, tmp_path):
|
||||
|
||||
def wrapper(timeout=30, max_retries=0):
|
||||
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)
|
||||
assert isinstance(model, ChatOpenAI)
|
||||
|
||||
Reference in new issue
Block a user