feat: enable full AI automation and safe startup updates

This commit is contained in:
chenmingxuan committed 2026-09-22 23:50:03 +08:00
1 parent 2ddec53954
commit 7b1a0755e2
62 files changed
+3123 -240

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
"""Automation acceptance checks with isolated persistence and no real device side effects."""
from io import BytesIO
import json
import time
from unittest.mock import AsyncMock
import zipfile
from fastapi import FastAPI
from fastapi.testclient import TestClient
from openpyxl import load_workbook
import pytest
from apps.admin_console.routers import automation as api
from apps.admin_console.services import automation_service as service
from apps.admin_console.services import report_export as reports
from apps.admin_console.services import task_import
from apps.admin_console.services.automation_store import AutomationStore
from artemis.data_engine.storage import StorageManager
@pytest.fixture
def isolated(tmp_path, monkeypatch):
db = tmp_path / "data.db"
StorageManager(db_path=db, base_trace_dir=tmp_path)
store = AutomationStore(db)
for module in (api, service, reports):
monkeypatch.setattr(module, "store", store)
monkeypatch.setattr(reports, "TRACES_PATH", tmp_path / "traces")
monkeypatch.setattr(reports, "EXPORT_ROOT", tmp_path / "exports")
from apps.admin_console.database.repositories.session_repository import SessionRepository
from apps.admin_console.database.repositories.step_repository import StepRepository
monkeypatch.setattr(reports, "SessionRepository", lambda *a: SessionRepository(db))
monkeypatch.setattr(reports, "StepRepository", lambda *a: StepRepository(db))
return store
@pytest.fixture
def client(isolated):
app = FastAPI()
app.include_router(api.router)
return TestClient(app)
def payload(**extra):
return {
"name": "批次",
"cases": [{"name": "用例甲", "goal": "观察设置"}, {"name": "用例乙", "goal": "观察首页"}],
"device_serial": "test-device",
**extra,
}
def workbook_bytes(rows):
wb = load_workbook(BytesIO(task_import.template()))
for row in rows:
wb["任务"].append(row)
output = BytesIO()
wb.save(output)
return output.getvalue()
def test_template_preview_rejects_formula_and_bad_json():
data = workbook_bytes(
[
["正常", "打开设置", "", "", "", '{"name":"测试"}', "冒烟"],
["公式", "=SUM(1,2)"],
["参数错误", "测试", "", "", "", "{"],
]
)
result = task_import.parse_workbook(data)
assert len(result["tasks"]) == 1
assert [e["row"] for e in result["errors"]] == [3, 4]
assert not result["valid"]
def test_invalid_archive_and_empty_template():
with pytest.raises(ValueError):
task_import.parse_workbook(b"not a zip")
assert not task_import.parse_workbook(task_import.template())["valid"]
def test_preview_does_not_enqueue(client, isolated):
response = client.post(
"/api/automation/import/preview",
files={"file": ("tasks.xlsx", workbook_bytes([["案例", "观察页面"]]))},
)
assert response.status_code == 200
assert response.json()["valid"]
assert not isolated.jobs()
def test_preferences_and_required_device(client):
assert client.get("/api/automation/preferences").json()["formats"] == ["docx", "xlsx"]
assert client.put("/api/automation/preferences", json={"formats": ["xlsx"]}).status_code == 200
assert client.put("/api/automation/preferences", json={"formats": []}).status_code == 422
assert client.post("/api/automation/batches", json=payload(device_serial="")).status_code == 422
def test_restart_never_repeats_started_work(isolated):
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
job = isolated.jobs(batch["id"])[0]
isolated.save_job({**job, "status": "queued"})
service.recover_batches()
assert isolated.get("batch", batch["id"])["status"] == "paused"
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["interrupted", "pending"]
retry = service.batch_action(batch["id"], "retry_failed")
assert retry["status"] == "draft"
assert len(isolated.jobs(retry["id"])) == 1
assert isolated.jobs(retry["id"])[0]["session_id"] != job["session_id"]
@pytest.mark.asyncio
async def test_batch_admission_idempotent_and_bound_device(isolated, monkeypatch):
from apps.admin_console import server
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
monkeypatch.setattr(
"artemis.runtime.device_pool.validate_explicit_serial_async", AsyncMock(return_value=None)
)
from artemis.runtime.device_pool import DeviceStatus
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async",
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
)
await service.tick_batches()
await service.tick_batches()
assert enqueue.await_count == 1
assert enqueue.call_args.kwargs["device_serial"] == "test-device"
assert isolated.jobs(batch["id"])[1]["status"] == "pending"
@pytest.mark.asyncio
async def test_device_outage_pauses_without_consuming_pending_case(isolated, monkeypatch):
batch = service.create_batch(payload())
service.batch_action(batch["id"], "resume")
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
monkeypatch.setattr(
"artemis.runtime.device_pool.try_list_devices_async", AsyncMock(return_value=[])
)
await service.tick_batches()
assert isolated.get("batch", batch["id"])["status"] == "paused"
assert all(job["status"] == "pending" for job in isolated.jobs(batch["id"]))
@pytest.mark.asyncio
async def test_fail_fast_includes_skipped_reports(isolated, monkeypatch):
batch = service.create_batch(payload(stop_on_failure=True))
first = isolated.jobs(batch["id"])[0]
isolated.save_job({**first, "status": "failed"})
service.batch_action(batch["id"], "resume")
await service.tick_batches()
assert [j["status"] for j in isolated.jobs(batch["id"])] == ["failed", "skipped"]
archive = reports.export_batch(batch["id"])
with zipfile.ZipFile(archive) as z:
assert len(z.namelist()) == 6 # two summary files + two files per task
assert sum(n.endswith(".docx") for n in z.namelist()) == 3
@pytest.mark.asyncio
async def test_report_failure_does_not_strand_job(isolated, monkeypatch):
job = isolated.jobs(service.create_batch(payload())["id"])[0]
monkeypatch.setattr(
reports, "export_session", lambda *a: (_ for _ in ()).throw(OSError("disk full"))
)
await service.on_terminal({"session_id": job["session_id"]}, "completed")
saved = isolated.job(job["session_id"])
assert saved["status"] == "completed"
assert "disk full" in saved["report_error"]
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
isolated.save_job(
{**job, "case": {"name": "=1+1", "goal": '=HYPERLINK("x")'}, "status": "completed"}
)
report = reports.session_report(job["session_id"])
assert report["verdict"].startswith("未验证")
assert reports.verdict({"tests": {"passed": 2, "failed": 1}}) == "验证失败"
assert reports.verdict({"tests": {"passed": 2, "unchecked": 1}}) == "验证不完整"
paths = reports.export_session(job["session_id"])
wb = load_workbook(next(p for p in paths if p.suffix == ".xlsx"))
assert wb["任务结果"]["B3"].data_type == "s"
with zipfile.ZipFile(next(p for p in paths if p.suffix == ".docx")) as doc:
assert "未验证".encode() in doc.read("word/document.xml")
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
batch = service.create_batch(payload())
first, second = isolated.jobs(batch["id"])
isolated.save_job(
{
**first,
"status": "completed",
"created_at": 100,
"case": {"name": "搜索_%", "goal": "abc", "tags": "回归"},
}
)
isolated.save_job({**second, "status": "failed", "created_at": 200})
assert client.get("/api/automation/history", params={"q": "_%"}).json()["total"] == 1
assert (
client.get("/api/automation/history", params={"q": "回归"}).json()["items"][0]["session_id"]
== first["session_id"]
)
assert (
client.get(
"/api/automation/history", params={"start": 150, "end": 250, "status": "failed"}
).json()["total"]
== 1
)
assert (
client.get("/api/automation/history", params={"size": 1, "page": 2}).json()["items"][0][
"session_id"
]
== first["session_id"]
)
def test_batch_submission_is_atomic_and_idempotent(client, isolated):
value = payload(request_id="same-request")
first = client.post("/api/automation/batches", json=value)
second = client.post("/api/automation/batches", json=value)
assert first.status_code == second.status_code == 200
assert first.json()["id"] == second.json()["id"]
assert len(isolated.jobs()) == 2
assert len(isolated.records("batch")) == 1
conflict = client.post("/api/automation/batches", json={**value, "name": "different"})
assert conflict.status_code == 409
lookup = client.get("/api/automation/batches/by-request/same-request").json()
assert lookup["id"] == first.json()["id"]
assert len(lookup["jobs"]) == 2
def test_removed_replay_is_inert_but_history_survives(client, isolated):
batch = service.create_batch(payload())
job = isolated.jobs(batch["id"])[0]
isolated.save_job({**job, "execution_mode": "strict", "workflow_id": "legacy"})
service.recover_batches()
assert isolated.get("batch", batch["id"])["status"] == "cancelled"
assert all(j["status"] == "cancelled" for j in isolated.jobs(batch["id"]))
assert client.post(f"/api/automation/batches/{batch['id']}/resume").status_code == 409
assert client.post(f"/api/automation/batches/{batch['id']}/retry_failed").status_code == 409
assert client.get("/api/automation/workflows").status_code == 404
assert client.get("/api/automation/history").json()["total"] == 2
@@ -22,6 +22,8 @@ def isolated_settings(monkeypatch, tmp_path):
"OPENAI_BASE_URL",
"OPENAI_TIMEOUT_SECONDS",
"OPENAI_MAX_RETRIES",
"OPENAI_RETRY_INTERVAL_SECONDS",
"OPENAI_REASONING_EFFORT",
"ARTEMIS_VIDEO_ENABLED",
"ARTEMIS_OUTPUTTER_ENABLED",
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
@@ -85,7 +87,7 @@ def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
assert config.pro.video_analyzer.enabled is False
assert config.outputter.enabled is False
assert config.flash.step_summarizer.enabled is False
service.save_capability_settings(service.CapabilitySettings())
service.save_capability_settings(service.CapabilitySettings(video_enabled=True))
assert load_agent_config().video_analyzer.enabled is True
@@ -106,3 +108,34 @@ def test_key_not_in_validation_errors(isolated_settings):
service.ModelSettingsUpdate(model="test", api_key="secret\nvalue")
)
assert "secret" not in str(exc.value)
def test_reasoning_retry_settings_roundtrip(isolated_settings):
result = service.save_model_settings(service.ModelSettingsUpdate(model="gpt-5.6-luna"))
assert result["timeout_seconds"] == 300
assert result["max_retries"] == 5
assert result["retry_interval_seconds"] == 10
assert result["reasoning_effort"] == "high"
saved = Settings(_env_file=isolated_settings)
assert saved.OPENAI_REASONING_EFFORT == "high"
assert parse_llm_config().operator.reasoning_effort == "high"
service.save_model_settings(
service.ModelSettingsUpdate(model="custom", reasoning_effort="default")
)
assert parse_llm_config().operator.reasoning_effort == "default"
def test_responses_payload_has_reasoning_and_no_sdk_retry(monkeypatch):
from artemis.llm.router import ModelEndpoint, ModelFactory
from unittest.mock import Mock
factory = Mock()
monkeypatch.setattr("langchain_openai.ChatOpenAI", factory)
ModelFactory.create_model(
ModelEndpoint(api_key="test-only", reasoning_effort="high", max_retries=5)
)
assert factory.call_args.kwargs["reasoning_effort"] == "high"
assert factory.call_args.kwargs["max_retries"] == 0
assert factory.call_args.kwargs["use_responses_api"] is True
ModelFactory.create_model(ModelEndpoint(api_key="test-only", reasoning_effort="default"))
assert "reasoning_effort" not in factory.call_args.kwargs
+2 -2
View File
@@ -241,8 +241,8 @@ async def test_invoke_with_retry_caps_unknown_failures_by_policy():
with patch.object(va.asyncio, "sleep", new=AsyncMock()):
with pytest.raises(RuntimeError):
await va._invoke_with_retry(op, "test-op", max_attempts=5)
# UNKNOWN failures follow the shared policy, not the caller's outer cap.
assert calls == retry_policy_for(FailureCategory.UNKNOWN).max_attempts
# Respect both the configured policy and the caller's explicit cap.
assert calls == min(5, retry_policy_for(FailureCategory.UNKNOWN).max_attempts)
# ------------------------------------------------ mid-run agentic degrade
+95
View File
@@ -0,0 +1,95 @@
"""MCP transport contracts: no real device actions, files and HTTP are isolated."""
from unittest.mock import AsyncMock
import httpx
import pytest
from mcp_server.tools import automation as tools
@pytest.mark.asyncio
async def test_config_requires_explicit_persistence_and_excludes_secrets(monkeypatch):
request = AsyncMock(
return_value={
"model": "old",
"base_url": "https://example.test/v1",
"api_key_configured": True,
"env_path": "/private/.env",
"reasoning_effort": "high",
}
)
monkeypatch.setattr(tools, "_request", request)
with pytest.raises(ValueError, match="persist_defaults"):
await tools.mobile_config(changes={"model": "new"})
with pytest.raises(ValueError, match="密钥"):
await tools.mobile_config(changes={"api_key": "never-sent"}, persist_defaults=True)
request.assert_not_awaited()
await tools.mobile_config(changes={"model": "new"}, persist_defaults=True)
payload = request.call_args.kwargs["json"]
assert payload == {
"model": "new",
"base_url": "https://example.test/v1",
"reasoning_effort": "high",
}
@pytest.mark.asyncio
async def test_preview_is_read_only_and_create_is_draft(monkeypatch, tmp_path):
source = tmp_path / "cases.xlsx"
source.write_bytes(b"test workbook")
request = AsyncMock(return_value={"valid": True, "tasks": [], "errors": []})
monkeypatch.setattr(tools, "_request", request)
await tools.mobile_task_file("preview", str(source))
assert request.call_args.args == ("POST", "/api/automation/import/preview")
request.reset_mock()
with pytest.raises(ValueError):
await tools.mobile_batch("create", batch={"name": "x"})
request.assert_not_awaited()
await tools.mobile_batch("create", request_id="stable-id", batch={"name": "x"})
assert request.await_count == 1
assert request.call_args.kwargs["json"]["request_id"] == "stable-id"
assert request.call_args.args[1] == "/api/automation/batches"
@pytest.mark.asyncio
async def test_running_batch_start_is_idempotent(monkeypatch):
request = AsyncMock(return_value={"id": "batch", "status": "running"})
monkeypatch.setattr(tools, "_request", request)
await tools.mobile_batch("start", batch_id="batch")
assert request.await_count == 1
assert request.call_args.args[0] == "GET"
@pytest.mark.asyncio
async def test_download_writes_actual_binary_atomically(monkeypatch, tmp_path):
monkeypatch.setattr(tools, "EXPORT_DIR", tmp_path)
monkeypatch.setattr(tools, "_base_url", AsyncMock(return_value="http://test"))
client_type = httpx.AsyncClient
transport = httpx.MockTransport(
lambda request: httpx.Response(200, content=b"PK\x03\x04report-bytes")
)
monkeypatch.setattr(
tools.httpx, "AsyncClient", lambda **kw: client_type(transport=transport, **kw)
)
result = await tools._download("/api/automation/reports/test/docx", "test.docx")
from pathlib import Path
assert Path(result["path"]).read_bytes() == b"PK\x03\x04report-bytes"
assert Path(result["path"]).is_absolute()
assert result["download_url"] == "http://test/api/automation/reports/test/docx"
assert not list(tmp_path.glob("*.part"))
@pytest.mark.asyncio
async def test_export_uses_saved_formats_without_mutating_execution(monkeypatch):
request = AsyncMock(
side_effect=[{"formats": ["xlsx"]}, {"verdict": "未验证", "status": "completed"}]
)
download = AsyncMock(return_value={"path": "/exports/report.xlsx"})
monkeypatch.setattr(tools, "_request", request)
monkeypatch.setattr(tools, "_download", download)
result = await tools.mobile_export_report(session_id="session")
assert result["report"]["verdict"] == "未验证"
assert len(result["files"]) == 1
assert all(call.args[0] == "GET" for call in request.call_args_list)
assert download.call_args.args[0].endswith("/xlsx")
+18
View File
@@ -197,6 +197,12 @@ def test_cli_mcp_generate_config_antigravity():
"mobile_get_device_state",
"mobile_inspect_trace",
"mobile_diagnose",
"mobile_workspace",
"mobile_config",
"mobile_task_file",
"mobile_batch",
"mobile_search_history",
"mobile_export_report",
}
assert all(tool["eager"] is True for tool in legacy_config["tools"].values())
@@ -236,6 +242,12 @@ def test_cli_mcp_generate_config_codex():
"mobile_get_device_state",
"mobile_inspect_trace",
"mobile_diagnose",
"mobile_workspace",
"mobile_config",
"mobile_task_file",
"mobile_batch",
"mobile_search_history",
"mobile_export_report",
]
@@ -433,6 +445,12 @@ def test_cli_mcp_install_codex_preserves_config_and_is_idempotent(tmp_path, monk
"mobile_get_device_state",
"mobile_inspect_trace",
"mobile_diagnose",
"mobile_workspace",
"mobile_config",
"mobile_task_file",
"mobile_batch",
"mobile_search_history",
"mobile_export_report",
]
assert data["mcp_servers"]["artemis"]["env"]["PYTHONPATH"] == str(tmp_path)
assert config_text.count("# BEGIN ARTEMIS MCP CONFIG") == 1
+158
View File
@@ -0,0 +1,158 @@
"""Exercise upgrades against disposable Git repos and client configs, never the user's repo."""
import importlib.util
import json
from pathlib import Path
import shutil
import subprocess
import tomllib
import pytest
MODULE_PATH = Path(__file__).resolve().parents[2] / "scripts/update_installation.py"
spec = importlib.util.spec_from_file_location("installation_update", MODULE_PATH)
update = importlib.util.module_from_spec(spec)
spec.loader.exec_module(update)
def git(path, *args):
result = subprocess.run(
["git", "-C", str(path), *args], capture_output=True, text=True, timeout=15
)
assert result.returncode == 0, result.stderr
return result.stdout.strip()
@pytest.fixture
def repos(tmp_path):
if not shutil.which("git"):
pytest.skip("Git required")
remote = tmp_path / "upstream.git"
remote.mkdir()
git(remote, "init", "--bare")
author = tmp_path / "author"
author.mkdir()
git(author, "init", "-b", "main")
git(author, "config", "user.name", "Test Fixture")
git(author, "config", "user.email", "fixture@example.test")
(author / "source.txt").write_text("version1")
(author / ".gitignore").write_text(".env\n.venv/\n")
git(author, "add", "source.txt", ".gitignore")
git(author, "commit", "-m", "initial")
git(author, "remote", "add", "origin", str(remote))
git(author, "push", "-u", "origin", "main")
installed = tmp_path / "installed with spaces"
git(tmp_path, "clone", "-b", "main", str(remote), str(installed))
(installed / ".env").write_text("secret stays local")
(author / "source.txt").write_text("version2")
git(author, "add", "source.txt")
git(author, "commit", "-m", "upgrade")
git(author, "push")
return installed, author
def test_fast_forward_update_preserves_env_and_is_repeatable(repos):
installed, author = repos
assert update.update_source(installed, port=1) == 10
assert (installed / "source.txt").read_text() == "version2"
assert (installed / ".env").read_text() == "secret stays local"
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
assert update.update_source(installed, port=1) == 0
def test_dirty_checkout_is_preserved(repos):
installed, _ = repos
before = git(installed, "rev-parse", "HEAD")
(installed / "source.txt").write_text("user edits")
assert update.update_source(installed, port=1) == 0
assert git(installed, "rev-parse", "HEAD") == before
assert (installed / "source.txt").read_text() == "user edits"
def test_diverged_checkout_is_not_merged(repos):
installed, _ = repos
git(installed, "config", "user.name", "Test Fixture")
git(installed, "config", "user.email", "fixture@example.test")
(installed / "local.txt").write_text("local commit")
git(installed, "add", "local.txt")
git(installed, "commit", "-m", "local")
before = git(installed, "rev-parse", "HEAD")
assert update.update_source(installed, port=1) == 0
assert git(installed, "rev-parse", "HEAD") == before
def test_zip_install_continues_without_git_mutation(tmp_path, capsys):
assert update.update_source(tmp_path, port=1) == 0
assert not (tmp_path / ".git").exists()
assert "Git" in capsys.readouterr().out
def test_codex_refresh_preserves_credentials_restrictions_and_other_settings(tmp_path):
root = tmp_path / "project"
path = tmp_path / "config.toml"
original = f"""model = "custom"
[mcp_servers.artemis]
command = "old-python"
cwd = {json.dumps(str(root))}
args = ["-m", "mcp_server"]
enabled = false
enabled_tools = [
"mobile_run_task",
]
disabled_tools = ["mobile_config"]
tool_timeout_sec = 900
[mcp_servers.artemis.env]
OPENAI_API_KEY = "test-only-secret"
[mcp_servers.other]
command = "leave-alone"
"""
path.write_text(original, encoding="utf-8")
assert update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch"))
data = tomllib.loads(path.read_text(encoding="utf-8"))
config = data["mcp_servers"]["artemis"]
assert config["enabled"] is False
assert config["disabled_tools"] == ["mobile_config"]
assert config["tool_timeout_sec"] == 900
assert config["env"]["OPENAI_API_KEY"] == "test-only-secret"
assert config["enabled_tools"] == ["mobile_run_task", "mobile_batch"]
assert data["mcp_servers"]["other"]["command"] == "leave-alone"
assert not update.refresh_codex(path, root, "new-python", ("mobile_run_task", "mobile_batch"))
assert path.with_name(path.name + ".artemis-update.bak").read_text(encoding="utf-8") == original
def test_json_only_updates_existing_matching_project(tmp_path):
path = tmp_path / "mcp.json"
root = tmp_path / "project"
config = {
"cwd": str(root),
"command": "old",
"args": ["-m", "mcp_server"],
"disabled": True,
"env": {"CUSTOM": "preserved"},
"autoApprove": [],
}
path.write_text(json.dumps({"mcpServers": {"artemis": config, "other": {"command": "keep"}}}))
assert not update.refresh_json(
path, ("mcpServers",), tmp_path / "different", "new", ("mobile_batch",)
)
assert update.refresh_json(path, ("mcpServers",), root, "new", ("mobile_batch",))
result = json.loads(path.read_text())["mcpServers"]
assert result["artemis"]["env"] == {"CUSTOM": "preserved"}
assert result["artemis"]["disabled"] is True
assert result["other"] == {"command": "keep"}
missing = tmp_path / "not-installed.json"
assert not update.refresh_json(missing, ("mcpServers",), root, "new", ())
assert not missing.exists()
def test_frontend_fingerprint_detects_lock_source_and_deleted_assets(tmp_path):
ui = tmp_path / "apps/showcase_ui"
(ui / "src").mkdir(parents=True)
(ui / "src/app.ts").write_text("one")
(ui / "package-lock.json").write_text("lock1")
first = update.frontend_fingerprint(tmp_path)
(ui / "package-lock.json").write_text("lock2")
second = update.frontend_fingerprint(tmp_path)
assert first != second
(ui / "src/app.ts").unlink()
assert update.frontend_fingerprint(tmp_path) != second
+54
View File
@@ -239,3 +239,57 @@ async def test_mid_stream_failure_records_stream_reset_payload(monkeypatch):
assert payload["reason"] == "mid_stream_failure"
assert "stream_exec_id" in payload
assert "lower API priority" in payload["message"]
@pytest.mark.asyncio
async def test_configured_retry_budget_is_shared_by_identical_fallback(monkeypatch):
from unittest.mock import AsyncMock
from artemis.llm.reliability import retry_policy_for
from artemis.llm.router import ModelEndpoint
monkeypatch.setattr(llm_service, "retry_policy_for", retry_policy_for)
monkeypatch.setattr(llm_service.settings, "OPENAI_MAX_RETRIES", 5)
monkeypatch.setattr(llm_service.settings, "OPENAI_RETRY_INTERVAL_SECONDS", 10)
monkeypatch.setattr(llm_service, "_wait_for_resume", AsyncMock(return_value=False))
sleep = AsyncMock()
monkeypatch.setattr(llm_service.asyncio, "sleep", sleep)
class Fails:
def __init__(self):
self.calls = 0
async def ainvoke(self, *args, **kwargs):
self.calls += 1
if self.calls % 2:
raise ConnectionError("connection reset")
raise TimeoutError("timeout")
main, fallback = Fails(), Fails()
endpoint = ModelEndpoint(model_name="test", api_base="https://example.test/v1", max_retries=5)
primary = RobustChatModelWrapper(main, endpoint=endpoint)
secondary = RobustChatModelWrapper(fallback, endpoint=endpoint.model_copy())
with pytest.raises(LLMExhaustedError):
await with_fallback(lambda: primary.ainvoke([]), lambda: secondary.ainvoke([]))
assert main.calls == 6
assert fallback.calls == 0
assert [call.args[0] for call in sleep.call_args_list] == [10] * 5
assert llm_service._FAILED_ENDPOINTS.get() is None
@pytest.mark.asyncio
async def test_request_deadline_cancels_attempt_and_respects_zero_retries(monkeypatch):
import asyncio
from unittest.mock import AsyncMock
monkeypatch.setattr(llm_service, "_wait_for_resume", AsyncMock(return_value=False))
cancelled = asyncio.Event()
async def pending():
try:
await asyncio.Event().wait()
finally:
cancelled.set()
with pytest.raises(LLMExhaustedError):
await llm_service._run_with_recovery(pending, request_timeout=0.005, max_retries=0)
assert cancelled.is_set()
+1 -1
View File
@@ -157,7 +157,7 @@ def test_client_settings_and_cache_credentials(monkeypatch, max_retries):
endpoint = ModelEndpoint(api_key="test", model_name="gpt-5-mini", reasoning_effort="low")
model = ModelFactory.create_model(endpoint)
assert model.use_responses_api is True
assert model.max_retries == max_retries
assert model.max_retries == 0 # Gateway owns retries, never multiply at SDK layer.
assert model.reasoning_effort == "low"
dynamic = ModelEndpoint()
key = dynamic.cache_key()