Files
VianoBugAnalysis/tests/test_llm_gateway.py
T

473 lines
24 KiB
Python

import asyncio
import json
import threading
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from viano_bug_analysis.case import CaseBuilder
from viano_bug_analysis.lifecycle import CheckpointStore, InboxProcessor, State, Workspace
from viano_bug_analysis.lifecycle.errors import LockBusy
from viano_bug_analysis.llm import (CaseLLMRunner, GatewayConfig, GatewayUnavailable, LLMError,
LLMGateway, PromptBuilder, ProviderConfig, ProviderResponse, ProviderUnavailable)
from viano_bug_analysis.package import PackageReader, PackageValidator
SCHEMA = {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"], "additionalProperties": False}
def request(privacy="local_only"):
return PromptBuilder().build(task="Transport test", data={"observation":"Off"}, output_schema=SCHEMA, privacy=privacy)
def provider(name="first", **kw):
return ProviderConfig(id=name, model="fixture-model", base_url="http://127.0.0.1:8000/v1", adapter="fixture", **kw)
class Adapter:
def __init__(self, results):
self.results = results
self.calls = []
async def generate(self, config, request, *, max_response_bytes):
self.calls.append(config.id)
response = self.results[config.id]
if isinstance(response, Exception):
raise response
return response
def success():
return ProviderResponse({"answer":"ok"}, "fixture-model", 10, 20)
def gateway(adapter, **kw):
config = GatewayConfig((provider(), provider("second")), **kw)
return LLMGateway(config, {"fixture":adapter})
def test_ordered_failover_and_usage():
adapter = Adapter({"first":ProviderUnavailable("LLM_CONNECTION"),"second":success()})
config = GatewayConfig((provider(), replace(provider("second"), input_cost_per_million=2, output_cost_per_million=4)))
output = asyncio.run(LLMGateway(config,{"fixture":adapter}).complete_round(request()))
assert adapter.calls == ["first","second"]
assert output["document"] == {"answer":"ok"}
assert output["metadata"]["input_tokens"] == 10
assert output["metadata"]["estimated_cost"] == pytest.approx(0.0001)
assert output["metadata"]["attempts"] == [{"provider":"first","code":"LLM_CONNECTION"}]
def test_first_success_stops():
adapter = Adapter({"first":success()})
asyncio.run(gateway(adapter).complete_round(request()))
assert adapter.calls == ["first"]
def test_all_unavailable_one_round():
adapter = Adapter({"first":ProviderUnavailable("LLM_CONNECTION"),"second":ProviderUnavailable("LLM_TIMEOUT")})
with pytest.raises(GatewayUnavailable) as caught:
asyncio.run(gateway(adapter).complete_round(request()))
assert len(caught.value.attempts) == 2
assert adapter.calls == ["first","second"]
def test_cloud_requires_explicit_privacy():
adapter = Adapter({"cloud":success()})
config = GatewayConfig((provider(), replace(provider("cloud"), privacy="cloud", base_url="https://cloud.example/v1")), privacy_profile="cloud_allowed")
adapter.results["first"] = ProviderUnavailable("LLM_CONNECTION")
with pytest.raises(GatewayUnavailable):
asyncio.run(LLMGateway(config,{"fixture":adapter}).complete_round(request()))
assert adapter.calls == ["first"]
output = asyncio.run(LLMGateway(config,{"fixture":adapter}).complete_round(request("cloud_allowed")))
assert output["metadata"]["provider"] == "cloud"
@pytest.mark.parametrize("response,code", [(ProviderResponse({"wrong":1},"model"),"LLM_OUTPUT_SCHEMA"),
(LLMError("LLM_CREDENTIALS"),"LLM_CREDENTIALS"), (RuntimeError("secret"),"LLM_PROVIDER_ERROR"),
(ProviderResponse({"answer":"ok"},"model",True,1),"LLM_RESPONSE")])
def test_invalid_output_not_treated_as_unavailability(response, code):
adapter = Adapter({"first":response,"second":success()})
with pytest.raises(LLMError) as caught:
asyncio.run(gateway(adapter).complete_round(request()))
assert caught.value.code == code
assert "secret" not in str(caught.value)
assert adapter.calls == ["first"]
def test_async_deadline_moves_to_next_provider():
class Slow(Adapter):
async def generate(self, config, request, *, max_response_bytes):
self.calls.append(config.id)
if config.id == "first": await asyncio.sleep(10)
return success()
adapter = Slow({})
config = GatewayConfig((provider(timeout_seconds=0.01),provider("second")))
output = asyncio.run(LLMGateway(config,{"fixture":adapter}).complete_round(request()))
assert output["metadata"]["attempts"][0]["code"] == "LLM_TIMEOUT"
@pytest.mark.parametrize("field,value", [("timeout_seconds",0),("timeout_seconds",float("nan")),("enabled",1),
("api_key_env","inline secret"),("model","\n"),("base_url","https://user:secret@server.example/v1"),
("base_url","http://remote.example/v1"),("privacy","unknown"),("response_format","unsupported"),
("input_cost_per_million",-1),("max_output_tokens",True)])
def test_invalid_provider_config(field, value):
with pytest.raises(LLMError): replace(provider(), **{field:value})
def test_config_defaults_and_loading(tmp_path):
config = GatewayConfig.load("config/llm.example.toml")
assert config.max_attempts == 10
assert [p.id for p in config.providers] == ["local","own-server","cloud"]
assert config.providers[0].enabled
assert not config.providers[-1].enabled
invalid = tmp_path / "invalid.toml"
invalid.write_text('[llm]\nmax_attempts=true\nproviders=[]')
with pytest.raises(LLMError): GatewayConfig.load(invalid)
with pytest.raises(LLMError): GatewayConfig((provider(),provider()))
@pytest.mark.parametrize("change", ["external_schema","bad_schema","privacy","messages","limit"])
def test_request_validated_before_network(change):
adapter = Adapter({"first":success()})
req = request()
cfg = gateway(adapter)
if change == "external_schema": req["output_schema"] = {"type":"object","$ref":"https://example.org/schema"}
if change == "bad_schema": req["output_schema"] = {"type":"wrong"}
if change == "privacy": req["privacy"] = "unknown"
if change == "messages": req["messages"] = [{"role":"tool","content":"execute"}]
if change == "limit": cfg = gateway(adapter,max_request_bytes=1)
with pytest.raises(LLMError): asyncio.run(cfg.complete_round(req))
assert not adapter.calls
@pytest.fixture
def http_endpoint():
seen = []
reply = {"status":200,"body":{"model":"remote-model","choices":[{"finish_reason":"stop","message":{"content":'{"answer":"ok"}'}}],"usage":{"prompt_tokens":3,"completion_tokens":5}},"headers":{}}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
seen.append({"path":self.path,"body":json.loads(self.rfile.read(int(self.headers["Content-Length"]))),"authorization":self.headers.get("Authorization")})
self.send_response(reply["status"])
for key,value in reply["headers"].items(): self.send_header(key,value)
self.end_headers()
body = reply["body"]
self.wfile.write(body if isinstance(body,bytes) else json.dumps(body).encode())
def log_message(self, *args): pass
server = ThreadingHTTPServer(("127.0.0.1",0),Handler)
thread = threading.Thread(target=server.serve_forever,daemon=True)
thread.start()
yield f"http://127.0.0.1:{server.server_port}/v1",seen,reply
server.shutdown()
server.server_close()
thread.join()
@pytest.mark.parametrize("format", ["json_schema","json_object","none"])
def test_real_http_adapter(http_endpoint, monkeypatch, format):
url, seen, _ = http_endpoint
monkeypatch.setenv("TEST_API_KEY","test-secret")
config = GatewayConfig((ProviderConfig(id="test",model="chosen-model",base_url=url,api_key_env="TEST_API_KEY",response_format=format),))
output = asyncio.run(LLMGateway(config).complete_round(request()))
assert output["document"] == {"answer":"ok"}
assert output["metadata"]["model"] == "remote-model"
assert output["metadata"]["input_tokens"] == 3
assert seen[0]["path"] == "/v1/chat/completions"
assert seen[0]["authorization"] == "Bearer test-secret"
assert seen[0]["body"]["stream"] is False
assert seen[0]["body"]["model"] == "chosen-model"
assert "test-secret" not in json.dumps(output)
if format == "none": assert "response_format" not in seen[0]["body"]
else: assert seen[0]["body"]["response_format"]["type"] == format
@pytest.mark.parametrize("status,temporary", [(401,False),(400,False),(408,True),(429,True),(500,True),(503,True),(302,False)])
def test_http_errors_and_redirects(http_endpoint,status,temporary):
url, seen, reply = http_endpoint
reply.update(status=status,body=b"secret error body",headers={"Location":url+"/redirect"})
cfg = GatewayConfig((ProviderConfig(id="test",model="m",base_url=url),))
with pytest.raises(GatewayUnavailable if temporary else LLMError) as caught:
asyncio.run(LLMGateway(cfg).complete_round(request()))
assert "secret" not in str(caught.value)
assert len(seen) == 1
if temporary:
assert caught.value.attempts[0]["http_status"] == status
else:
assert caught.value.http_status == status
@pytest.mark.parametrize("status", [401, 429, 503])
def test_http_status_persisted_and_visible_in_safe_telemetry(make_package, tmp_path, status):
from viano_bug_analysis.telemetry import Telemetry
from viano_bug_analysis.telemetry.__main__ import read_events
from viano_bug_analysis.case.contracts import validate
workspace = Workspace(tmp_path / "work")
error = ProviderUnavailable("LLM_HTTP_TEMPORARY", http_status=status) if status != 401 else LLMError("LLM_HTTP_CONFIGURATION", http_status=status)
with Telemetry(workspace) as telemetry:
store = CheckpointStore(workspace, telemetry=telemetry)
store.intake(make_package())
config = GatewayConfig((provider(),))
gateway = LLMGateway(config, {"fixture": Adapter({"first": error})})
result = asyncio.run(CaseLLMRunner(store, gateway).run("case-A", "stable-package", operation_id="http-status", request=request()))
with PackageReader(result.package) as reader:
record = json.loads(reader.read_bytes(result.result_ref))
if status == 401:
assert record["outcome"]["http_status"] == status
del record["outcome"]["http_status"]
else:
assert record["outcome"]["attempts"][0]["http_status"] == status
del record["outcome"]["attempts"][0]["http_status"]
# Optional field: previously saved v1 records remain valid.
validate(record, "llm-call")
events = read_events(workspace.root)
event = next(e for e in events if e["event"] == "llm_round")
if status == 401:
assert event["detail"]["http_status"] == status
else:
assert event["detail"]["failed_providers"][0]["http_status"] == status
def test_successful_failover_keeps_first_http_status():
config = GatewayConfig((provider("first"), provider("second")))
adapter = Adapter({"first": ProviderUnavailable("LLM_HTTP_TEMPORARY", http_status=429),
"second": ProviderResponse({"answer": "ok"}, "fixture-model", 1, 1)})
outcome = asyncio.run(LLMGateway(config, {"fixture": adapter}).complete_round(request()))
assert outcome["metadata"]["attempts"] == [
{"provider": "first", "code": "LLM_HTTP_TEMPORARY", "http_status": 429}]
@pytest.mark.parametrize("value", [True, "secret-value", 99, 600, None])
def test_http_status_only_accepts_bounded_integers(value):
from viano_bug_analysis.telemetry.safety import detail
assert LLMError("LLM_HTTP_CONFIGURATION", http_status=value).http_status is None
data = detail("llm_round", {"http_status": value, "failed_providers": [{"http_status": value}]})
assert data["http_status"] is None
assert data["failed_providers"][0]["http_status"] is None
assert "secret-value" not in json.dumps(data)
@pytest.mark.parametrize("body", [b'not JSON',b'{"choices":[],"choices":[]}',
{"choices":[{"finish_reason":"length","message":{"content":"{}"}}]},
{"choices":[{"finish_reason":"stop","message":{"refusal":"no","content":"{}"}}]},
{"choices":[{"finish_reason":"stop","message":{"content":'{"answer":NaN}'}}]}])
def test_bad_http_responses(http_endpoint,body):
url, _, reply = http_endpoint
reply["body"] = body
cfg = GatewayConfig((ProviderConfig(id="test",model="m",base_url=url),))
with pytest.raises(LLMError): asyncio.run(LLMGateway(cfg).complete_round(request()))
def test_response_size_limit(http_endpoint):
url, _, reply = http_endpoint
reply["body"] = b"x" * 1000
cfg = GatewayConfig((ProviderConfig(id="test",model="m",base_url=url),), max_response_bytes=100)
with pytest.raises(LLMError,match="LLM_RESPONSE_LIMIT"): asyncio.run(LLMGateway(cfg).complete_round(request()))
@pytest.fixture
def active_case(make_package,tmp_path):
store = CheckpointStore(Workspace(tmp_path / "service"))
original = make_package(entries={"original/data":b"keep","unknown/vendor":b"opaque"})
store.intake(original)
active = store.advance("case-A","stable-package",State.BUILDING_CASE,step_id="start")
return store,active,original
def test_ten_rounds_persist_and_block_without_sleep(active_case,caplog):
store, active, original = active_case
initial = original.read_bytes()
adapter = Adapter({"first":ProviderUnavailable("LLM_CONNECTION"),"second":ProviderUnavailable("LLM_CONNECTION")})
cfg = gateway(adapter,retry_delay_seconds=1)
now = [datetime(2026,1,1,tzinfo=timezone.utc)]
for index in range(10):
runner = CaseLLMRunner(CheckpointStore(store.workspace),cfg,clock=lambda:now[0])
outcome = asyncio.run(runner.run("case-A","stable-package",operation_id="operation",request=request()))
assert outcome.rounds == index+1
assert outcome.status == ("BLOCKED" if index == 9 else "RETRY_PENDING")
deferred = asyncio.run(runner.run("case-A","stable-package",operation_id="operation",request=request()))
assert deferred.status in {"DEFERRED","BLOCKED"}
now[0] += timedelta(seconds=1)
assert adapter.calls == ["first","second"] * 10
with PackageReader(outcome.package) as reader:
assert reader.manifest["state"] == State.BLOCKED
assert reader.manifest["routing"]["reason"] == "LLM_UNAVAILABLE"
assert reader.manifest["routing"]["destination"] == "inbox"
assert reader.manifest["analysis_revision"] == 1
assert reader.read_bytes("original/data") == b"keep"
assert reader.read_bytes("unknown/vendor") == b"opaque"
assert len([p for p in reader.files if "/round-" in p]) == 10
assert original.read_bytes() == initial
assert PackageValidator().validate(outcome.package).valid
assert "Casebuilding failed: no reachable LLM server" in caplog.text
lines = (store.workspace.runtime / "logs/llm.jsonl").read_text().splitlines()
assert any("status=BLOCKED" in json.loads(line)["message"] for line in lines)
# Exhaustion stays blocked even after the cooldown; inbox cannot start an endless campaign.
assert not any(r.status == "RESUMED" for r in InboxProcessor(store.workspace).poll())
def test_success_replay_no_network_and_config_conflict(active_case):
store, _, _ = active_case
adapter = Adapter({"first":success()})
cfg = gateway(adapter)
runner = CaseLLMRunner(store,cfg)
first = asyncio.run(runner.run("case-A","stable-package",operation_id="op",request=request()))
second = asyncio.run(CaseLLMRunner(CheckpointStore(store.workspace),cfg).run("case-A","stable-package",operation_id="op",request=request()))
assert second == first
assert adapter.calls == ["first"]
changed = request()
changed["messages"][0]["content"] += "changed"
with pytest.raises(LLMError,match="LLM_OPERATION_CONFLICT"):
asyncio.run(runner.run("case-A","stable-package",operation_id="op",request=changed))
def test_schema_error_is_persisted_not_blocked(active_case):
store, _, _ = active_case
adapter = Adapter({"first":ProviderResponse({"wrong":1},"m")})
runner = CaseLLMRunner(store,gateway(adapter))
failed = asyncio.run(runner.run("case-A","stable-package",operation_id="op",request=request()))
assert failed.status == "ERROR" and failed.code == "LLM_OUTPUT_SCHEMA"
with PackageReader(failed.package) as reader: assert reader.manifest["state"] == State.BUILDING_CASE
assert asyncio.run(runner.run("case-A","stable-package",operation_id="op",request=request())) == failed
assert adapter.calls == ["first"]
def test_duplicate_dispatch_lock_does_not_wait(active_case):
store, _, _ = active_case
entered = asyncio.Event()
release = asyncio.Event()
class Waiting:
async def generate(self,*args,**kwargs):
entered.set()
await release.wait()
return success()
runner = CaseLLMRunner(store,LLMGateway(GatewayConfig((provider(),)),{"fixture":Waiting()}))
async def dispatch():
task = asyncio.create_task(runner.run("case-A","stable-package",operation_id="op",request=request()))
await entered.wait()
with pytest.raises(LockBusy): await runner.run("case-A","stable-package",operation_id="op",request=request())
release.set()
return await task
assert asyncio.run(dispatch()).status == "SUCCESS"
def test_completed_casebuilder_ignores_gateway_artifacts(make_package,tmp_path):
store = CheckpointStore(Workspace(tmp_path / "store"))
store.intake(make_package(entries={"unknown":b"data"}))
builder = CaseBuilder(store)
built = builder.build("case-A","stable-package")
runner = CaseLLMRunner(store,gateway(Adapter({"first":success()})))
outcome = asyncio.run(runner.run("case-A","stable-package",operation_id="transport-test",request=request()))
assert outcome.status == "SUCCESS"
assert builder.build("case-A","stable-package").reused
def test_configured_cloud_and_narrower_request_policy():
adapter = Adapter({"cloud":success()})
config = GatewayConfig((replace(provider("cloud"), privacy="cloud", base_url="https://cloud.example/v1"),), privacy_profile="cloud_allowed")
req = PromptBuilder().build(task="test",data={},output_schema=SCHEMA)
assert asyncio.run(LLMGateway(config,{"fixture":adapter}).complete_round(req))["metadata"]["provider"] == "cloud"
with pytest.raises(LLMError,match="LLM_PRIVACY_NO_PROVIDER"):
asyncio.run(LLMGateway(config,{"fixture":adapter}).complete_round(request()))
def test_disabled_provider_is_never_called():
adapter = Adapter({"second":success()})
config = GatewayConfig((replace(provider(),enabled=False),provider("second")))
asyncio.run(LLMGateway(config,{"fixture":adapter}).complete_round(request()))
assert adapter.calls == ["second"]
def test_other_case_progresses_while_endpoint_waits(make_package,manifest,tmp_path):
store = CheckpointStore(Workspace(tmp_path / "service"))
for identity in ("case-A","case-B"):
doc = dict(manifest,case_id=identity)
store.intake(make_package(document=doc,name=identity))
store.advance(identity,"stable-package",State.BUILDING_CASE,step_id="start")
async def concurrent():
entered, release = asyncio.Event(), asyncio.Event()
class Slow:
async def generate(self,*args,**kwargs):
entered.set()
await release.wait()
return success()
slow = CaseLLMRunner(store,LLMGateway(GatewayConfig((provider(),)),{"fixture":Slow()}))
fast = CaseLLMRunner(store,gateway(Adapter({"first":success()})))
pending = asyncio.create_task(slow.run("case-A","stable-package",operation_id="op",request=request()))
await entered.wait()
finished = await fast.run("case-B","stable-package",operation_id="op",request=request())
assert finished.status == "SUCCESS"
assert not pending.done()
release.set()
assert (await pending).status == "SUCCESS"
asyncio.run(concurrent())
def test_pending_retry_recovers_then_succeeds(active_case):
store, _, _ = active_case
adapter = Adapter({"first":ProviderUnavailable("LLM_CONNECTION"),"second":ProviderUnavailable("LLM_CONNECTION")})
cfg = gateway(adapter,retry_delay_seconds=2)
now = [datetime(2026,1,1,tzinfo=timezone.utc)]
runner = CaseLLMRunner(store,cfg,clock=lambda:now[0])
first = asyncio.run(runner.run("case-A","stable-package",operation_id="op",request=request()))
assert first.status == "RETRY_PENDING"
now[0] += timedelta(seconds=2)
adapter.results["first"] = success()
resumed = CaseLLMRunner(CheckpointStore(store.workspace),cfg,clock=lambda:now[0])
outcome = asyncio.run(resumed.run("case-A","stable-package",operation_id="op",request=request()))
assert outcome.status == "SUCCESS" and outcome.rounds == 2
assert adapter.calls == ["first","second","first"]
def test_exhaustion_flag_survives_returning_package_intake(active_case):
store, _, _ = active_case
adapter = Adapter({"first":ProviderUnavailable("LLM_CONNECTION"),"second":ProviderUnavailable("LLM_CONNECTION")})
runner = CaseLLMRunner(store,gateway(adapter,max_attempts=1),clock=lambda:datetime(2026,1,1,tzinfo=timezone.utc))
blocked = asyncio.run(runner.run("case-A","stable-package",operation_id="op",request=request()))
assert blocked.status == "BLOCKED"
# A new service receives the self-contained blocked package after its due date.
receiving = CheckpointStore(Workspace(store.workspace.root / "new-service"))
receiving.intake(blocked.package)
assert not any(r.status == "RESUMED" for r in InboxProcessor(receiving.workspace).poll())
def test_missing_credentials_never_sent(http_endpoint,monkeypatch):
url, seen, _ = http_endpoint
monkeypatch.delenv("NONEXISTENT_LLM_KEY",raising=False)
cfg = GatewayConfig((ProviderConfig(id="test",model="m",base_url=url,api_key_env="NONEXISTENT_LLM_KEY"),))
with pytest.raises(LLMError,match="LLM_CREDENTIALS"):
asyncio.run(LLMGateway(cfg).complete_round(request()))
assert not seen
def test_crash_after_publication_reuses_and_repairs_metadata(active_case,monkeypatch):
store, _, _ = active_case
adapter = Adapter({"first":success()})
runner = CaseLLMRunner(store,gateway(adapter))
def crash(*args): raise RuntimeError("simulated publication/index crash")
monkeypatch.setattr(store,"_index",crash)
with pytest.raises(RuntimeError,match="simulated"):
asyncio.run(runner.run("case-A","stable-package",operation_id="op",request=request()))
recovered_store = CheckpointStore(store.workspace)
recovered = asyncio.run(CaseLLMRunner(recovered_store,gateway(adapter)).run("case-A","stable-package",operation_id="op",request=request()))
assert recovered.status == "SUCCESS"
assert adapter.calls == ["first"]
assert json.loads((store.workspace.runtime / "current_job.json").read_bytes()) is None
assert json.loads((recovered.package.parent / "current.json").read_bytes())["package"] == recovered.package.name
@pytest.mark.parametrize("contract", ["llm-step", "llm-call"])
def test_llm_schemas_valid(contract):
from importlib.resources import files
from jsonschema import Draft202012Validator
Draft202012Validator.check_schema(json.loads(files("viano_bug_analysis.schemas").joinpath(contract + "-v1.schema.json").read_text()))
def test_unrelated_blocked_case_never_calls_llm(active_case):
store, _, _ = active_case
store.advance("case-A","stable-package",State.BLOCKED,step_id="research-offline",
reason="RESEARCH_UNAVAILABLE",retry_not_before="2099-01-01T00:00:00Z")
adapter = Adapter({"first":success()})
outcome = asyncio.run(CaseLLMRunner(store,gateway(adapter)).run("case-A","stable-package",operation_id="op",request=request()))
assert outcome.status == "BLOCKED" and outcome.code == "CASE_BLOCKED"
assert not adapter.calls