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: except GatewayUnavailable as error:
return {"status": "UNAVAILABLE", "attempts": error.attempts} return {"status": "UNAVAILABLE", "attempts": error.attempts}
except LLMError as error: 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): 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): 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, *, http_status=None): def __init__(self, code: str, *, http_status=None, diagnostics=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 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) super().__init__(code)
+32 -4
View File
@@ -2,6 +2,7 @@ import asyncio
import copy import copy
import logging import logging
import time import time
import math
from jsonschema import Draft202012Validator from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError, ValidationError 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 .errors import GatewayUnavailable, LLMError, ProviderUnavailable
from .openai_compatible import OpenAICompatibleProvider from .openai_compatible import OpenAICompatibleProvider
from .provider_base import ProviderResponse
from .diagnostics import sanitize, count
LOGGER = logging.getLogger("viano_bug_analysis.llm") LOGGER = logging.getLogger("viano_bug_analysis.llm")
PRIVACY = {"local_only": {"local"}, "self_hosted": {"local", "self_hosted"}, "cloud_allowed": {"local", "self_hosted", "cloud"}} 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]): if not provider.enabled or provider.privacy not in (PRIVACY[request["privacy"]] & PRIVACY[self.config.privacy_profile]):
continue continue
started = time.monotonic() started = time.monotonic()
response = None
try: 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) 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): 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: if type(http_status) is int and 100 <= http_status <= 599:
attempts[-1]["http_status"] = http_status 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 as invalid:
raise LLMError("LLM_OUTPUT_SCHEMA") from None error = LLMError("LLM_OUTPUT_SCHEMA",diagnostics={'validation_keyword':invalid.validator})
except LLMError: 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 raise
except Exception: 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) 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 .errors import LLMError, ProviderUnavailable
from .provider_base import ProviderResponse from .provider_base import ProviderResponse
from .diagnostics import envelope_details
class _NoRedirect(urllib.request.HTTPRedirectHandler): class _NoRedirect(urllib.request.HTTPRedirectHandler):
@@ -62,24 +63,26 @@ class OpenAICompatibleProvider:
raise LLMError("LLM_HTTP_CONFIGURATION", http_status=error.code) 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
diagnostics = {'response_bytes':len(raw),'max_output_tokens':config.max_output_tokens}
try: try:
envelope = parse_manifest(raw) envelope = parse_manifest(raw)
diagnostics = envelope_details(envelope,config.max_output_tokens,len(raw))
choice = envelope["choices"][0] choice = envelope["choices"][0]
message = choice["message"] message = choice["message"]
if choice.get("finish_reason") != "stop" or message.get("refusal") or message.get("tool_calls"): 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"] content = message["content"]
if not isinstance(content, str): if not isinstance(content, str):
raise LLMError("LLM_RESPONSE") raise LLMError("LLM_RESPONSE",diagnostics=diagnostics)
document = parse_manifest(content.encode("utf-8")) document = parse_manifest(content.encode("utf-8"))
model = envelope.get("model", config.model) 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): 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 {} usage = envelope.get("usage") or {}
input_tokens, output_tokens = usage.get("prompt_tokens"), usage.get("completion_tokens") input_tokens, output_tokens = usage.get("prompt_tokens"), usage.get("completion_tokens")
for count in (input_tokens, output_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): if count is not None and (isinstance(count, bool) or not isinstance(count, int) or count < 0):
raise LLMError("LLM_RESPONSE") raise LLMError("LLM_RESPONSE",diagnostics=diagnostics)
return ProviderResponse(document, model, input_tokens, output_tokens) return ProviderResponse(document, model, input_tokens, output_tokens, diagnostics)
except (ValueError, TypeError, KeyError, IndexError, AttributeError, RecursionError): 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 model: str
input_tokens: int | None = None input_tokens: int | None = None
output_tokens: int | None = None output_tokens: int | None = None
diagnostics: dict | None = None
class LLMProvider(Protocol): class LLMProvider(Protocol):
+4
View File
@@ -152,6 +152,10 @@ 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.metadata is not None:
outcome['metadata'] = error.metadata
if error.diagnostics:
outcome['diagnostics'] = error.diagnostics
if error.http_status is not None: if error.http_status is not None:
outcome["http_status"] = error.http_status outcome["http_status"] = error.http_status
result_ref = base + f"/round-{rounds:04d}.json" result_ref = base + f"/round-{rounds:04d}.json"
@@ -85,18 +85,133 @@
"outcome": { "outcome": {
"type": "object", "type": "object",
"properties": { "properties": {
"http_status": {"type": "integer", "minimum": 100, "maximum": 599}, "http_status": {
"attempts": {"type": "array", "items": {"$ref": "#/$defs/http_attempt"}}, "type": "integer",
"metadata": {"type": "object", "properties": { "minimum": 100,
"attempts": {"type": "array", "items": {"$ref": "#/$defs/http_attempt"}} "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": { "$defs": {
"http_attempt": {"type": "object", "properties": { "http_attempt": {
"http_status": {"type": "integer", "minimum": 100, "maximum": 599} "type": "object",
}} "properties": {
"http_status": {
"type": "integer",
"minimum": 100,
"maximum": 599
}
}
}
}, },
"required": [ "required": [
"operation", "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_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.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"), 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"), "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"), "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,
+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")), "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} "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, result = {"phase": value.get("phase") if value.get("phase") in STATES else None,
"http_status": http_status(value.get("http_status")), "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")), "http_status": http_status(a.get("http_status"))} 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)]}
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"}: if event in {"operation_failed", "phase_diagnostic"}:
chains = [] chains = []
for error in value.get("exceptions", [])[:8]: 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