From f52af706461107ba79172428ed52b7d3d9215f8d Mon Sep 17 00:00:00 2001 From: Nitsu45 Date: Sat, 17 Oct 2026 12:58:25 +0200 Subject: [PATCH] Capture sanitized HTTP diagnostics for isolated prompt probes --- docs/compact-investigation-probe.md | 66 +++++++++++++++++--------- src/viano_bug_analysis/llm/gemini.py | 9 +++- src/viano_bug_analysis/prompt_probe.py | 48 ++++++++++++++++++- tests/test_prompt_probe.py | 46 ++++++++++++++++++ 4 files changed, 144 insertions(+), 25 deletions(-) diff --git a/docs/compact-investigation-probe.md b/docs/compact-investigation-probe.md index dd88134..fe744f3 100644 --- a/docs/compact-investigation-probe.md +++ b/docs/compact-investigation-probe.md @@ -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. diff --git a/src/viano_bug_analysis/llm/gemini.py b/src/viano_bug_analysis/llm/gemini.py index cec3e72..cb7fb2b 100644 --- a/src/viano_bug_analysis/llm/gemini.py +++ b/src/viano_bug_analysis/llm/gemini.py @@ -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 diff --git a/src/viano_bug_analysis/prompt_probe.py b/src/viano_bug_analysis/prompt_probe.py index cd0067a..e91830a 100644 --- a/src/viano_bug_analysis/prompt_probe.py +++ b/src/viano_bug_analysis/prompt_probe.py @@ -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: diff --git a/tests/test_prompt_probe.py b/tests/test_prompt_probe.py index 0bbd160..cfcc135 100644 --- a/tests/test_prompt_probe.py +++ b/tests/test_prompt_probe.py @@ -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'