Capture sanitized HTTP diagnostics for isolated prompt probes
This commit is contained in:
1 parent
2eaba70083
commit
f52af70646
4 files changed
+144
-25
No files matched your search
@@ -4,45 +4,45 @@
|
|||||||
.\.venv\Scripts\python.exe -m viano_bug_analysis.prompt_probe --run
|
.\.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.
|
service-data/benchmarks/compact-investigation-20261017/request.json.
|
||||||
Standardoutput ist result.json im selben Ordner. Ein vorhandenes Ergebnis
|
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.
|
--output mit einem neuen Dateinamen verwenden.
|
||||||
|
|
||||||
Genau ein nativer Gemini-Aufruf, keine Wiederholung, kein Failover, kein
|
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,
|
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;
|
Instruktionen und Kontext werden als System-/User-Nachricht übertragen;
|
||||||
das Ausgabeschema wird ausschließlich im nativen generationConfig gesetzt.
|
das Ausgabeschema wird ausschließlich im nativen generationConfig gesetzt.
|
||||||
Erfasst werden Eingabe-, Kandidaten-, Denk-, Cache- und Gesamttokens sowie
|
Erfasst werden Eingabe-, Kandidaten-, Denk-, Cache- und Gesamttokens sowie
|
||||||
Laufzeit, Modellversion, JSON-Schemavalidität und der Hash der vorbereiteten
|
Laufzeit, Modellversion, JSON-Schemavalidität und der Hash der vorbereiteten
|
||||||
Anfrage. Eine passende JSON-Struktur allein beweist keine fachliche Qualität.
|
Anfrage. Eine passende JSON-Struktur allein beweist keine fachliche Qualität.
|
||||||
|
|
||||||
Der vorbereitete Prompt umfasst 8.396 Nachrichtenzeichen plus 2.486 Zeichen
|
Der vorbereitete Prompt umfasst 8.396 Nachrichtenzeichen plus 2.486 Zeichen
|
||||||
minifiziertes Ausgabeschema, zusammen 10.882. Das sind etwa 29 mal 375 Zeichen
|
minifiziertes Ausgabeschema, zusammen 10.882. Das sind etwa 29 mal 375 Zeichen
|
||||||
(des englischen Minimaltests). Formatierung der lesbaren PROMPT.txt zählt
|
(des englischen Minimaltests). Formatierung der lesbaren PROMPT.txt zählt
|
||||||
nicht als zusätzlicher API-Input. Keine Tokenschätzung ersetzt die API-Messung.
|
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-
|
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
|
Messartefakte und Provenienz liegen im Benchmarkordner, nicht in der produktiven
|
||||||
Fallakte. Ein Einzeltest untersucht Schrittwahl und Kontextgröße, nicht die
|
Fallakte. Ein Einzeltest untersucht Schrittwahl und Kontextgröße, nicht die
|
||||||
Qualität oder Gesamtkosten einer vollständigen Untersuchung.
|
Qualität oder Gesamtkosten einer vollständigen Untersuchung.
|
||||||
|
|
||||||
## Korrektur nach HTTP 400
|
## Korrektur nach HTTP 400
|
||||||
|
|
||||||
Der erste kompakte Aufruf wurde mit HTTP 400 abgelehnt. Kein Tokenzähler wurde
|
Der erste kompakte Aufruf wurde mit HTTP 400 abgelehnt. Kein Tokenzähler wurde
|
||||||
zurückgegeben. Ein genauer Anbieterfehlertext wurde vom Transport nicht erhalten;
|
zurückgegeben. Ein genauer Anbieterfehlertext wurde vom Transport nicht erhalten;
|
||||||
die exakte serverseitige Ursache bleibt daher unbestätigt. Im Schema fanden sich
|
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
|
const-only Diskriminatoren ohne type. Diese sind für den nativen Gemini-Subset
|
||||||
ungeeignet. Die Messhilfe übersetzt string-const in type:string plus enum,
|
ungeeignet. Die Messhilfe übersetzt string-const in type:string plus enum,
|
||||||
oneOf in anyOf und primitive Typvereinigungen in anyOf. Nicht unterstützte
|
oneOf in anyOf und primitive Typvereinigungen in anyOf. Nicht unterstützte
|
||||||
Längen-/Pattern-/Unique-Regeln werden ausschließlich lokal geprüft. Nicht
|
Längen-/Pattern-/Unique-Regeln werden ausschließlich lokal geprüft. Nicht
|
||||||
abgebildete komplexe Referenz-/Bedingungsschemata werden lokal abgelehnt.
|
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
|
Gemini-Referenz: https://ai.google.dev/api/generate-content#v1beta.GenerationConfig
|
||||||
|
|
||||||
Der bestehende fehlgeschlagene result.json bleibt erhalten. Neuer Versuch:
|
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
|
Ausgangskostenstand laut Nutzer jetzt 8,30 EUR. Der vorherige Anstieg um 0,01 EUR
|
||||||
ist als nachträglich sichtbare Kontodifferenz dokumentiert, nicht als exakter
|
ist als nachträglich sichtbare Kontodifferenz dokumentiert, nicht als exakter
|
||||||
Einzelaufruf-Abrechnungsbeleg. Die Korrektur wurde nur offline geprüft.
|
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.
|
||||||
@@ -30,7 +30,7 @@ class GeminiProvider:
|
|||||||
return await asyncio.to_thread(self._generate, config, request, max_response_bytes)
|
return await asyncio.to_thread(self._generate, config, request, max_response_bytes)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _post(url, payload, headers, limit, deadline):
|
def _post(url, payload, headers, limit, deadline, *, error_capture=None):
|
||||||
remaining = deadline - time.monotonic()
|
remaining = deadline - time.monotonic()
|
||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
raise ProviderUnavailable('LLM_TIMEOUT')
|
raise ProviderUnavailable('LLM_TIMEOUT')
|
||||||
@@ -51,6 +51,13 @@ class GeminiProvider:
|
|||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
return parse_manifest(b''.join(chunks))
|
return parse_manifest(b''.join(chunks))
|
||||||
except urllib.error.HTTPError as error:
|
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()
|
error.close()
|
||||||
if error.code in {408, 429} or 500 <= error.code < 600:
|
if error.code in {408, 429} or 500 <= error.code < 600:
|
||||||
raise ProviderUnavailable('LLM_HTTP_TEMPORARY', http_status=error.code) from None
|
raise ProviderUnavailable('LLM_HTTP_TEMPORARY', http_status=error.code) from None
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from jsonschema import Draft202012Validator
|
from jsonschema import Draft202012Validator
|
||||||
from .llm import GatewayConfig, LLMError
|
from .llm import GatewayConfig, LLMError
|
||||||
from .llm.gateway import PRIVACY
|
from .llm.gateway import PRIVACY
|
||||||
|
from .llm.gemini import GeminiProvider
|
||||||
from .minimal_prompt_test import measure
|
from .minimal_prompt_test import measure
|
||||||
from .telemetry.safety import safe_code
|
from .telemetry.safety import safe_code
|
||||||
|
|
||||||
@@ -61,6 +64,46 @@ def prepare(provider,request):
|
|||||||
'payload':payload,'validation_schema':request['output_schema']}
|
'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):
|
def main(argv=None):
|
||||||
p=argparse.ArgumentParser(description=__doc__)
|
p=argparse.ArgumentParser(description=__doc__)
|
||||||
p.add_argument('--request',type=Path,default=DEFAULT)
|
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')
|
output=args.output or args.request.with_name('result.json')
|
||||||
# Exclusive reservation prevents accidental duplicate paid execution.
|
# Exclusive reservation prevents accidental duplicate paid execution.
|
||||||
with output.open('x',encoding='utf-8') as f:json.dump({'status':'RUNNING',**metadata},f)
|
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')
|
output.write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
||||||
print(json.dumps(result,ensure_ascii=True))
|
print(json.dumps(result,ensure_ascii=True))
|
||||||
return 0 if result['status']=='SUCCESS' else 2
|
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:
|
except KeyboardInterrupt:
|
||||||
print(json.dumps({'status':'INTERRUPTED'}));return 130
|
print(json.dumps({'status':'INTERRUPTED'}));return 130
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|||||||
@@ -59,3 +59,49 @@ def test_local_constraints_not_silently_dropped_from_response_check():
|
|||||||
'usageMetadata':{'promptTokenCount':200}}
|
'usageMetadata':{'promptTokenCount':200}}
|
||||||
result=measure(provider(),c,10000,post)
|
result=measure(provider(),c,10000,post)
|
||||||
assert result['status']=='INVALID_JSON' and result['schema_valid'] is False
|
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'
|
||||||
Reference in new issue
Block a user