diff --git a/.env.example b/.env.example index 5ccaafa..cca64ce 100644 --- a/.env.example +++ b/.env.example @@ -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 不叠加重试。 diff --git a/README.md b/README.md index 96dfe6d..bd4e91d 100644 --- a/README.md +++ b/README.md @@ -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`,重启后对新任务生效。 diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py index 5f28e9d..3228c19 100644 --- a/apps/admin_console/services/model_settings_service.py +++ b/apps/admin_console/services/model_settings_service.py @@ -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): diff --git a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts index 24c26fd..91fb16f 100644 --- a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts +++ b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts @@ -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('加载配置后保留密钥,保存新模型且不发送空密钥', () => { diff --git a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts index ea25125..de20bea 100644 --- a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts +++ b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts @@ -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; diff --git a/artemis/config/environment.py b/artemis/config/environment.py new file mode 100644 index 0000000..2e202f3 --- /dev/null +++ b/artemis/config/environment.py @@ -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 diff --git a/artemis/config/settings.py b/artemis/config/settings.py index cab4cdc..1e0a0b1 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -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) diff --git a/artemis/resources/config/artemis.jsonc b/artemis/resources/config/artemis.jsonc index 2803aca..c146947 100644 --- a/artemis/resources/config/artemis.jsonc +++ b/artemis/resources/config/artemis.jsonc @@ -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}", diff --git a/config/artemis.jsonc b/config/artemis.jsonc index 2803aca..c146947 100644 --- a/config/artemis.jsonc +++ b/config/artemis.jsonc @@ -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}", diff --git a/docs/ai-automation.md b/docs/ai-automation.md index eb3a0c4..4923664 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -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、探索和辅助模型运行路径均为异步。 diff --git a/skills/artemis-use/references/modes.md b/skills/artemis-use/references/modes.md index a249dc9..d71eae1 100644 --- a/skills/artemis-use/references/modes.md +++ b/skills/artemis-use/references/modes.md @@ -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 默认值。 diff --git a/tests/unit/admin_console/test_model_settings.py b/tests/unit/admin_console/test_model_settings.py index 8641cee..37a7470 100644 --- a/tests/unit/admin_console/test_model_settings.py +++ b/tests/unit/admin_console/test_model_settings.py @@ -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 diff --git a/tests/unit/core/test_transport_defaults.py b/tests/unit/core/test_transport_defaults.py new file mode 100644 index 0000000..b8eb7a6 --- /dev/null +++ b/tests/unit/core/test_transport_defaults.py @@ -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 diff --git a/tests/unit/test_llm_response_integrity.py b/tests/unit/test_llm_response_integrity.py index c95c713..eadf994 100644 --- a/tests/unit/test_llm_response_integrity.py +++ b/tests/unit/test_llm_response_integrity.py @@ -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( diff --git a/tests/unit/test_model_proxy.py b/tests/unit/test_model_proxy.py index 0bcad0c..77fc332 100644 --- a/tests/unit/test_model_proxy.py +++ b/tests/unit/test_model_proxy.py @@ -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): diff --git a/tests/unit/test_openai_responses.py b/tests/unit/test_openai_responses.py index 5366fc7..446855c 100644 --- a/tests/unit/test_openai_responses.py +++ b/tests/unit/test_openai_responses.py @@ -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 = [] diff --git a/tests/unit/test_responses_websocket.py b/tests/unit/test_responses_websocket.py index d06e11e..d0417b3 100644 --- a/tests/unit/test_responses_websocket.py +++ b/tests/unit/test_responses_websocket.py @@ -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" ) diff --git a/tests/unit/test_responses_wire_lifecycle.py b/tests/unit/test_responses_wire_lifecycle.py index 3096893..53923e3 100644 --- a/tests/unit/test_responses_wire_lifecycle.py +++ b/tests/unit/test_responses_wire_lifecycle.py @@ -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)