Preserve safe LLM failure diagnostics and usage telemetry

This commit is contained in:
Nico committed 2026-10-15 02:15:35 +02:00
1 parent cb57db794d
commit 645cc2e490
14 files changed
+475 -21

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
# LLM failure diagnostics
Failed response processing now retains optional `outcome.metadata` and
`outcome.diagnostics` in the existing v1 `llm/operations/.../round-NNNN.json`
records. Old records without these fields remain valid and replay without API
calls. Previously discarded details cannot be recovered retrospectively.
For incomplete replies, diagnostics preserve:
- The API finish reason, restricted to known values; absent/unrecognized values
are labeled `missing`/`unknown` rather than copying arbitrary provider text.
- Boolean refusal/tool-call indicators, not their text or arguments.
- Input/output tokens and reasoning tokens when supplied by the API.
- Configured output-token limit, received response bytes and output character count.
Metadata records the configured provider/model, prompt version, elapsed seconds,
known usage, previous unavailable-provider attempts, and estimated cost only when
usage and operator-configured prices are available. Missing/invalid token values
remain unknown, not zero. API output token usage may include reasoning, depending
on the provider; do not infer reasoning usage when the API omits that field.
JSON parsing errors retain available envelope details. Schema rejection retains
usage and a safe validation keyword, without storing response contents or the
validation exception message. Diagnostic fields have their own allowlisted JSON
schema (`llm-failure-diagnostics-v1.schema.json`). No headers, API keys, provider
error bodies, refusal text, tool arguments or partial completion text are added.
The same safe details are visible in structured service `llm_round` log events.
Known usage/cost from failed replies now contributes to existing telemetry
counters. The connection-test CLI includes diagnostic/metadata fields on errors.
Existing retry, failover, validation limits, lifecycle and hypothesis budgets are
unchanged. Successful reply metadata and response contracts remain unchanged.
A `finish_reason = length` indicates an output-budget stop reported by the API;
compare the output token count with `max_output_tokens`, and inspect reasoning
usage if available. `content_filter`, refusal/tool-call indicators, or missing
finish reasons describe different failure conditions. An incomplete response
alone does not prove a transport timeout. Transport/availability failures still
use their existing codes and HTTP attempt records; usage may be unavailable.
Tests use local HTTP fixtures and model fixtures. They cover truncation,
refusal/tool calls, malformed usage, unknown finish reasons, JSON/schema failures,
usage persistence, replay without network calls, telemetry counters/logs and
removal of free provider text. No paid model request is needed.
+6 -1
View File
@@ -21,7 +21,12 @@ async def probe(gateway):
except GatewayUnavailable as error:
return {"status": "UNAVAILABLE", "attempts": error.attempts}
except LLMError as error:
return {"status": "ERROR", "code": safe_code(error.code), "http_status": error.http_status}
result = {"status": "ERROR", "code": safe_code(error.code), "http_status": error.http_status}
if error.metadata is not None:
result['metadata'] = error.metadata
if error.diagnostics:
result['diagnostics'] = error.diagnostics
return result
def main(argv=None):
+43
View File
@@ -0,0 +1,43 @@
"""Allowlisted failure details: never response text, headers or provider errors."""
REASONS = {'stop','length','content_filter','tool_calls','function_call','MAX_TOKENS','SAFETY','STOP','OTHER','unknown','missing'}
COUNTS = {'input_tokens','output_tokens','reasoning_tokens','response_bytes','output_characters','max_output_tokens'}
KEYWORDS = {'type','required','additionalProperties','enum','const','pattern','minItems','maxItems','minimum','maximum','anyOf','oneOf'}
def count(value):
return value if type(value) is int and 0 <= value <= 10**12 else None
def sanitize(data):
if not isinstance(data,dict):
return {}
result = {k:count(v) for k,v in data.items() if k in COUNTS}
if 'finish_reason' in data:
value = data['finish_reason']
result['finish_reason'] = value if isinstance(value,str) and value in REASONS else ('missing' if value is None else 'unknown')
for key in ('has_refusal','has_tool_calls'):
if type(data.get(key)) is bool:
result[key] = data[key]
if isinstance(data.get('validation_keyword'),str) and data['validation_keyword'] in KEYWORDS:
result['validation_keyword'] = data['validation_keyword']
return result
def envelope_details(envelope, max_output_tokens, response_bytes):
result = {'max_output_tokens':max_output_tokens,'response_bytes':response_bytes}
if isinstance(envelope,dict):
usage = envelope.get('usage')
if isinstance(usage,dict):
result.update(input_tokens=usage.get('prompt_tokens'),output_tokens=usage.get('completion_tokens'))
details = usage.get('completion_tokens_details')
if isinstance(details,dict):
result['reasoning_tokens'] = details.get('reasoning_tokens')
choices = envelope.get('choices')
if isinstance(choices,list) and choices and isinstance(choices[0],dict):
result['finish_reason'] = choices[0].get('finish_reason')
message = choices[0].get('message')
if isinstance(message,dict):
result.update(has_refusal=bool(message.get('refusal')),has_tool_calls=bool(message.get('tool_calls')))
if isinstance(message.get('content'),str):
result['output_characters'] = len(message['content'])
return sanitize(result)
+4 -1
View File
@@ -1,9 +1,12 @@
class LLMError(Exception):
"""Safe diagnostic code; provider bodies and secrets are never included."""
def __init__(self, code: str, *, http_status=None):
def __init__(self, code: str, *, http_status=None, diagnostics=None):
self.code = code
self.http_status = http_status if type(http_status) is int and 100 <= http_status <= 599 else None
from .diagnostics import sanitize
self.diagnostics = sanitize(diagnostics)
self.metadata = None
super().__init__(code)
+32 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import copy
import logging
import time
import math
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError
@@ -11,6 +12,8 @@ from viano_bug_analysis.package.manifest import FORMAT_CHECKER, parse_manifest
from .errors import GatewayUnavailable, LLMError, ProviderUnavailable
from .openai_compatible import OpenAICompatibleProvider
from .provider_base import ProviderResponse
from .diagnostics import sanitize, count
LOGGER = logging.getLogger("viano_bug_analysis.llm")
PRIVACY = {"local_only": {"local"}, "self_hosted": {"local", "self_hosted"}, "cloud_allowed": {"local", "self_hosted", "cloud"}}
@@ -71,6 +74,7 @@ class LLMGateway:
if not provider.enabled or provider.privacy not in (PRIVACY[request["privacy"]] & PRIVACY[self.config.privacy_profile]):
continue
started = time.monotonic()
response = None
try:
response = await asyncio.wait_for(self.adapters[provider.adapter].generate(provider, copy.deepcopy(request), max_response_bytes=self.config.max_response_bytes), timeout=provider.timeout_seconds)
if not isinstance(response.model, str) or not response.model.strip() or len(response.model) > 256 or any(ord(c) < 32 for c in response.model):
@@ -100,10 +104,34 @@ class LLMGateway:
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)
except ValidationError:
raise LLMError("LLM_OUTPUT_SCHEMA") from None
except LLMError:
except ValidationError as invalid:
error = LLMError("LLM_OUTPUT_SCHEMA",diagnostics={'validation_keyword':invalid.validator})
self._failure_metadata(error,provider,request,started,response,attempts)
raise error from None
except LLMError as error:
self._failure_metadata(error,provider,request,started,response,attempts)
raise
except Exception:
raise LLMError("LLM_PROVIDER_ERROR") from None
error = LLMError("LLM_PROVIDER_ERROR")
self._failure_metadata(error,provider,request,started,response,attempts)
raise error from None
raise GatewayUnavailable(attempts)
@staticmethod
def _failure_metadata(error,provider,request,started,response,attempts):
details = sanitize(error.diagnostics)
if isinstance(response,ProviderResponse):
details = sanitize(response.diagnostics) | details
details.update(input_tokens=count(response.input_tokens),output_tokens=count(response.output_tokens))
details['max_output_tokens'] = count(provider.max_output_tokens)
error.diagnostics = details
input_tokens, output_tokens = details.get('input_tokens'),details.get('output_tokens')
cost = None
if all(v is not None for v in (input_tokens,output_tokens,provider.input_cost_per_million,provider.output_cost_per_million)):
value = (input_tokens*provider.input_cost_per_million+output_tokens*provider.output_cost_per_million)/1_000_000
if math.isfinite(value):
cost = value
error.metadata = {'provider':provider.id,'model':provider.model,'prompt_version':request['prompt_version'],
'input_tokens':input_tokens,'output_tokens':output_tokens,'estimated_cost':cost,
'cost_currency':provider.cost_currency if cost is not None else None,
'elapsed_seconds':round(time.monotonic()-started,6),'attempts':copy.deepcopy(attempts)}
@@ -10,6 +10,7 @@ from viano_bug_analysis.package.manifest import parse_manifest
from .errors import LLMError, ProviderUnavailable
from .provider_base import ProviderResponse
from .diagnostics import envelope_details
class _NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -62,24 +63,26 @@ class OpenAICompatibleProvider:
raise LLMError("LLM_HTTP_CONFIGURATION", http_status=error.code) from None
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException):
raise ProviderUnavailable("LLM_CONNECTION") from None
diagnostics = {'response_bytes':len(raw),'max_output_tokens':config.max_output_tokens}
try:
envelope = parse_manifest(raw)
diagnostics = envelope_details(envelope,config.max_output_tokens,len(raw))
choice = envelope["choices"][0]
message = choice["message"]
if choice.get("finish_reason") != "stop" or message.get("refusal") or message.get("tool_calls"):
raise LLMError("LLM_INCOMPLETE_OUTPUT")
raise LLMError("LLM_INCOMPLETE_OUTPUT",diagnostics=diagnostics)
content = message["content"]
if not isinstance(content, str):
raise LLMError("LLM_RESPONSE")
raise LLMError("LLM_RESPONSE",diagnostics=diagnostics)
document = parse_manifest(content.encode("utf-8"))
model = envelope.get("model", config.model)
if not isinstance(model, str) or not model.strip() or len(model) > 256 or any(ord(c) < 32 for c in model):
raise LLMError("LLM_RESPONSE")
raise LLMError("LLM_RESPONSE",diagnostics=diagnostics)
usage = envelope.get("usage") or {}
input_tokens, output_tokens = usage.get("prompt_tokens"), usage.get("completion_tokens")
for count in (input_tokens, output_tokens):
if count is not None and (isinstance(count, bool) or not isinstance(count, int) or count < 0):
raise LLMError("LLM_RESPONSE")
return ProviderResponse(document, model, input_tokens, output_tokens)
raise LLMError("LLM_RESPONSE",diagnostics=diagnostics)
return ProviderResponse(document, model, input_tokens, output_tokens, diagnostics)
except (ValueError, TypeError, KeyError, IndexError, AttributeError, RecursionError):
raise LLMError("LLM_RESPONSE") from None
raise LLMError("LLM_RESPONSE",diagnostics=diagnostics) from None
@@ -10,6 +10,7 @@ class ProviderResponse:
model: str
input_tokens: int | None = None
output_tokens: int | None = None
diagnostics: dict | None = None
class LLMProvider(Protocol):
+4
View File
@@ -152,6 +152,10 @@ class CaseLLMRunner:
except LLMError as error:
details.update(status="ERROR", code=error.code)
outcome = {"code": error.code}
if error.metadata is not None:
outcome['metadata'] = error.metadata
if error.diagnostics:
outcome['diagnostics'] = error.diagnostics
if error.http_status is not None:
outcome["http_status"] = error.http_status
result_ref = base + f"/round-{rounds:04d}.json"
@@ -85,18 +85,133 @@
"outcome": {
"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"}}
}}
"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"
}
}
}
},
"diagnostics": {
"type": "object",
"properties": {
"input_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"max_output_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"output_characters": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"output_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"reasoning_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"response_bytes": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"finish_reason": {
"enum": [
"MAX_TOKENS",
"OTHER",
"SAFETY",
"STOP",
"content_filter",
"function_call",
"length",
"missing",
"stop",
"tool_calls",
"unknown"
]
},
"has_refusal": {
"type": "boolean"
},
"has_tool_calls": {
"type": "boolean"
},
"validation_keyword": {
"enum": [
"additionalProperties",
"anyOf",
"const",
"enum",
"maxItems",
"maximum",
"minItems",
"minimum",
"oneOf",
"pattern",
"required",
"type"
]
}
},
"additionalProperties": false
}
}
}
},
"$defs": {
"http_attempt": {"type": "object", "properties": {
"http_status": {"type": "integer", "minimum": 100, "maximum": 599}
}}
"http_attempt": {
"type": "object",
"properties": {
"http_status": {
"type": "integer",
"minimum": 100,
"maximum": 599
}
}
}
},
"required": [
"operation",
@@ -0,0 +1,92 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"input_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"max_output_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"output_characters": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"output_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"reasoning_tokens": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"response_bytes": {
"type": [
"integer",
"null"
],
"minimum": 0,
"maximum": 1000000000000
},
"finish_reason": {
"enum": [
"MAX_TOKENS",
"OTHER",
"SAFETY",
"STOP",
"content_filter",
"function_call",
"length",
"missing",
"stop",
"tool_calls",
"unknown"
]
},
"has_refusal": {
"type": "boolean"
},
"has_tool_calls": {
"type": "boolean"
},
"validation_keyword": {
"enum": [
"additionalProperties",
"anyOf",
"const",
"enum",
"maxItems",
"maximum",
"minItems",
"minimum",
"oneOf",
"pattern",
"required",
"type"
]
}
},
"additionalProperties": false
}
@@ -19,3 +19,5 @@ KNOWN_CODES = KNOWN_CODES | frozenset({"INTERPRETATION_ENTITY_PROVENANCE"})
KNOWN_CODES = KNOWN_CODES | frozenset({"INTERPRETATION_DESCRIPTION_SOURCES", "INTERPRETATION_PROVISIONAL_GAP_SOURCES"})
KNOWN_MODULES = KNOWN_MODULES | frozenset({"viano_bug_analysis.analysis.quality"})
KNOWN_MODULES = KNOWN_MODULES | frozenset({"viano_bug_analysis.llm.diagnostics"})
+2
View File
@@ -368,6 +368,8 @@ class Telemetry:
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")),
"http_status": record["outcome"].get("http_status"),
"diagnostics": record["outcome"].get("diagnostics"),
"elapsed_seconds": metadata.get("elapsed_seconds"),
"input_tokens": metadata.get("input_tokens"), "output_tokens": metadata.get("output_tokens"),
"estimated_cost": cost if currency_valid else None,
"currency": currency if currency_valid else None,
+8 -1
View File
@@ -96,13 +96,20 @@ def detail(event, value):
"package_generation": number(value.get("package_generation")), "analysis_revision": number(value.get("analysis_revision")),
"reason": safe_code(value.get("reason")) if value.get("reason") else None}
if event == "llm_round":
return {"phase": value.get("phase") if value.get("phase") in STATES else None,
result = {"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")),
"input_tokens": number(value.get("input_tokens")), "output_tokens": number(value.get("output_tokens")),
"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,
"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)]}
from viano_bug_analysis.llm.diagnostics import sanitize
diagnostics = sanitize(value.get('diagnostics'))
if diagnostics:
result['diagnostics'] = diagnostics
if value.get('elapsed_seconds') is not None:
result['elapsed_seconds'] = number(value['elapsed_seconds'])
return result
if event in {"operation_failed", "phase_diagnostic"}:
chains = []
for error in value.get("exceptions", [])[:8]:
+105
View File
@@ -0,0 +1,105 @@
import asyncio
import json
import pytest
from test_llm_gateway import http_endpoint, active_case, request, provider, Adapter
from viano_bug_analysis.llm import LLMError, LLMGateway, GatewayConfig, ProviderConfig, ProviderResponse, CaseLLMRunner
from viano_bug_analysis.llm.diagnostics import sanitize
from viano_bug_analysis.package import PackageReader, PackageValidator
from viano_bug_analysis.telemetry.safety import detail
@pytest.mark.parametrize('reason,refusal,tools', [('length',None,None),('content_filter','PRIVATE_REFUSAL',None),
('tool_calls',None,[{'PRIVATE_TOOL':'PRIVATE_ARGUMENT'}])])
def test_incomplete_api_responses_keep_usage_and_safe_finish_details(http_endpoint,reason,refusal,tools):
url,seen,reply=http_endpoint
reply['body']={'choices':[{'finish_reason':reason,'message':{'content':'PRIVATE_OUTPUT','refusal':refusal,'tool_calls':tools}}],
'usage':{'prompt_tokens':123,'completion_tokens':8192,'completion_tokens_details':{'reasoning_tokens':8000}}}
config=GatewayConfig((ProviderConfig(id='test',model='configured-model',base_url=url,max_output_tokens=8192),))
with pytest.raises(LLMError,match='LLM_INCOMPLETE_OUTPUT') as caught:
asyncio.run(LLMGateway(config).complete_round(request()))
error=caught.value
assert error.diagnostics['finish_reason']==reason
assert error.diagnostics['has_refusal']==bool(refusal) and error.diagnostics['has_tool_calls']==bool(tools)
assert error.diagnostics['reasoning_tokens']==8000
assert error.metadata['input_tokens']==123 and error.metadata['output_tokens']==8192
assert error.metadata['elapsed_seconds']>=0
assert error.diagnostics['max_output_tokens']==8192
assert 'PRIVATE' not in json.dumps(error.diagnostics) and 'PRIVATE' not in str(error)
assert len(seen)==1
def test_unknown_finish_reasons_and_bad_usage_are_not_copied(http_endpoint):
url,_,reply=http_endpoint
reply['body']={'choices':[{'finish_reason':'PRIVATE_REASON','message':{'content':'PRIVATE_BODY'}}],
'usage':{'prompt_tokens':True,'completion_tokens':'PRIVATE_TOKEN_VALUE'}}
config=GatewayConfig((ProviderConfig(id='test',model='configured',base_url=url),))
with pytest.raises(LLMError) as caught:asyncio.run(LLMGateway(config).complete_round(request()))
error=caught.value
assert error.diagnostics['finish_reason']=='unknown'
assert error.metadata['input_tokens'] is None and error.metadata['output_tokens'] is None
assert 'PRIVATE' not in json.dumps({'metadata':error.metadata,'diagnostics':error.diagnostics})
def test_failed_json_parsing_still_keeps_api_usage(http_endpoint):
url,_,reply=http_endpoint
reply['body']['choices'][0]['message']['content']='PRIVATE_NON_JSON'
config=GatewayConfig((ProviderConfig(id='test',model='configured',base_url=url),))
with pytest.raises(LLMError,match='LLM_RESPONSE') as caught:asyncio.run(LLMGateway(config).complete_round(request()))
assert caught.value.metadata['input_tokens']==3 and caught.value.diagnostics['finish_reason']=='stop'
def test_http_schema_rejection_retains_completion_details(http_endpoint):
url,_,reply=http_endpoint
reply['body']['choices'][0]['message']['content']='{"wrong":1}'
config=GatewayConfig((ProviderConfig(id='test',model='configured',base_url=url),))
with pytest.raises(LLMError,match='LLM_OUTPUT_SCHEMA') as caught:asyncio.run(LLMGateway(config).complete_round(request()))
assert caught.value.diagnostics['finish_reason']=='stop'
assert caught.value.diagnostics['response_bytes']>0
assert caught.value.metadata['output_tokens']==5
def test_schema_rejection_persists_usage_and_replays_without_network(active_case):
store,_,_=active_case
adapter=Adapter({'first':ProviderResponse({'PRIVATE_UNKNOWN_FIELD':'PRIVATE_VALUE'},'returned-model',100,20)})
config=GatewayConfig((provider(input_cost_per_million=2,output_cost_per_million=4),))
runner=CaseLLMRunner(store,LLMGateway(config,{'fixture':adapter}))
result=asyncio.run(runner.run('case-A','stable-package',operation_id='diagnostics',request=request()))
assert result.status=='ERROR' and PackageValidator().validate(result.package).valid
with PackageReader(result.package) as reader:
record=json.loads(reader.read_bytes(result.result_ref))
assert record['outcome']['metadata']['input_tokens']==100
assert record['outcome']['metadata']['estimated_cost']==pytest.approx(0.00028)
assert record['outcome']['diagnostics']['validation_keyword'] in {'required','additionalProperties'}
assert 'PRIVATE' not in json.dumps(record['outcome'])
assert asyncio.run(runner.run('case-A','stable-package',operation_id='diagnostics',request=request()))==result
assert adapter.calls==['first']
def test_safe_diagnostics_survive_log_sanitization():
dirty={'finish_reason':'length','input_tokens':3,'output_tokens':8,'has_refusal':True,
'provider_body':'PRIVATE_BODY','validation_keyword':{},'output_characters':4}
cleaned=sanitize(dirty)
assert 'provider_body' not in cleaned and 'validation_keyword' not in cleaned
event=detail('llm_round',{'diagnostics':dirty,'elapsed_seconds':1.25})
assert event['diagnostics']==cleaned and event['elapsed_seconds']==1.25
assert 'PRIVATE' not in json.dumps(event)
def test_failed_usage_is_counted_and_finish_reason_visible_in_service_log(active_case):
from viano_bug_analysis.telemetry import Telemetry
from viano_bug_analysis.telemetry.__main__ import read_events
store,_,_=active_case
error=LLMError('LLM_INCOMPLETE_OUTPUT',diagnostics={'finish_reason':'length','input_tokens':10,'output_tokens':7})
adapter=Adapter({'first':error})
with Telemetry(store.workspace) as observer:
store.telemetry=observer
runner=CaseLLMRunner(store,LLMGateway(GatewayConfig((provider(),)),{'fixture':adapter}))
result=asyncio.run(runner.run('case-A','stable-package',operation_id='incomplete',request=request()))
assert result.status=='ERROR'
event=next(e for e in read_events(store.workspace.root) if e['event']=='llm_round')
assert event['detail']['diagnostics']['finish_reason']=='length'
metrics=(observer.directory/'metrics.prom').read_text()
assert 'vba_llm_tokens_total{direction="input_tokens"} 10' in metrics
assert 'vba_llm_tokens_total{direction="output_tokens"} 7' in metrics