Capture sanitized HTTP diagnostics for isolated prompt probes

This commit is contained in:
Nico committed 2026-10-17 12:58:25 +02:00
1 parent 2eaba70083
commit f52af70646
4 files changed
+144 -25

No files matched your search

+43 -23
View File
@@ -4,45 +4,45 @@
.\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --run
```
Ohne --run erfolgt nur eine lokale Prüfung. Standardinput ist
Ohne --run erfolgt nur eine lokale Prüfung. Standardinput ist
service-data/benchmarks/compact-investigation-20261017/request.json.
Standardoutput ist result.json im selben Ordner. Ein vorhandenes Ergebnis
verhindert unbeabsichtigte Wiederholungen; für eine bewusst neue Messung
verhindert unbeabsichtigte Wiederholungen; für eine bewusst neue Messung
--output mit einem neuen Dateinamen verwenden.
Genau ein nativer Gemini-Aufruf, keine Wiederholung, kein Failover, kein
expliziter Cache und keinerlei Ausführung der vorgeschlagenen Recherche.
expliziter Cache und keinerlei Ausführung der vorgeschlagenen Recherche.
Das vorhandene Gateway-Konfigurationsprofil bestimmt Modell, Denkstufe,
Ausgabelimit und Timeout. Produktionsprompts bleiben unverändert.
Ausgabelimit und Timeout. Produktionsprompts bleiben unverändert.
Instruktionen und Kontext werden als System-/User-Nachricht übertragen;
das Ausgabeschema wird ausschließlich im nativen generationConfig gesetzt.
Instruktionen und Kontext werden als System-/User-Nachricht übertragen;
das Ausgabeschema wird ausschließlich im nativen generationConfig gesetzt.
Erfasst werden Eingabe-, Kandidaten-, Denk-, Cache- und Gesamttokens sowie
Laufzeit, Modellversion, JSON-Schemavalidität und der Hash der vorbereiteten
Anfrage. Eine passende JSON-Struktur allein beweist keine fachliche Qualität.
Laufzeit, Modellversion, JSON-Schemavalidität und der Hash der vorbereiteten
Anfrage. Eine passende JSON-Struktur allein beweist keine fachliche Qualität.
Der vorbereitete Prompt umfasst 8.396 Nachrichtenzeichen plus 2.486 Zeichen
minifiziertes Ausgabeschema, zusammen 10.882. Das sind etwa 29 mal 375 Zeichen
(des englischen Minimaltests). Formatierung der lesbaren PROMPT.txt zählt
nicht als zusätzlicher API-Input. Keine Tokenschätzung ersetzt die API-Messung.
(des englischen Minimaltests). Formatierung der lesbaren PROMPT.txt zählt
nicht als zusätzlicher API-Input. Keine Tokenschätzung ersetzt die API-Messung.
Testgrundlage ist der erste H1-Kontext von Bug 20 vor jeder Investigation-
Recherche; die Vorbereitung enthält keine später festgestellten Spawndaten.
Recherche; die Vorbereitung enthält keine später festgestellten Spawndaten.
Messartefakte und Provenienz liegen im Benchmarkordner, nicht in der produktiven
Fallakte. Ein Einzeltest untersucht Schrittwahl und Kontextgröße, nicht die
Qualität oder Gesamtkosten einer vollständigen Untersuchung.
Fallakte. Ein Einzeltest untersucht Schrittwahl und Kontextgröße, nicht die
Qualität oder Gesamtkosten einer vollständigen Untersuchung.
## Korrektur nach HTTP 400
Der erste kompakte Aufruf wurde mit HTTP 400 abgelehnt. Kein Tokenzähler wurde
zurückgegeben. Ein genauer Anbieterfehlertext wurde vom Transport nicht erhalten;
die exakte serverseitige Ursache bleibt daher unbestätigt. Im Schema fanden sich
const-only Diskriminatoren ohne type. Diese sind für den nativen Gemini-Subset
ungeeignet. Die Messhilfe übersetzt string-const in type:string plus enum,
oneOf in anyOf und primitive Typvereinigungen in anyOf. Nicht unterstützte
Längen-/Pattern-/Unique-Regeln werden ausschließlich lokal geprüft. Nicht
Der erste kompakte Aufruf wurde mit HTTP 400 abgelehnt. Kein Tokenzähler wurde
zurückgegeben. Ein genauer Anbieterfehlertext wurde vom Transport nicht erhalten;
die exakte serverseitige Ursache bleibt daher unbestätigt. Im Schema fanden sich
const-only Diskriminatoren ohne type. Diese sind für den nativen Gemini-Subset
ungeeignet. Die Messhilfe übersetzt string-const in type:string plus enum,
oneOf in anyOf und primitive Typvereinigungen in anyOf. Nicht unterstützte
Längen-/Pattern-/Unique-Regeln werden ausschließlich lokal geprüft. Nicht
abgebildete komplexe Referenz-/Bedingungsschemata werden lokal abgelehnt.
Originalprompt und vollständiger lokaler Validierungsvertrag bleiben gleich.
Originalprompt und vollständiger lokaler Validierungsvertrag bleiben gleich.
Gemini-Referenz: https://ai.google.dev/api/generate-content#v1beta.GenerationConfig
Der bestehende fehlgeschlagene result.json bleibt erhalten. Neuer Versuch:
@@ -52,5 +52,25 @@ Der bestehende fehlgeschlagene result.json bleibt erhalten. Neuer Versuch:
```
Ausgangskostenstand laut Nutzer jetzt 8,30 EUR. Der vorherige Anstieg um 0,01 EUR
ist als nachträglich sichtbare Kontodifferenz dokumentiert, nicht als exakter
Einzelaufruf-Abrechnungsbeleg. Die Korrektur wurde nur offline geprüft.
ist als nachträglich sichtbare Kontodifferenz dokumentiert, nicht als exakter
Einzelaufruf-Abrechnungsbeleg. Die Korrektur wurde nur offline geprüft.
## Anbieterdiagnose nach erneutem HTTP 400
Auch der erste Schemafix wurde von der API mit 400 abgewiesen. Die Ursache
ist weiterhin offen. Der Probe-Aufruf liest nun bei HTTP-Fehlern opt-in höchstens
8 KB des Anbieterfehlers und gibt nur begrenzte Status-/Nachrichten- und
Feldhinweise aus. API-Schlüssel, URLs und zitierte Werte werden bereinigt;
Rohkörper werden nicht gespeichert. Der normale Gemini-Produktionspfad behält
seine bisherigen kontrollierten Fehlercodes und verwirft weiterhin Rohfehler.
Eine bereits vorhandene Ergebnisdatei erzeugt PROMPT_PROBE_OUTPUT_EXISTS und
keinen Netzaufruf. Auch ein fehlgeschlagener API-Versuch wird nicht überschrieben.
Der nächste bewusst einzelne Diagnoseversuch lautet:
```powershell
.\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --run --output service-data/benchmarks/compact-investigation-20261017/result-diagnostic-1.json
```
Dies ist keine weitere vermutete Schemakorrektur. Die konkrete Fehlermeldung
muss erst vorliegen, bevor der eigentliche API-Vertrag weiter angepasst wird.
+8 -1
View File
@@ -30,7 +30,7 @@ class GeminiProvider:
return await asyncio.to_thread(self._generate, config, request, max_response_bytes)
@staticmethod
def _post(url, payload, headers, limit, deadline):
def _post(url, payload, headers, limit, deadline, *, error_capture=None):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise ProviderUnavailable('LLM_TIMEOUT')
@@ -51,6 +51,13 @@ class GeminiProvider:
chunks.append(chunk)
return parse_manifest(b''.join(chunks))
except urllib.error.HTTPError as error:
if error_capture is not None:
try:
# Opt-in diagnostic probes only; portable cases retain codes.
body = error.read(min(limit,8192)+1)
if len(body) <= min(limit,8192): error_capture(parse_manifest(body))
except Exception:
pass # Diagnostics must not change controlled HTTP rejection.
error.close()
if error.code in {408, 429} or 500 <= error.code < 600:
raise ProviderUnavailable('LLM_HTTP_TEMPORARY', http_status=error.code) from None
+47 -1
View File
@@ -2,10 +2,13 @@
import argparse
import hashlib
import json
import os
import re
from pathlib import Path
from jsonschema import Draft202012Validator
from .llm import GatewayConfig, LLMError
from .llm.gateway import PRIVACY
from .llm.gemini import GeminiProvider
from .minimal_prompt_test import measure
from .telemetry.safety import safe_code
@@ -61,6 +64,46 @@ def prepare(provider,request):
'payload':payload,'validation_schema':request['output_schema']}
def provider_diagnostic(body, provider):
"""Bounded local probe feedback, excluding raw response bodies and secrets."""
error=body.get('error',{}) if isinstance(body,dict) else {}
if not isinstance(error,dict): return {}
def clean(text):
if not isinstance(text,str): return None
secret=os.environ.get(provider.api_key_env) if provider.api_key_env else None
if secret: text=text.replace(secret,'[redacted]')
text=re.sub(r'https?://[^\s]+','[url]',text)
# Echoed literal values can contain prompt data; field paths remain useful.
text=re.sub(r'"[^"\n]*"|\'[^\'\n]*\'', '[quoted value]', text)
return ''.join(c if c.isprintable() else ' ' for c in text)[:1000]
result={}
if error.get('status') in {'INVALID_ARGUMENT','FAILED_PRECONDITION','RESOURCE_EXHAUSTED','PERMISSION_DENIED','UNAUTHENTICATED','NOT_FOUND','INTERNAL','UNAVAILABLE'}:
result['status']=error['status']
if isinstance(error.get('message'),str): result['message']=clean(error['message'])
violations=[]
details=error.get('details',[])
if isinstance(details,list):
for detail in details[:8]:
if not isinstance(detail,dict):continue
values=detail.get('fieldViolations',[])
if not isinstance(values,list):continue
for value in values[:8]:
if not isinstance(value,dict):continue
violations.append({k:clean(value[k]) for k in ('field','description') if isinstance(value.get(k),str)})
if violations:result['field_violations']=violations[:8]
return result
def measure_diagnostic(provider,case,limit):
diagnostic={}
def post(url,payload,headers,response_limit,deadline):
return GeminiProvider._post(url,payload,headers,response_limit,deadline,
error_capture=lambda body:diagnostic.update(provider_diagnostic(body,provider)))
result=measure(provider,case,limit,post=post)
if diagnostic:result['provider_error']=diagnostic
return result
def main(argv=None):
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--request',type=Path,default=DEFAULT)
@@ -89,10 +132,13 @@ def main(argv=None):
output=args.output or args.request.with_name('result.json')
# Exclusive reservation prevents accidental duplicate paid execution.
with output.open('x',encoding='utf-8') as f:json.dump({'status':'RUNNING',**metadata},f)
result={**measure(provider,case,config.max_response_bytes),**metadata}
result={**measure_diagnostic(provider,case,config.max_response_bytes),**metadata}
output.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps(result,ensure_ascii=True))
return 0 if result['status']=='SUCCESS' else 2
except FileExistsError:
print(json.dumps({'status':'ERROR','code':'PROMPT_PROBE_OUTPUT_EXISTS',
'guidance':'Use --output with a new filename. No API request was sent.'}));return 2
except KeyboardInterrupt:
print(json.dumps({'status':'INTERRUPTED'}));return 130
except Exception as error:
+46
View File
@@ -59,3 +59,49 @@ def test_local_constraints_not_silently_dropped_from_response_check():
'usageMetadata':{'promptTokenCount':200}}
result=measure(provider(),c,10000,post)
assert result['status']=='INVALID_JSON' and result['schema_valid'] is False
def test_provider_error_is_bounded_and_redacts_credentials_and_literals(monkeypatch):
from dataclasses import replace
from viano_bug_analysis.prompt_probe import provider_diagnostic
monkeypatch.setenv('PROBE_TEST_SECRET','key-secret-value')
p=replace(provider(),api_key_env='PROBE_TEST_SECRET')
detail=provider_diagnostic({'error':{'status':'INVALID_ARGUMENT',
'message':'generation_config schema failed: key-secret-value "private text" https://example.invalid/private',
'details':[{'fieldViolations':[{'field':'generation_config.response_json_schema','description':'Too many states'}]}],
'extra':'do not retain'}},p)
import json
text=json.dumps(detail)
assert 'key-secret-value' not in text and 'private text' not in text
assert 'https://' not in text and 'do not retain' not in text
assert detail['status']=='INVALID_ARGUMENT'
assert detail['field_violations'][0]['description']=='Too many states'
def test_opt_in_http_capture_preserves_controlled_error(monkeypatch):
import io,json,time,urllib.error
from viano_bug_analysis.llm.gemini import GeminiProvider
body=json.dumps({'error':{'status':'INVALID_ARGUMENT','message':'Schema too complex'}}).encode()
class Opener:
def open(self,*args,**kwargs):
raise urllib.error.HTTPError('https://example.invalid',400,'Bad Request',{},io.BytesIO(body))
monkeypatch.setattr('urllib.request.build_opener',lambda *args:Opener())
seen=[]
with pytest.raises(LLMError) as caught:
GeminiProvider._post('https://example.invalid',{}, {},10000,time.monotonic()+10,error_capture=seen.append)
assert caught.value.code=='LLM_HTTP_CONFIGURATION' and caught.value.http_status==400
assert seen[0]['error']['message']=='Schema too complex'
def test_existing_output_is_named_error_without_network(tmp_path,monkeypatch,capsys):
import json
from viano_bug_analysis import prompt_probe
from viano_bug_analysis.llm import GatewayConfig
path=tmp_path/'request.json';path.write_text(json.dumps(request()))
output=tmp_path/'result.json';output.write_text('keep original')
monkeypatch.setattr(prompt_probe.GatewayConfig,'load',lambda _:GatewayConfig(providers=(provider(),),privacy_profile='cloud_allowed'))
def forbidden(*args):raise AssertionError('Unexpected network call')
monkeypatch.setattr(prompt_probe,'measure_diagnostic',forbidden)
result=prompt_probe.main(['--run','--request',str(path),'--output',str(output)])
assert result==2 and 'PROMPT_PROBE_OUTPUT_EXISTS' in capsys.readouterr().out
assert output.read_text()=='keep original'