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
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
Reference in new issue
Block a user