5a558eb09e
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled
745 lines
24 KiB
Python
745 lines
24 KiB
Python
import asyncio
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from opik.api_objects.prompt import mask_context as prompt_mask_context_module
|
|
from opik.rest_api.core.api_error import ApiError
|
|
from opik.rest_api.types.local_runner_heartbeat_response import (
|
|
LocalRunnerHeartbeatResponse,
|
|
)
|
|
from opik.rest_api.types.local_runner_job import LocalRunnerJob
|
|
from opik.runner import in_process_loop, registry
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_api():
|
|
api = MagicMock()
|
|
api.runners.heartbeat = MagicMock(
|
|
return_value=LocalRunnerHeartbeatResponse(cancelled_job_ids=[])
|
|
)
|
|
api.runners.next_job = MagicMock(return_value=None)
|
|
api.runners.report_job_result = MagicMock()
|
|
api.runners.register_agents = MagicMock()
|
|
return api
|
|
|
|
|
|
@pytest.fixture
|
|
def shutdown_event():
|
|
return threading.Event()
|
|
|
|
|
|
@pytest.fixture
|
|
def loop(mock_api, shutdown_event):
|
|
return in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
heartbeat_interval_seconds=100,
|
|
poll_idle_interval_seconds=0.01,
|
|
)
|
|
|
|
|
|
class TestHeartbeatLoop:
|
|
def test_heartbeat__410__triggers_shutdown(self, mock_api, shutdown_event, loop):
|
|
loop._heartbeat_interval_seconds = 0.05
|
|
mock_api.runners.heartbeat.side_effect = ApiError(status_code=410)
|
|
|
|
t = threading.Thread(target=loop._heartbeat_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
assert shutdown_event.is_set()
|
|
|
|
def test_heartbeat__cancelled_ids__tracked(self, mock_api, shutdown_event, loop):
|
|
loop._heartbeat_interval_seconds = 0.05
|
|
call_count = 0
|
|
|
|
def heartbeat_side_effect(runner_id, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return LocalRunnerHeartbeatResponse(cancelled_job_ids=["j-1"])
|
|
shutdown_event.set()
|
|
return LocalRunnerHeartbeatResponse(cancelled_job_ids=[])
|
|
|
|
mock_api.runners.heartbeat.side_effect = heartbeat_side_effect
|
|
|
|
t = threading.Thread(target=loop._heartbeat_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
assert "j-1" in loop._cancelled_jobs
|
|
|
|
|
|
class TestPollLoop:
|
|
def test_poll__no_jobs__polls_repeatedly(self, mock_api, shutdown_event, loop):
|
|
call_count = 0
|
|
|
|
def side_effect(runner_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count >= 3:
|
|
shutdown_event.set()
|
|
return None
|
|
|
|
mock_api.runners.next_job.side_effect = side_effect
|
|
loop._loop = asyncio.new_event_loop()
|
|
|
|
t = threading.Thread(target=loop._poll_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
assert call_count >= 3
|
|
|
|
def test_poll__error__backs_off(self, mock_api, shutdown_event):
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
heartbeat_interval_seconds=100,
|
|
backoff_cap_seconds=0.01,
|
|
)
|
|
lp._loop = asyncio.new_event_loop()
|
|
call_count = 0
|
|
|
|
def side_effect(runner_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count >= 2:
|
|
shutdown_event.set()
|
|
return None
|
|
raise ConnectionError("fail")
|
|
|
|
mock_api.runners.next_job.side_effect = side_effect
|
|
|
|
t = threading.Thread(target=lp._poll_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
assert call_count >= 2
|
|
|
|
def test_poll__job_available__queued(self, mock_api, shutdown_event, loop):
|
|
job = LocalRunnerJob(id="j-1", agent_name="test", inputs={"q": "hi"})
|
|
call_count = 0
|
|
|
|
def side_effect(runner_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return job
|
|
shutdown_event.set()
|
|
return None
|
|
|
|
mock_api.runners.next_job.side_effect = side_effect
|
|
|
|
aio_loop = asyncio.new_event_loop()
|
|
loop._loop = aio_loop
|
|
|
|
calls = []
|
|
|
|
def mock_call_soon(fn, *args):
|
|
calls.append(args)
|
|
fn(*args)
|
|
|
|
loop._loop.call_soon_threadsafe = mock_call_soon
|
|
|
|
t = threading.Thread(target=loop._poll_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
assert len(calls) >= 1
|
|
assert calls[0][0].id == "j-1"
|
|
|
|
|
|
class TestPollFailureLogging:
|
|
@staticmethod
|
|
def _make_loop(mock_api, shutdown_event):
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
heartbeat_interval_seconds=100,
|
|
poll_idle_interval_seconds=0.01,
|
|
initial_backoff_seconds=0.001,
|
|
backoff_cap_seconds=0.001,
|
|
)
|
|
lp._loop = asyncio.new_event_loop()
|
|
return lp
|
|
|
|
def test_poll__sustained_429__warns_with_firewall_and_interval_hint(
|
|
self, mock_api, shutdown_event, capfd
|
|
):
|
|
lp = self._make_loop(mock_api, shutdown_event)
|
|
call_count = 0
|
|
|
|
def side_effect(runner_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count >= in_process_loop._POLL_FAILURE_HINT_THRESHOLD:
|
|
shutdown_event.set()
|
|
raise ApiError(status_code=429, body="too many requests")
|
|
|
|
mock_api.runners.next_job.side_effect = side_effect
|
|
|
|
t = threading.Thread(target=lp._poll_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
stderr = capfd.readouterr().err
|
|
assert "OPIK_RUNNER_POLL_INTERVAL" in stderr
|
|
assert "firewall or proxy" in stderr
|
|
assert "429" in stderr
|
|
# Full error detail is surfaced, not just a friendly message.
|
|
assert "status_code: 429" in stderr
|
|
assert "too many requests" in stderr
|
|
|
|
def test_poll__sustained_connection_error__hint_without_rate_limit_note(
|
|
self, mock_api, shutdown_event, capfd
|
|
):
|
|
lp = self._make_loop(mock_api, shutdown_event)
|
|
call_count = 0
|
|
|
|
def side_effect(runner_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count >= in_process_loop._POLL_FAILURE_HINT_THRESHOLD:
|
|
shutdown_event.set()
|
|
raise ConnectionError("name resolution failed")
|
|
|
|
mock_api.runners.next_job.side_effect = side_effect
|
|
|
|
t = threading.Thread(target=lp._poll_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
stderr = capfd.readouterr().err
|
|
assert "firewall or proxy" in stderr
|
|
assert "429" not in stderr
|
|
# Full error detail (message + type via traceback) is surfaced.
|
|
assert "name resolution failed" in stderr
|
|
assert "ConnectionError" in stderr
|
|
|
|
def test_poll__recovers_after_failures__logs_reconnected(
|
|
self, mock_api, shutdown_event, capfd
|
|
):
|
|
lp = self._make_loop(mock_api, shutdown_event)
|
|
call_count = 0
|
|
|
|
def side_effect(runner_id):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
raise ConnectionError("transient")
|
|
shutdown_event.set()
|
|
return None
|
|
|
|
mock_api.runners.next_job.side_effect = side_effect
|
|
|
|
t = threading.Thread(target=lp._poll_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
stderr = capfd.readouterr().err
|
|
assert "Reconnected to Opik server" in stderr
|
|
|
|
def test_poll__success_resets_counter__intermittent_failures_stay_quiet(
|
|
self, mock_api, shutdown_event, capfd
|
|
):
|
|
lp = self._make_loop(mock_api, shutdown_event)
|
|
# Two failures, a success (resets), two more failures: never 3 in a row.
|
|
outcomes = ["fail", "fail", "ok", "fail", "fail", "stop"]
|
|
call_count = 0
|
|
|
|
def side_effect(runner_id):
|
|
nonlocal call_count
|
|
outcome = outcomes[min(call_count, len(outcomes) - 1)]
|
|
call_count += 1
|
|
if outcome == "stop":
|
|
shutdown_event.set()
|
|
return None
|
|
if outcome == "ok":
|
|
return None
|
|
raise ConnectionError("blip")
|
|
|
|
mock_api.runners.next_job.side_effect = side_effect
|
|
|
|
t = threading.Thread(target=lp._poll_loop)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
|
|
stderr = capfd.readouterr().err
|
|
assert "firewall or proxy" not in stderr
|
|
assert "Reconnected to Opik server" in stderr
|
|
|
|
|
|
class TestJobExecution:
|
|
def test_execute_job__sync_entrypoint__calls_function(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
def my_agent(q, **kwargs):
|
|
return f"answer: {q}"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(id="j-1", agent_name="my_agent", inputs={"q": "hello"})
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
call_kwargs = mock_api.runners.report_job_result.call_args[1]
|
|
assert call_kwargs["status"] == "completed"
|
|
|
|
def test_execute_job__async_entrypoint__calls_function(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
async def my_agent(q, **kwargs):
|
|
return f"answer: {q}"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(id="j-1", agent_name="my_agent", inputs={"q": "hello"})
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
call_kwargs = mock_api.runners.report_job_result.call_args[1]
|
|
assert call_kwargs["status"] == "completed"
|
|
|
|
def test_execute_job__unknown_agent__reports_failed(self, mock_api, shutdown_event):
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(id="j-1", agent_name="missing", inputs={})
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
call_kwargs = mock_api.runners.report_job_result.call_args[1]
|
|
assert call_kwargs["status"] == "failed"
|
|
assert "Unknown agent" in call_kwargs["error"]
|
|
|
|
def test_execute_job__exception__reports_failed(self, mock_api, shutdown_event):
|
|
def bad_agent(**kwargs):
|
|
raise ValueError("boom")
|
|
|
|
registry.register("bad", bad_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(id="j-1", agent_name="bad", inputs={})
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
call_kwargs = mock_api.runners.report_job_result.call_args[1]
|
|
assert call_kwargs["status"] == "failed"
|
|
assert "ValueError" in call_kwargs["error"]
|
|
|
|
def test_execute_job__timeout__reports_failed(self, mock_api, shutdown_event):
|
|
def slow_agent(**kwargs):
|
|
time.sleep(5)
|
|
|
|
registry.register("slow", slow_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(id="j-1", agent_name="slow", inputs={}, timeout=1)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
call_kwargs = mock_api.runners.report_job_result.call_args[1]
|
|
assert call_kwargs["status"] == "failed"
|
|
assert "timed out" in call_kwargs["error"].lower()
|
|
|
|
def test_execute_job__cancelled__skipped(self, mock_api, shutdown_event):
|
|
def my_agent(**kwargs):
|
|
return "ok"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
lp._cancelled_jobs["j-cancel"] = 0.0
|
|
|
|
job = LocalRunnerJob(id="j-cancel", agent_name="my_agent", inputs={})
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
mock_api.runners.report_job_result.assert_not_called()
|
|
|
|
def test_execute_job__trace_id__merges_opik_args(self, mock_api, shutdown_event):
|
|
captured_kwargs = {}
|
|
|
|
def my_agent(**kwargs):
|
|
captured_kwargs.update(kwargs)
|
|
return "ok"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(
|
|
id="j-1",
|
|
agent_name="my_agent",
|
|
inputs={
|
|
"opik_args": {
|
|
"trace": {"tags": ["existing"]},
|
|
"span": {"metadata": {"k": "v"}},
|
|
}
|
|
},
|
|
)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
assert len(captured_kwargs["opik_args"]["trace"]["id"]) > 0 # injected trace ID
|
|
assert captured_kwargs["opik_args"]["trace"]["tags"] == ["existing"]
|
|
assert captured_kwargs["opik_args"]["span"]["metadata"] == {"k": "v"}
|
|
|
|
def test_execute_job__reports_running_before_function__running_precedes_func_and_completed(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
call_order = []
|
|
|
|
def my_agent(**kwargs):
|
|
call_order.append("func")
|
|
return "ok"
|
|
|
|
def track_report(job_id, *, status, **kwargs):
|
|
call_order.append(status)
|
|
|
|
mock_api.runners.report_job_result.side_effect = track_report
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(mock_api, "r-1", shutdown_event)
|
|
job = LocalRunnerJob(id="j-1", agent_name="my_agent", inputs={})
|
|
|
|
aio_loop = asyncio.new_event_loop()
|
|
aio_loop.run_until_complete(lp._execute_job(job))
|
|
aio_loop.close()
|
|
|
|
assert call_order == ["running", "func", "completed"]
|
|
|
|
def test_execute_job__running_report__uses_same_generated_trace_id_as_completed(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
def my_agent(**kwargs):
|
|
return "ok"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(mock_api, "r-1", shutdown_event)
|
|
job = LocalRunnerJob(id="j-1", agent_name="my_agent", inputs={})
|
|
|
|
aio_loop = asyncio.new_event_loop()
|
|
aio_loop.run_until_complete(lp._execute_job(job))
|
|
aio_loop.close()
|
|
|
|
calls = mock_api.runners.report_job_result.call_args_list
|
|
assert len(calls) == 2
|
|
running_kwargs = calls[0][1]
|
|
completed_kwargs = calls[1][1]
|
|
assert running_kwargs["status"] == "running"
|
|
assert completed_kwargs["status"] == "completed"
|
|
# Both calls share the same generated trace_id
|
|
assert running_kwargs["trace_id"] == completed_kwargs["trace_id"]
|
|
assert len(running_kwargs["trace_id"]) > 0
|
|
|
|
def test_execute_job__typed_params__string_inputs_cast_before_call(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
captured = {}
|
|
|
|
def my_agent(query: str, count: int, score: float, active: bool, **kwargs):
|
|
captured["query"] = query
|
|
captured["count"] = count
|
|
captured["score"] = score
|
|
captured["active"] = active
|
|
|
|
params = registry.extract_params(my_agent)
|
|
registry.register("typed_agent", my_agent, "proj", params, "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(mock_api, "r-1", shutdown_event)
|
|
|
|
job = LocalRunnerJob(
|
|
id="j-1",
|
|
agent_name="typed_agent",
|
|
inputs={"query": "hello", "count": "5", "score": "3.14", "active": "true"},
|
|
)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
assert captured == {"query": "hello", "count": 5, "score": 3.14, "active": True}
|
|
assert type(captured["count"]) is int
|
|
assert type(captured["score"]) is float
|
|
assert type(captured["active"]) is bool
|
|
|
|
def test_execute_job__invalid_input_type__reports_failed(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
def my_agent(count: int, **kwargs):
|
|
pass
|
|
|
|
params = registry.extract_params(my_agent)
|
|
registry.register("typed_fail", my_agent, "proj", params, "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(mock_api, "r-1", shutdown_event)
|
|
job = LocalRunnerJob(
|
|
id="j-1",
|
|
agent_name="typed_fail",
|
|
inputs={"count": "3.9"},
|
|
)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
call_kwargs = mock_api.runners.report_job_result.call_args[1]
|
|
assert call_kwargs["status"] == "failed"
|
|
assert "TypeError" in call_kwargs["error"]
|
|
|
|
def test_execute_job__report_failure__does_not_raise(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
def my_agent(**kwargs):
|
|
return "ok"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
mock_api.runners.report_job_result.side_effect = RuntimeError("network down")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(id="j-1", agent_name="my_agent", inputs={})
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
# All report calls go through _safe_report_job_result — failures are swallowed.
|
|
assert mock_api.runners.report_job_result.call_count == 2
|
|
|
|
def test_execute_job__prompt_masks__activates_mask_context_during_execution(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
captured = {}
|
|
|
|
def my_agent(**kwargs):
|
|
captured["p1"] = prompt_mask_context_module.get_mask_for_prompt("prompt-1")
|
|
captured["p2"] = prompt_mask_context_module.get_mask_for_prompt("prompt-2")
|
|
captured["unknown"] = prompt_mask_context_module.get_mask_for_prompt(
|
|
"prompt-unknown"
|
|
)
|
|
return "ok"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(
|
|
id="j-1",
|
|
agent_name="my_agent",
|
|
inputs={},
|
|
prompt_masks={"prompt-1": "mask-a", "prompt-2": "mask-b"},
|
|
)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
assert captured["p1"] == "mask-a"
|
|
assert captured["p2"] == "mask-b"
|
|
assert captured["unknown"] is None
|
|
assert prompt_mask_context_module.get_active_prompt_masks() is None
|
|
|
|
def test_execute_job__prompt_masks_absent__mask_context_inactive(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
captured = {}
|
|
|
|
def my_agent(**kwargs):
|
|
captured["p1"] = prompt_mask_context_module.get_mask_for_prompt("prompt-1")
|
|
captured["all"] = prompt_mask_context_module.get_active_prompt_masks()
|
|
return "ok"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(
|
|
mock_api,
|
|
"r-1",
|
|
shutdown_event,
|
|
)
|
|
|
|
job = LocalRunnerJob(id="j-1", agent_name="my_agent", inputs={})
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop.run_until_complete(lp._execute_job(job))
|
|
loop.close()
|
|
|
|
assert captured["p1"] is None
|
|
assert captured["all"] is None
|
|
|
|
|
|
class TestInjectTraceId:
|
|
def test_absent_key__injects_fresh_opik_args(self):
|
|
inputs: dict = {}
|
|
in_process_loop._inject_trace_id(inputs, "tid-1")
|
|
assert inputs["opik_args"]["trace"]["id"] == "tid-1"
|
|
|
|
def test_dict_value__merges_trace_id(self):
|
|
inputs = {"opik_args": {"trace": {"tags": ["t"]}, "span": {"k": "v"}}}
|
|
in_process_loop._inject_trace_id(inputs, "tid-2")
|
|
assert inputs["opik_args"]["trace"]["id"] == "tid-2"
|
|
assert inputs["opik_args"]["trace"]["tags"] == ["t"]
|
|
assert inputs["opik_args"]["span"] == {"k": "v"}
|
|
|
|
def test_explicit_none__leaves_inputs_unchanged(self):
|
|
inputs: dict = {"opik_args": None}
|
|
in_process_loop._inject_trace_id(inputs, "tid-3")
|
|
assert inputs["opik_args"] is None
|
|
|
|
def test_trace_none__treats_as_empty(self):
|
|
inputs = {"opik_args": {"trace": None}}
|
|
in_process_loop._inject_trace_id(inputs, "tid-4")
|
|
assert inputs["opik_args"]["trace"]["id"] == "tid-4"
|
|
|
|
def test_non_dict_opik_args__replaces_with_fresh(self):
|
|
inputs: dict = {"opik_args": "unexpected"}
|
|
in_process_loop._inject_trace_id(inputs, "tid-5")
|
|
assert inputs["opik_args"]["trace"]["id"] == "tid-5"
|
|
|
|
def test_does_not_mutate_original_dict(self):
|
|
original_trace = {"tags": ["x"]}
|
|
original_opik = {"trace": original_trace}
|
|
inputs = {"opik_args": original_opik}
|
|
in_process_loop._inject_trace_id(inputs, "tid-6")
|
|
assert original_opik.get("trace", {}).get("id") is None
|
|
assert original_trace.get("id") is None
|
|
|
|
|
|
class TestJobLogs:
|
|
@staticmethod
|
|
def _run_with_streamer(lp, coro):
|
|
loop = asyncio.new_event_loop()
|
|
lp._loop = loop
|
|
from opik.runner.log_streamer import LogStreamer
|
|
|
|
lp._log_streamer = LogStreamer(lp._api, loop)
|
|
|
|
async def _wrapper():
|
|
lp._log_streamer.start()
|
|
await coro
|
|
await asyncio.sleep(0.1)
|
|
await lp._log_streamer.stop()
|
|
|
|
loop.run_until_complete(_wrapper())
|
|
loop.close()
|
|
|
|
@staticmethod
|
|
def _enqueue(lp, job_id, text):
|
|
from opik.rest_api.types.local_runner_log_entry import LocalRunnerLogEntry
|
|
|
|
lp._log_streamer._queue.put_nowait(
|
|
(job_id, LocalRunnerLogEntry(stream="stdout", text=text))
|
|
)
|
|
|
|
def test_execute_job__logs_sent_when_present(self, mock_api, shutdown_event):
|
|
lp = in_process_loop.InProcessRunnerLoop(mock_api, "r-1", shutdown_event)
|
|
|
|
async def my_agent(**kwargs):
|
|
self._enqueue(lp, "j-log-1", "hello\n")
|
|
return "ok"
|
|
|
|
registry.register("my_agent", my_agent, "proj", [], "")
|
|
|
|
job = LocalRunnerJob(id="j-log-1", agent_name="my_agent", inputs={})
|
|
self._run_with_streamer(lp, lp._execute_job(job))
|
|
|
|
mock_api.runners.append_job_logs.assert_called_once()
|
|
call_kwargs = mock_api.runners.append_job_logs.call_args[1]
|
|
assert call_kwargs["job_id"] == "j-log-1"
|
|
assert any("hello" in e.text for e in call_kwargs["request"])
|
|
|
|
def test_execute_job__no_logs__append_not_called(self, mock_api, shutdown_event):
|
|
async def silent_agent(**kwargs):
|
|
return "ok"
|
|
|
|
registry.register("silent_agent", silent_agent, "proj", [], "")
|
|
|
|
lp = in_process_loop.InProcessRunnerLoop(mock_api, "r-1", shutdown_event)
|
|
job = LocalRunnerJob(id="j-log-2", agent_name="silent_agent", inputs={})
|
|
self._run_with_streamer(lp, lp._execute_job(job))
|
|
|
|
mock_api.runners.append_job_logs.assert_not_called()
|
|
|
|
def test_execute_job__exception__logs_sent_then_result_failed(
|
|
self, mock_api, shutdown_event
|
|
):
|
|
lp = in_process_loop.InProcessRunnerLoop(mock_api, "r-1", shutdown_event)
|
|
|
|
async def failing_agent(**kwargs):
|
|
self._enqueue(lp, "j-log-3", "about to fail\n")
|
|
raise ValueError("boom")
|
|
|
|
registry.register("failing_agent", failing_agent, "proj", [], "")
|
|
|
|
job = LocalRunnerJob(id="j-log-3", agent_name="failing_agent", inputs={})
|
|
self._run_with_streamer(lp, lp._execute_job(job))
|
|
|
|
mock_api.runners.append_job_logs.assert_called_once()
|
|
call_kwargs = mock_api.runners.report_job_result.call_args[1]
|
|
assert call_kwargs["status"] == "failed"
|