Preserve HTTP status codes in LLM history and safe telemetry
This commit is contained in:
1 parent
5826d8f730
commit
4d0edc030d
10 files changed
+99
-7
No files matched your search
@@ -51,6 +51,13 @@ other HTTP configuration failures, refusals, truncated model output, invalid JSO
|
|||||||
schema violations are controlled errors; they are not logged as unreachable servers.
|
schema violations are controlled errors; they are not logged as unreachable servers.
|
||||||
Adapters execute one call and never retry independently.
|
Adapters execute one call and never retry independently.
|
||||||
|
|
||||||
|
HTTP failures additionally preserve an optional numeric `http_status` (100–599).
|
||||||
|
Temporary failures record it in `outcome.attempts` or successful failover metadata;
|
||||||
|
nonretryable HTTP errors record it in `outcome.http_status`. Existing v1 records
|
||||||
|
without this field remain valid. No provider response body, headers or credentials
|
||||||
|
are retained. Retry/failover classification is unchanged: 408/429/5xx are temporary;
|
||||||
|
a 429 alone does not distinguish rate limits from exhausted quota.
|
||||||
|
|
||||||
Operational defaults:
|
Operational defaults:
|
||||||
|
|
||||||
| Setting | Default |
|
| Setting | Default |
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Operational telemetry v1
|
# Operational telemetry v1
|
||||||
|
|
||||||
|
LLM round events expose numeric `http_status` for nonretryable HTTP failures and
|
||||||
|
for every entry in `failed_providers`. Use the `events` CLI to inspect temporary
|
||||||
|
provider failures (warning events). For example, 429 indicates a quota/rate-limit
|
||||||
|
response; 503 indicates service unavailability. Missing status on older records,
|
||||||
|
timeouts or connection errors means unknown, not success. Provider bodies and
|
||||||
|
headers are never logged. This field does not change retries or case budgets.
|
||||||
|
|
||||||
Telemetry observes existing library execution, not case truth. It never resumes
|
Telemetry observes existing library execution, not case truth. It never resumes
|
||||||
BLOCKED cases, refills budgets, changes routing, retries a model, edits packages,
|
BLOCKED cases, refills budgets, changes routing, retries a model, edits packages,
|
||||||
executes tools or deletes case artifacts. `docs/architecture.md` is unchanged.
|
executes tools or deletes case artifacts. `docs/architecture.md` is unchanged.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
class LLMError(Exception):
|
class LLMError(Exception):
|
||||||
"""Safe diagnostic code; provider bodies and secrets are never included."""
|
"""Safe diagnostic code; provider bodies and secrets are never included."""
|
||||||
|
|
||||||
def __init__(self, code: str):
|
def __init__(self, code: str, *, http_status=None):
|
||||||
self.code = code
|
self.code = code
|
||||||
|
self.http_status = http_status if type(http_status) is int and 100 <= http_status <= 599 else None
|
||||||
super().__init__(code)
|
super().__init__(code)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ class LLMGateway:
|
|||||||
except (ProviderUnavailable, TimeoutError) as error:
|
except (ProviderUnavailable, TimeoutError) as error:
|
||||||
code = error.code if isinstance(error, ProviderUnavailable) else "LLM_TIMEOUT"
|
code = error.code if isinstance(error, ProviderUnavailable) else "LLM_TIMEOUT"
|
||||||
attempts.append({"provider": provider.id, "code": code})
|
attempts.append({"provider": provider.id, "code": code})
|
||||||
|
http_status = getattr(error, "http_status", None)
|
||||||
|
if type(http_status) is int and 100 <= http_status <= 599:
|
||||||
|
attempts[-1]["http_status"] = http_status
|
||||||
LOGGER.warning("LLM endpoint unavailable provider=%s code=%s", provider.id, code)
|
LOGGER.warning("LLM endpoint unavailable provider=%s code=%s", provider.id, code)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
raise LLMError("LLM_OUTPUT_SCHEMA") from None
|
raise LLMError("LLM_OUTPUT_SCHEMA") from None
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ class OpenAICompatibleProvider:
|
|||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
error.close()
|
error.close()
|
||||||
if error.code in {408, 429} or 500 <= error.code < 600:
|
if error.code in {408, 429} or 500 <= error.code < 600:
|
||||||
raise ProviderUnavailable("LLM_HTTP_TEMPORARY") from None
|
raise ProviderUnavailable("LLM_HTTP_TEMPORARY", http_status=error.code) from None
|
||||||
raise LLMError("LLM_HTTP_CONFIGURATION") from None
|
raise LLMError("LLM_HTTP_CONFIGURATION", http_status=error.code) from None
|
||||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException):
|
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException):
|
||||||
raise ProviderUnavailable("LLM_CONNECTION") from None
|
raise ProviderUnavailable("LLM_CONNECTION") from None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ class CaseLLMRunner:
|
|||||||
except LLMError as error:
|
except LLMError as error:
|
||||||
details.update(status="ERROR", code=error.code)
|
details.update(status="ERROR", code=error.code)
|
||||||
outcome = {"code": error.code}
|
outcome = {"code": error.code}
|
||||||
|
if error.http_status is not None:
|
||||||
|
outcome["http_status"] = error.http_status
|
||||||
result_ref = base + f"/round-{rounds:04d}.json"
|
result_ref = base + f"/round-{rounds:04d}.json"
|
||||||
details["result_ref"] = result_ref
|
details["result_ref"] = result_ref
|
||||||
record = {"format": "viano-llm-call", "format_version": 1,
|
record = {"format": "viano-llm-call", "format_version": 1,
|
||||||
|
|||||||
@@ -83,9 +83,21 @@
|
|||||||
"minimum": 0
|
"minimum": 0
|
||||||
},
|
},
|
||||||
"outcome": {
|
"outcome": {
|
||||||
"type": "object"
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"http_status": {"type": "integer", "minimum": 100, "maximum": 599},
|
||||||
|
"attempts": {"type": "array", "items": {"$ref": "#/$defs/http_attempt"}},
|
||||||
|
"metadata": {"type": "object", "properties": {
|
||||||
|
"attempts": {"type": "array", "items": {"$ref": "#/$defs/http_attempt"}}
|
||||||
|
}}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"$defs": {
|
||||||
|
"http_attempt": {"type": "object", "properties": {
|
||||||
|
"http_status": {"type": "integer", "minimum": 100, "maximum": 599}
|
||||||
|
}}
|
||||||
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"operation",
|
"operation",
|
||||||
"operation_key",
|
"operation_key",
|
||||||
|
|||||||
@@ -367,10 +367,11 @@ class Telemetry:
|
|||||||
self._metric("vba_llm_failed_attempt_usage_unknown_total", len(attempts))
|
self._metric("vba_llm_failed_attempt_usage_unknown_total", len(attempts))
|
||||||
self.emit("llm_round", level="INFO" if status == "SUCCESS" else "WARNING", status=status, code=record.get("code"),
|
self.emit("llm_round", level="INFO" if status == "SUCCESS" else "WARNING", status=status, code=record.get("code"),
|
||||||
extra={"phase": phase, "provider_key": identity(metadata.get("provider")), "model_key": identity(metadata.get("model")),
|
extra={"phase": phase, "provider_key": identity(metadata.get("provider")), "model_key": identity(metadata.get("model")),
|
||||||
|
"http_status": record["outcome"].get("http_status"),
|
||||||
"input_tokens": metadata.get("input_tokens"), "output_tokens": metadata.get("output_tokens"),
|
"input_tokens": metadata.get("input_tokens"), "output_tokens": metadata.get("output_tokens"),
|
||||||
"estimated_cost": cost if currency_valid else None,
|
"estimated_cost": cost if currency_valid else None,
|
||||||
"currency": currency if currency_valid else None,
|
"currency": currency if currency_valid else None,
|
||||||
"failed_providers": [{"provider_key": identity(a.get("provider")), "code": safe_code(a.get("code"))} for a in attempts]})
|
"failed_providers": [{"provider_key": identity(a.get("provider")), "code": safe_code(a.get("code")), "http_status": a.get("http_status")} for a in attempts]})
|
||||||
|
|
||||||
def refresh_inventory(self, *, force=False):
|
def refresh_inventory(self, *, force=False):
|
||||||
if not self.started or not force and self.monotonic() - self._last_scan < self.config.inventory_interval_seconds:
|
if not self.started or not force and self.monotonic() - self._last_scan < self.config.inventory_interval_seconds:
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ def number(value):
|
|||||||
return value if type(value) in {int, float} and math.isfinite(value) and value >= 0 else None
|
return value if type(value) in {int, float} and math.isfinite(value) and value >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def http_status(value):
|
||||||
|
return value if type(value) is int and 100 <= value <= 599 else None
|
||||||
|
|
||||||
|
|
||||||
def detail(event, value):
|
def detail(event, value):
|
||||||
"""Strict field allowlists even for callers emitting their own events."""
|
"""Strict field allowlists even for callers emitting their own events."""
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
@@ -93,11 +97,12 @@ def detail(event, value):
|
|||||||
"reason": safe_code(value.get("reason")) if value.get("reason") else None}
|
"reason": safe_code(value.get("reason")) if value.get("reason") else None}
|
||||||
if event == "llm_round":
|
if event == "llm_round":
|
||||||
return {"phase": value.get("phase") if value.get("phase") in STATES else None,
|
return {"phase": value.get("phase") if value.get("phase") in STATES else None,
|
||||||
|
"http_status": http_status(value.get("http_status")),
|
||||||
"provider_key": hashed(value.get("provider_key")), "model_key": hashed(value.get("model_key")),
|
"provider_key": hashed(value.get("provider_key")), "model_key": hashed(value.get("model_key")),
|
||||||
"input_tokens": number(value.get("input_tokens")), "output_tokens": number(value.get("output_tokens")),
|
"input_tokens": number(value.get("input_tokens")), "output_tokens": number(value.get("output_tokens")),
|
||||||
"estimated_cost": number(value.get("estimated_cost")),
|
"estimated_cost": number(value.get("estimated_cost")),
|
||||||
"currency": value.get("currency") if isinstance(value.get("currency"), str) and re.fullmatch("[A-Z]{3}", value["currency"]) else None,
|
"currency": value.get("currency") if isinstance(value.get("currency"), str) and re.fullmatch("[A-Z]{3}", value["currency"]) else None,
|
||||||
"failed_providers": [{"provider_key": hashed(a.get("provider_key")), "code": safe_code(a.get("code"))} for a in value.get("failed_providers", [])[:64] if isinstance(a, dict)]}
|
"failed_providers": [{"provider_key": hashed(a.get("provider_key")), "code": safe_code(a.get("code")), "http_status": http_status(a.get("http_status"))} for a in value.get("failed_providers", [])[:64] if isinstance(a, dict)]}
|
||||||
if event in {"operation_failed", "phase_diagnostic"}:
|
if event in {"operation_failed", "phase_diagnostic"}:
|
||||||
chains = []
|
chains = []
|
||||||
for error in value.get("exceptions", [])[:8]:
|
for error in value.get("exceptions", [])[:8]:
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ def test_real_http_adapter(http_endpoint, monkeypatch, format):
|
|||||||
else: assert seen[0]["body"]["response_format"]["type"] == format
|
else: assert seen[0]["body"]["response_format"]["type"] == format
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status,temporary", [(401,False),(400,False),(429,True),(503,True),(302,False)])
|
@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):
|
def test_http_errors_and_redirects(http_endpoint,status,temporary):
|
||||||
url, seen, reply = http_endpoint
|
url, seen, reply = http_endpoint
|
||||||
reply.update(status=status,body=b"secret error body",headers={"Location":url+"/redirect"})
|
reply.update(status=status,body=b"secret error body",headers={"Location":url+"/redirect"})
|
||||||
@@ -190,6 +190,60 @@ def test_http_errors_and_redirects(http_endpoint,status,temporary):
|
|||||||
asyncio.run(LLMGateway(cfg).complete_round(request()))
|
asyncio.run(LLMGateway(cfg).complete_round(request()))
|
||||||
assert "secret" not in str(caught.value)
|
assert "secret" not in str(caught.value)
|
||||||
assert len(seen) == 1
|
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":[]}',
|
@pytest.mark.parametrize("body", [b'not JSON',b'{"choices":[],"choices":[]}',
|
||||||
|
|||||||
Reference in new issue
Block a user