@@ -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+
464549def _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+
676817def 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