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
@@ -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