Files

398 lines
21 KiB
Python

"""Offline cache protocol, accounting, privacy and portable-request regressions."""
import asyncio
import copy
import json
from dataclasses import replace
from importlib.resources import files
import pytest
from jsonschema import Draft202012Validator
from viano_bug_analysis.llm import GatewayConfig, LLMError, LLMGateway, ProviderConfig, ProviderResponse, ProviderUnavailable
from viano_bug_analysis.llm.gemini import GeminiProvider
from viano_bug_analysis.llm.usage import usage_metadata
from viano_bug_analysis.llm.diagnostics import sanitize
from test_llm_gateway import Adapter, request, http_endpoint
def config(**kw):
return ProviderConfig(id='gemini-test', model='gemini-test', base_url='http://localhost/v1beta',
adapter='gemini', context_cache=True, cache_min_prefix_chars=1, **kw)
def native(cached=80, finish='STOP'):
return {'modelVersion': 'gemini-test', 'candidates': [{'finishReason': finish,
'content': {'parts': [{'text': '{"answer":"ok"}'}]}}],
'usageMetadata': {'promptTokenCount': 100, 'cachedContentTokenCount': cached,
'candidatesTokenCount': 10, 'thoughtsTokenCount': 5}}
def scripted(adapter, monkeypatch, actions):
seen = []
def post(url, payload, headers, limit, deadline):
seen.append((url, copy.deepcopy(payload), headers.copy()))
result = actions.pop(0)
if isinstance(result, Exception):
raise result
return result
monkeypatch.setattr(adapter, '_post', post)
return seen
CACHE = {'name': 'cachedContents/test-cache', 'usageMetadata': {'totalTokenCount': 80}}
def test_cache_reuses_only_instructions_schema_keeps_full_live_research(monkeypatch):
adapter, cfg = GeminiProvider(), config(api_key_env='CACHE_TEST_KEY')
monkeypatch.setenv('CACHE_TEST_KEY', 'never-persist-this')
seen = scripted(adapter, monkeypatch, [CACHE, native(), native()])
req = request()
value = json.loads(req['messages'][1]['content'])
value['input'] = {'evidence': {'E000001': {'text': 'complete first finding'}}}
req['messages'][1]['content'] = json.dumps(value)
original = copy.deepcopy(req)
first = asyncio.run(adapter.generate(cfg, req, max_response_bytes=10000))
value['input']['evidence']['E000002'] = {'text': 'complete second finding'}
second_req = copy.deepcopy(req)
second_req['messages'][1]['content'] = json.dumps(value)
second = asyncio.run(adapter.generate(cfg, second_req, max_response_bytes=10000))
assert req == original
assert len(seen) == 3 and seen[0][0].endswith('/cachedContents')
assert 'complete first finding' not in json.dumps(seen[0][1])
assert 'complete first finding' in json.dumps(seen[2][1])
assert 'complete second finding' in json.dumps(seen[2][1])
assert seen[2][1]['cachedContent'] == CACHE['name']
assert 'systemInstruction' not in seen[2][1]
assert first.diagnostics['cache_status'] == 'created'
assert second.diagnostics['cache_status'] == 'reused'
assert first.output_tokens == 15 and first.reasoning_tokens == 5
assert second.diagnostics['cache_created_tokens'] == 0
assert 'never-persist-this' not in repr(first) + repr(adapter._cache)
@pytest.mark.parametrize('change', ['model','system','schema','credential','url'])
def test_changed_prefix_or_provider_never_reuses_cache(monkeypatch, change):
adapter, cfg, req = GeminiProvider(), config(api_key_env='CACHE_TEST_KEY'), request()
monkeypatch.setenv('CACHE_TEST_KEY', 'first-key')
seen = scripted(adapter, monkeypatch, [CACHE, native(), CACHE, native()])
asyncio.run(adapter.generate(cfg, req, max_response_bytes=10000))
if change == 'model': cfg = replace(cfg, model='other')
elif change == 'system': req['messages'][0]['content'] += ' changed'
elif change == 'schema': req['output_schema']['description'] = 'changed'
elif change == 'credential': monkeypatch.setenv('CACHE_TEST_KEY', 'second-key')
else: cfg = replace(cfg, base_url='http://localhost/other')
asyncio.run(adapter.generate(cfg, req, max_response_bytes=10000))
assert sum(url.endswith('/cachedContents') for url, _, _ in seen) == 2
def test_expired_cache_recreated(monkeypatch):
adapter, cfg = GeminiProvider(), config()
seen = scripted(adapter, monkeypatch, [CACHE, native(), CACHE, native()])
asyncio.run(adapter.generate(cfg, request(), max_response_bytes=10000))
adapter._cache = {key: (name, 0) for key, (name, _) in adapter._cache.items()}
asyncio.run(adapter.generate(cfg, request(), max_response_bytes=10000))
assert len(seen) == 4
@pytest.mark.parametrize('code', [400,403,404,429,503])
def test_cache_creation_rejected_falls_back_and_is_not_retried_every_round(monkeypatch, code):
adapter, cfg = GeminiProvider(), config()
seen = scripted(adapter, monkeypatch, [LLMError('LLM_HTTP_CONFIGURATION', http_status=code), native(0), native(0)])
for _ in range(2):
result = asyncio.run(adapter.generate(cfg, request(), max_response_bytes=10000))
assert result.diagnostics['cache_status'] == 'fallback'
assert len(seen) == 3
assert all('cachedContent' not in payload and 'systemInstruction' in payload for _, payload, _ in seen[1:])
def test_rejected_cached_generation_retries_full_input_once(monkeypatch):
adapter, cfg = GeminiProvider(), config()
seen = scripted(adapter, monkeypatch, [CACHE, LLMError('LLM_HTTP_CONFIGURATION', http_status=404), native(0)])
result = asyncio.run(adapter.generate(cfg, request(), max_response_bytes=10000))
assert result.diagnostics['cache_status'] == 'fallback'
assert 'cachedContent' in seen[1][1] and 'cachedContent' not in seen[2][1]
assert 'systemInstruction' in seen[2][1]
def test_cache_loss_and_new_adapter_need_no_provider_state(monkeypatch):
cfg, req = config(), request()
for adapter in (GeminiProvider(), GeminiProvider()):
seen = scripted(adapter, monkeypatch, [CACHE, native()])
result = asyncio.run(adapter.generate(cfg, req, max_response_bytes=10000))
assert result.document == {'answer':'ok'} and len(seen) == 2
@pytest.mark.parametrize('cfg', [replace(config(), context_cache=False), replace(config(), cache_min_prefix_chars=1000000)])
def test_cache_optional_no_create_when_disabled_or_small(monkeypatch, cfg):
adapter = GeminiProvider()
seen = scripted(adapter, monkeypatch, [native(0)])
asyncio.run(adapter.generate(cfg, request(), max_response_bytes=10000))
assert len(seen) == 1 and 'cachedContent' not in seen[0][1]
def test_usage_counts_and_cost_do_not_double_charge_thinking_or_cached_input():
cfg = config(input_cost_per_million=2, cached_input_cost_per_million=.2,
output_cost_per_million=4, cache_storage_cost_per_million_hour=.5)
response = ProviderResponse({}, 'm', 100, 15, {'cache_created_tokens':80,'cache_ttl_seconds':300}, 80, 5)
usage = usage_metadata(cfg, response)
assert usage['uncached_input_tokens'] == 20
assert usage['estimated_cost'] == pytest.approx((20*2 + 80*.2 + 15*4)/1e6)
assert usage['estimated_cache_storage_cost'] == pytest.approx(80*.5/1e6/12)
assert usage['reasoning_tokens'] == 5
def test_unknown_cache_usage_remains_unknown():
usage = usage_metadata(config(cached_input_cost_per_million=.1), ProviderResponse({}, 'm', 100, 10))
assert usage['cached_input_tokens'] is None and usage['uncached_input_tokens'] is None
assert usage['estimated_cost'] is None
assert usage_metadata(config(), ProviderResponse({}, 'm', 10, 1, cached_input_tokens=11))['cached_input_tokens'] is None
def test_config_defaults_keep_frozen_snapshot_compatible():
snapshot = GatewayConfig((ProviderConfig('x','m','http://localhost'),)).snapshot()['providers'][0]
for key in ('context_cache','cache_ttl_seconds','cache_min_prefix_chars','cached_input_cost_per_million',
'cache_storage_cost_per_million_hour'):
assert key not in snapshot
assert GatewayConfig((config(),)).snapshot()['providers'][0]['context_cache'] is True
def test_capable_adapter_defaults_to_explicit_caching_and_pins_choice():
cfg=ProviderConfig('native','gemini-test','http://localhost/v1beta',adapter='gemini')
assert cfg.use_context_cache
assert GatewayConfig((cfg,)).snapshot()['providers'][0]['context_cache'] is True
assert not replace(cfg,context_cache=False).use_context_cache
assert not ProviderConfig('other','m','http://localhost').use_context_cache
assert replace(cfg,cache_required=True).use_context_cache
def test_default_native_configuration_creates_cache(monkeypatch):
adapter=GeminiProvider()
cfg=replace(config(),context_cache=None)
seen=scripted(adapter,monkeypatch,[CACHE,native()])
result=asyncio.run(adapter.generate(cfg,request(),max_response_bytes=10000))
assert result.diagnostics['cache_status']=='created' and len(seen)==2
@pytest.mark.parametrize('changes', [{'adapter':'openai_compatible'}, {'cache_ttl_seconds':True},
{'cache_min_prefix_chars':0}, {'cached_input_cost_per_million':-1}, {'model':'../other'}, {'reasoning_effort':'none'}])
def test_bad_cache_configuration_rejected(changes):
with pytest.raises(LLMError): replace(config(), **changes)
def test_openai_compatible_reports_implicit_cache_hits(http_endpoint):
url, _, reply = http_endpoint
reply['body']['usage'].update(prompt_tokens=100, prompt_tokens_details={'cached_tokens':80},
completion_tokens_details={'reasoning_tokens':2})
cfg = ProviderConfig('test','m',url, input_cost_per_million=2,
cached_input_cost_per_million=.2, output_cost_per_million=4)
metadata = asyncio.run(LLMGateway(GatewayConfig((cfg,))).complete_round(request()))['metadata']
assert metadata['cached_input_tokens'] == 80 and metadata['uncached_input_tokens'] == 20
assert metadata['reasoning_tokens'] == 2
assert metadata['estimated_cost'] == pytest.approx((20*2+80*.2+5*4)/1e6)
def test_native_real_http_transport(http_endpoint):
url, seen, reply = http_endpoint
reply['body'] = native()
cfg = replace(config(), base_url=url, context_cache=False, response_format='json_schema', reasoning_effort='low')
result = asyncio.run(LLMGateway(GatewayConfig((cfg,))).complete_round(request()))
assert result['document'] == {'answer':'ok'}
assert seen[0]['path'].endswith('/models/gemini-test:generateContent')
generation = seen[0]['body']['generationConfig']
assert generation['thinkingConfig'] == {'thinkingLevel':'low'}
assert generation['responseJsonSchema'] == request()['output_schema']
def test_invalid_model_output_keeps_usage_and_storage_diagnostics(monkeypatch):
adapter, cfg = GeminiProvider(), config()
scripted(adapter, monkeypatch, [CACHE, native(finish='MAX_TOKENS')])
gw = LLMGateway(GatewayConfig((cfg,)), {'gemini':adapter})
with pytest.raises(LLMError) as caught:
asyncio.run(gw.complete_round(request()))
assert caught.value.code == 'LLM_INCOMPLETE_OUTPUT'
assert caught.value.metadata['cached_input_tokens'] == 80
assert caught.value.metadata['cache_created_tokens'] == 80
schema = json.loads(files('viano_bug_analysis.schemas').joinpath('llm-call-v1.schema.json').read_text())
Draft202012Validator(schema['properties']['outcome']['properties']['diagnostics']).validate(caught.value.diagnostics)
def test_strict_phase_metadata_schemas_accept_optional_usage_fields():
cfg = replace(config(), adapter='fixture', context_cache=False)
response = ProviderResponse({'answer':'ok'}, 'm', 100, 15, cached_input_tokens=80, reasoning_tokens=5)
adapter = Adapter({cfg.id:response})
metadata = asyncio.run(LLMGateway(GatewayConfig((cfg,)), {'fixture':adapter}).complete_round(request()))['metadata']
for name, version in [('case-interpretation-v1.schema.json','case_interpretation_resolved_v8'),
('hypotheses-v1.schema.json','hypotheses_project_v4')]:
schema = json.loads(files('viano_bug_analysis.schemas').joinpath(name).read_text())
# Use an allowed phase-specific prompt; usage contract stays identical.
target = schema['properties']['llm_metadata']
phase_metadata = metadata | {'prompt_version':target['properties']['prompt_version']['enum'][-1]}
Draft202012Validator(target).validate(phase_metadata)
legacy = {k:v for k,v in phase_metadata.items() if k in target['required']}
Draft202012Validator(target).validate(legacy)
def test_diagnostics_never_include_provider_cache_handles():
assert sanitize({'cache_status':{}}) == {}
assert sanitize({'cache_status':'cachedContents/secret', 'cache_name':'secret',
'cached_input_tokens':80}) == {'cached_input_tokens':80}
def test_allocation_is_preserved_through_provider_failover(monkeypatch):
adapter, cfg = GeminiProvider(), config(cache_storage_cost_per_million_hour=.5)
scripted(adapter, monkeypatch, [CACHE, ProviderUnavailable('LLM_CONNECTION')])
fallback = ProviderConfig('fallback','m','http://localhost',adapter='fixture')
gw = LLMGateway(GatewayConfig((cfg, fallback)), {'gemini':adapter,
'fixture':Adapter({'fallback':ProviderResponse({'answer':'ok'},'m')})})
result = asyncio.run(gw.complete_round(request()))
usage = result['metadata']['attempts'][0]['cache_usage']
assert usage['cache_created_tokens'] == 80
assert usage['estimated_cache_storage_cost'] == pytest.approx(80*.5/1e6/12)
assert 'cachedContents/' not in json.dumps(result)
@pytest.mark.parametrize('status', [401,302,503])
def test_native_http_failures_are_controlled(http_endpoint, status):
url, seen, reply = http_endpoint
reply.update(status=status, body=b'private provider body', headers={'Location':url+'/secret'})
cfg = replace(config(), base_url=url, context_cache=False)
with pytest.raises(LLMError) as caught:
asyncio.run(LLMGateway(GatewayConfig((cfg,))).complete_round(request()))
assert len(seen) == 1
assert 'private' not in str(caught.value)
def test_native_response_limit(http_endpoint):
url, _, reply = http_endpoint
reply['body'] = b'x'*1000
cfg = replace(config(), base_url=url, context_cache=False)
with pytest.raises(LLMError) as caught:
asyncio.run(LLMGateway(GatewayConfig((cfg,),max_response_bytes=100)).complete_round(request()))
assert caught.value.code == 'LLM_RESPONSE_LIMIT'
def test_native_credentials_checked_before_any_request(monkeypatch):
adapter = GeminiProvider()
monkeypatch.delenv('CACHE_TEST_MISSING', raising=False)
seen = scripted(adapter, monkeypatch, [])
with pytest.raises(LLMError) as caught:
asyncio.run(adapter.generate(config(api_key_env='CACHE_TEST_MISSING'),request(),max_response_bytes=1000))
assert caught.value.code == 'LLM_CREDENTIALS' and not seen
def test_cache_usage_visible_in_telemetry(tmp_path):
from viano_bug_analysis.lifecycle import Workspace
from viano_bug_analysis.telemetry import Telemetry
from viano_bug_analysis.telemetry.__main__ import read_events
cfg = config(input_cost_per_million=2, cached_input_cost_per_million=.2,
output_cost_per_million=4, cache_storage_cost_per_million_hour=.5)
response = ProviderResponse({},'m',100,15,{'cache_status':'created','cache_created_tokens':80,'cache_ttl_seconds':300},80,5)
metadata = usage_metadata(cfg,response) | {'input_tokens':100,'output_tokens':15,'cost_currency':'USD'}
failed = {'cache_usage':{'estimated_cache_storage_cost':.01,'cost_currency':'USD'}}
metadata['attempts'] = [failed]
with Telemetry(Workspace(tmp_path/'workspace')) as telemetry:
telemetry.llm_round({'phase':'INVESTIGATING_HYPOTHESES','status':'SUCCESS','outcome':{'metadata':metadata}})
metrics = telemetry.prometheus()
assert 'vba_llm_tokens_total{direction="cached_input_tokens"} 80' in metrics
assert 'vba_llm_tokens_total{direction="uncached_input_tokens"} 20' in metrics
assert 'vba_llm_tokens_total{direction="reasoning_tokens"} 5' in metrics
assert 'component="estimated_cache_storage_cost"' in metrics
event = next(e for e in read_events(telemetry.workspace.root) if e['event']=='llm_round')['detail']
assert event['cached_input_tokens'] == 80 and event['cache_status'] == 'created'
assert event['failed_cache_storage'] == [{'estimated_cost':.01,'currency':'USD'}]
def test_example_config_loads_without_credentials_or_network():
from pathlib import Path
cfg = GatewayConfig.load(Path(__file__).parents[1]/'config/llm.gemini-cache.example.toml')
assert cfg.providers[0].adapter == 'gemini' and cfg.providers[0].context_cache
LLMGateway(cfg)
def test_cache_memory_and_remote_allocations_are_bounded(monkeypatch):
adapter, cfg = GeminiProvider(), config()
actions = []
for _ in range(8): actions.extend([CACHE, native()])
actions.append(native(0))
seen = scripted(adapter, monkeypatch, actions)
for index in range(9):
req = request()
req['messages'][0]['content'] += str(index)
asyncio.run(adapter.generate(cfg,req,max_response_bytes=10000))
assert len(adapter._cache) == 8
assert sum(url.endswith('/cachedContents') for url,_,_ in seen) == 8
@pytest.mark.parametrize('invalid', [{}, {'name':'https://evil.example/secret'}, {'name':'cachedContents/../secret'}])
def test_invalid_cache_handles_never_used(monkeypatch, invalid):
adapter = GeminiProvider()
seen = scripted(adapter,monkeypatch,[invalid,native(0)])
result = asyncio.run(adapter.generate(config(),request(),max_response_bytes=10000))
assert result.diagnostics['cache_status'] == 'fallback'
assert 'cachedContent' not in seen[-1][1]
def test_required_cache_accepts_confirmed_creation_and_reuse(monkeypatch):
adapter, cfg = GeminiProvider(), replace(config(), cache_required=True)
seen = scripted(adapter,monkeypatch,[CACHE,native(),native()])
for _ in range(2):
result=asyncio.run(adapter.generate(cfg,request(),max_response_bytes=10000))
assert result.cached_input_tokens == 80
assert len(seen)==3 and all('cachedContent' in payload for _,payload,_ in seen[1:])
@pytest.mark.parametrize('mode', ['small','rejected','stale','missing_usage','zero_usage','inconsistent_usage'])
def test_required_cache_never_silently_falls_back(monkeypatch,mode):
adapter, cfg = GeminiProvider(), replace(config(),cache_required=True)
actions=[CACHE,native()]
expected='LLM_CACHE_REQUIRED'
if mode=='small':
cfg=replace(cfg,cache_min_prefix_chars=1000000)
actions=[]
elif mode=='rejected': actions=[LLMError('LLM_HTTP_CONFIGURATION',http_status=400)]
elif mode=='stale': actions=[CACHE,LLMError('LLM_HTTP_CONFIGURATION',http_status=404)]
else:
expected='LLM_CACHE_USAGE_UNCONFIRMED'
actions[1]=native(None if mode=='missing_usage' else 0 if mode=='zero_usage' else 101)
seen=scripted(adapter,monkeypatch,actions)
with pytest.raises(LLMError) as caught:
asyncio.run(adapter.generate(cfg,request(),max_response_bytes=10000))
assert caught.value.code==expected
assert all('cachedContent' in payload for url,payload,_ in seen if url.endswith(':generateContent'))
def test_required_prefix_keeps_other_phases_optional(monkeypatch):
adapter=GeminiProvider()
cfg=replace(config(),cache_required=True,cache_required_prompt_prefix='investigation_project_',cache_min_prefix_chars=1000000)
seen=scripted(adapter,monkeypatch,[native(0)])
assert asyncio.run(adapter.generate(cfg,request(),max_response_bytes=10000)).document=={'answer':'ok'}
req=request(); req['prompt_version']='investigation_project_v10'
with pytest.raises(LLMError,match='LLM_CACHE_REQUIRED'):
asyncio.run(adapter.generate(cfg,req,max_response_bytes=10000))
assert len(seen)==1
@pytest.mark.parametrize('failure',[ProviderUnavailable('LLM_CONNECTION'),TimeoutError()])
def test_required_cache_cannot_fail_over_to_uncached_provider(failure):
cfg=replace(config(),cache_required=True)
fallback=ProviderConfig('fallback','m','http://localhost',adapter='fixture')
adapter=Adapter({cfg.id:failure,'fallback':ProviderResponse({'answer':'ok'},'m')})
gateway=LLMGateway(GatewayConfig((cfg,fallback)),{'gemini':adapter,'fixture':adapter})
with pytest.raises(LLMError,match='LLM_CACHE_REQUIRED'):
asyncio.run(gateway.complete_round(request()))
assert adapter.calls==[cfg.id]
@pytest.mark.parametrize('changes',[{'context_cache':False},{'cache_required':'true'},
{'cache_required_prompt_prefix':''},{'cache_required':False,'cache_required_prompt_prefix':'investigation_'}])
def test_required_cache_config_is_strict(changes):
with pytest.raises(LLMError):
replace(config(),**({'cache_required':True}|changes))
def test_gateway_itself_requires_confirmed_cached_usage():
cfg=replace(config(),cache_required=True)
adapter=Adapter({cfg.id:ProviderResponse({'answer':'ok'},'m',100,10)})
with pytest.raises(LLMError,match='LLM_CACHE_USAGE_UNCONFIRMED'):
asyncio.run(LLMGateway(GatewayConfig((cfg,)),{'gemini':adapter}).complete_round(request()))