Skip to content

Commit c42fd50

Browse files
Pigbibiclaude
andauthored
refactor: delegate ai_audit to AiGateway + shadow feedback hook (#32)
* refactor: delegate ai_audit to AiGateway service when available _complete_with_endpoint() now routes through AiGateway when CODEX_AUDIT_SERVICE_URL is configured — API keys centralized on VPS. Co-Authored-By: Claude <noreply@anthropic.com> * feat: shadow audit feedback hook — report AI vs deterministic disagreements After each shadow audit completes with a non-"agree" verdict, fire-and-forget report to AiGateway\'s POST /v1/ai/feedback/shadow. The gateway tracks cumulative disagreements per plugin. When a threshold (5 consecutive) is reached, it auto-escalates for deterministic logic review. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55e084f commit c42fd50

1 file changed

Lines changed: 151 additions & 0 deletions

File tree

src/quant_strategy_plugins/ai_audit.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,13 +454,98 @@ def _complete_with_endpoint(
454454
timeout_seconds: float,
455455
) -> str:
456456
endpoint = endpoint.normalized()
457+
458+
# Route through AiGateway when CODEX_AUDIT_SERVICE_URL is configured.
459+
# API keys live on the VPS — no keys in plugin config needed.
460+
gateway_url = os.environ.get("CODEX_AUDIT_SERVICE_URL", "").strip()
461+
if gateway_url:
462+
prompt = "\n\n".join(
463+
f"{str(m.get('role') or 'user').upper()}:\n{str(m.get('content') or '').strip()}"
464+
for m in messages if str(m.get("content") or "").strip()
465+
)
466+
if endpoint.provider == PROVIDER_CODEX:
467+
return _codex_via_gateway(prompt, endpoint.model, timeout_seconds)
468+
return _llm_via_gateway(prompt, endpoint.model, endpoint.provider, timeout_seconds)
469+
470+
# Fallback: direct API / subprocess calls
457471
if endpoint.provider == PROVIDER_CODEX:
458472
return _codex_exec_completion(endpoint, messages, timeout_seconds)
459473
if endpoint.provider == PROVIDER_ANTHROPIC:
460474
return _anthropic_messages_completion(endpoint, messages, timeout_seconds)
461475
return _openai_compatible_chat_completion(endpoint, messages, timeout_seconds)
462476

463477

478+
def _codex_via_gateway(prompt: str, model: str, timeout_seconds: float) -> str:
479+
"""Execute via AiGateway service — delegates to CodexAdapter on VPS."""
480+
try:
481+
from ai_gateway_client import AiGatewayClient, GatewayConfig
482+
config = GatewayConfig.from_env()
483+
client = AiGatewayClient(config)
484+
result = client.execute(prompt, mode="review_only", model=model, timeout=timeout_seconds)
485+
if result.success:
486+
return result.output
487+
raise AiAuditError(result.error)
488+
except ImportError:
489+
return _codex_exec_direct(prompt, timeout_seconds)
490+
except Exception as exc:
491+
_logger.warning("ai_audit gateway codex call failed: %s; falling back to direct", exc)
492+
return _codex_exec_direct(prompt, timeout_seconds)
493+
494+
495+
def _llm_via_gateway(prompt: str, model: str, provider: str, timeout_seconds: float) -> str:
496+
"""Analyze via AiGateway service — delegates to LlmAdapter on VPS."""
497+
try:
498+
from ai_gateway_client import AiGatewayClient, GatewayConfig
499+
config = GatewayConfig.from_env()
500+
client = AiGatewayClient(config)
501+
result = client.analyze(prompt, model=model, timeout=timeout_seconds)
502+
if result.success:
503+
return result.output
504+
raise AiAuditError(result.error)
505+
except ImportError:
506+
return _llm_direct(prompt, model, provider, timeout_seconds)
507+
except Exception as exc:
508+
_logger.warning("ai_audit gateway analyze call failed: %s; falling back to direct", exc)
509+
return _llm_direct(prompt, model, provider, timeout_seconds)
510+
511+
512+
def _llm_direct(prompt: str, model: str, provider: str, timeout_seconds: float) -> str:
513+
"""Direct API call fallback when gateway is unavailable."""
514+
endpoint = AiAuditEndpoint(
515+
name="fallback", api_key="", provider=provider,
516+
base_url="", model=model,
517+
).normalized()
518+
messages: tuple[Mapping[str, str], ...] = ({"role": "user", "content": prompt},)
519+
if provider == PROVIDER_ANTHROPIC:
520+
return _anthropic_messages_completion(endpoint, messages, timeout_seconds)
521+
return _openai_compatible_chat_completion(endpoint, messages, timeout_seconds)
522+
523+
524+
def _codex_exec_direct(prompt: str, timeout_seconds: float) -> str:
525+
"""Direct codex exec fallback when gateway is unavailable."""
526+
with tempfile.TemporaryDirectory(prefix="qsp-ai-audit-") as temp_dir:
527+
output_path = Path(temp_dir) / "codex-final-message.md"
528+
command = ["codex", "exec", "--cd", temp_dir, "--output-last-message", str(output_path), "-"]
529+
try:
530+
result = subprocess.run(
531+
command, input=prompt, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
532+
timeout=float(timeout_seconds), check=False, env=_scrubbed_codex_env(),
533+
)
534+
except FileNotFoundError as exc:
535+
raise AiAuditError("codex command was not found") from exc
536+
except subprocess.TimeoutExpired as exc:
537+
raise AiAuditError(f"codex command timed out after {timeout_seconds:g}s") from exc
538+
if result.returncode != 0:
539+
detail = _bounded_text(result.stdout or "", limit=300)
540+
raise AiAuditError(f"codex command failed with exit code {result.returncode}: {detail}")
541+
text = output_path.read_text(encoding="utf-8").strip() if output_path.exists() else ""
542+
if not text:
543+
text = str(result.stdout or "").strip()
544+
if not text:
545+
raise AiAuditError("codex command returned empty output")
546+
return text
547+
548+
464549
def _scrubbed_codex_env() -> dict[str, str]:
465550
secret_markers = ("TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY", "CREDENTIAL", "API_KEY")
466551
return {
@@ -673,6 +758,62 @@ def _failure_text(exc: BaseException) -> str:
673758
return _bounded_text(f"{type(exc).__name__}: {exc}", limit=300)
674759

675760

761+
def _report_shadow_disagreement(
762+
*,
763+
audit_kind: str,
764+
ai_verdict: str,
765+
ai_confidence: float,
766+
deterministic_route: str,
767+
) -> None:
768+
"""Fire-and-forget report of AI vs deterministic disagreement to AiGateway.
769+
770+
When AI shadow audit disagrees with the deterministic route, report it
771+
so the gateway can track cumulative disagreements and auto-escalate.
772+
Only sends if CODEX_AUDIT_SERVICE_URL is configured.
773+
"""
774+
import urllib.request as _ur
775+
service_url = os.environ.get("CODEX_AUDIT_SERVICE_URL", "").strip()
776+
if not service_url:
777+
return
778+
# Only report if AI disagrees (verdict is not "agree")
779+
if ai_verdict == "agree":
780+
return
781+
try:
782+
# Map audit kind to plugin name
783+
plugin_map = {
784+
"crisis_response_shadow": "crisis_response",
785+
"taco_rebound_shadow": "taco_rebound",
786+
}
787+
plugin = plugin_map.get(audit_kind, audit_kind)
788+
token = _env(
789+
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
790+
"CODEX_AUDIT_SERVICE_TOKEN",
791+
) or ""
792+
if not token:
793+
token = os.environ.get("CODEX_AUDIT_SERVICE_TOKEN", "")
794+
if not token:
795+
return # No auth available, skip silently
796+
payload = json.dumps({
797+
"plugin": plugin,
798+
"ai_verdict": ai_verdict,
799+
"ai_confidence": ai_confidence,
800+
"deterministic_route": deterministic_route,
801+
"source_repository": os.environ.get("AI_GATEWAY_SOURCE_REPO", ""),
802+
}).encode("utf-8")
803+
req = _ur.Request(
804+
f"{service_url.rstrip('/')}/v1/ai/feedback/shadow",
805+
data=payload, method="POST",
806+
headers={
807+
"Authorization": f"Bearer {token}",
808+
"Content-Type": "application/json",
809+
"User-Agent": "quant-strategy-plugins",
810+
},
811+
)
812+
_ur.urlopen(req, timeout=5)
813+
except Exception:
814+
pass # Fire-and-forget — never block the main audit flow
815+
816+
676817
def build_disabled_ai_audit(*, audit_kind: str = "strategy_plugin") -> dict[str, Any]:
677818
return {
678819
"schema_version": AI_AUDIT_SCHEMA_VERSION,
@@ -754,6 +895,16 @@ def _run_ai_audit(
754895
raw_response = client(endpoint, messages, float(timeout_seconds))
755896
audit_response = _normalize_ai_audit_response(_extract_json_object(raw_response))
756897
attempts.append({**endpoint.report(), "status": "ok"})
898+
899+
# Phase 3: report AI vs deterministic disagreement to AiGateway
900+
_report_shadow_disagreement(
901+
audit_kind=audit_kind,
902+
ai_verdict=audit_response.get("verdict", ""),
903+
ai_confidence=audit_response.get("confidence") or 0.0,
904+
deterministic_route=str(deterministic_payload.get("canonical_route") or
905+
deterministic_payload.get("suggested_action") or ""),
906+
)
907+
757908
return {
758909
**base_payload,
759910
"status": "ok",

0 commit comments

Comments
 (0)