1- """Unified AI Service Provider — two backend paths .
1+ """Unified AI Service Provider — routes all calls through AiGateway .
22
3- Architecture:
4- API calls (Claude/GPT) → direct API call (needs API_KEY secret)
5- Codex VPS calls → CodexAuditBridge service (needs CODEX_AUDIT_SERVICE_URL)
3+ Architecture::
64
7- The two paths exist because the VPS only runs Codex CLI. Claude/GPT are called
8- directly from the GitHub Actions workflow using repo/org-level secrets.
5+ QuantStrategyLifecycle AiGateway (VPS, single service)
6+ ─────────────────── ─────────────────────────────────
7+ AiServiceClient │
8+ ├─ review() ── task=analyze ──────┤──▶ LlmAdapter (Claude/GPT API)
9+ ├─ verify() ── task=execute ──────┤──▶ CodexAdapter (codex exec)
10+ └─ execute()── task=execute ──────┤──▶ CodexAdapter (codex exec)
911
10- Two built-in patterns:
12+ No API keys in this repo — all AI backends accessed through AiGateway.
13+ Only `CODEX_AUDIT_SERVICE_URL` is required.
1114
12- RELIABILITY — Codex VPS primary → Claude fallback → GPT fallback
13- Used by: CodexAuditBridge (code audit, must complete).
14-
15- SAFETY — Rules → Claude → GPT → Codex VPS verify → consensus
16- Used by: strategy_lifecycle (parameter approval, must be correct).
15+ Benefits:
16+ - API keys live on the VPS (one place), not in N repos
17+ - New backends = new adapter on the gateway, callers unchanged
18+ - REPAIR/SAFETY patterns unchanged, just the transport is unified
1719"""
1820
1921from __future__ import annotations
2022
2123import enum
2224import json
2325import os
24- import re
2526import time
2627import urllib .error
2728import urllib .request
28- from collections .abc import Mapping , Sequence
29+ from collections .abc import Sequence
2930from dataclasses import dataclass
3031from typing import Any
3132
@@ -45,44 +46,30 @@ class AiPattern(str, enum.Enum):
4546class AiProviderConfig :
4647 provider : AiProviderId
4748 label : str
48-
49- # For API-based providers (Claude, GPT)
50- api_key_env : str = ""
51- model : str = ""
52- base_url : str = ""
53-
54- # For Codex VPS
55- service_url_env : str = "CODEX_AUDIT_SERVICE_URL"
56- audience_env : str = "CODEX_AUDIT_SERVICE_AUDIENCE"
57- source_repo : str = "QuantStrategyLab/QuantStrategyLifecycle"
58- source_ref : str = "main"
59-
49+ model : str = "" # sent as "model" to gateway
50+ task : str = "analyze" # analyze (API) or execute (Codex)
6051 can_execute_code : bool = False
6152 can_analyze : bool = True
6253
63- def resolve_api_key (self ) -> str | None :
64- if not self .api_key_env :
65- return None
66- return os .environ .get (self .api_key_env , "" ).strip () or None
67-
6854 def resolve_service_url (self ) -> str | None :
69- return os .environ .get (self . service_url_env , "" ).strip () or None
55+ return os .environ .get ("CODEX_AUDIT_SERVICE_URL" , "" ).strip () or None
7056
7157 @classmethod
7258 def claude (cls ) -> "AiProviderConfig" :
7359 return cls (provider = AiProviderId .CLAUDE , label = "Claude" ,
74- api_key_env = "ANTHROPIC_API_KEY" , model = "claude-sonnet-4-6" ,
60+ model = "claude-sonnet-4-6" , task = "analyze " ,
7561 can_execute_code = False , can_analyze = True )
7662
7763 @classmethod
7864 def gpt (cls ) -> "AiProviderConfig" :
7965 return cls (provider = AiProviderId .GPT , label = "GPT" ,
80- api_key_env = "OPENAI_API_KEY" , model = "gpt-5.4-mini" ,
66+ model = "gpt-5.4-mini" , task = "analyze " ,
8167 can_execute_code = False , can_analyze = True )
8268
8369 @classmethod
8470 def codex_vps (cls ) -> "AiProviderConfig" :
8571 return cls (provider = AiProviderId .CODEX_VPS , label = "Codex VPS" ,
72+ task = "execute" ,
8673 can_execute_code = True , can_analyze = True )
8774
8875
@@ -93,52 +80,41 @@ class AiServiceConfig:
9380 fallback : tuple [AiProviderConfig , ...] = ()
9481 reviewers : tuple [AiProviderConfig , ...] = ()
9582 verifier : AiProviderConfig | None = None
96- require_consensus : bool = True
9783
9884 @classmethod
9985 def reliability (cls , * , primary : AiProviderConfig , fallback : Sequence [AiProviderConfig ] = ()) -> "AiServiceConfig" :
10086 return cls (pattern = AiPattern .RELIABILITY , primary = primary , fallback = tuple (fallback ))
10187
10288 @classmethod
103- def safety (cls , * , reviewers : Sequence [AiProviderConfig ], verifier : AiProviderConfig | None = None ,
104- require_consensus : bool = True ) -> "AiServiceConfig" :
89+ def safety (cls , * , reviewers : Sequence [AiProviderConfig ], verifier : AiProviderConfig | None = None ) -> "AiServiceConfig" :
10590 return cls (pattern = AiPattern .SAFETY , reviewers = tuple (reviewers ), verifier = verifier )
10691
10792 @classmethod
10893 def from_env (cls ) -> "AiServiceConfig" :
109- """Auto-detect available backends from env vars."""
110- has_api_key = bool (os .environ .get ("ANTHROPIC_API_KEY" , "" ).strip ())
111- has_gpt = bool (os .environ .get ("OPENAI_API_KEY" , "" ).strip ())
112- has_codex = bool (os .environ .get ("CODEX_AUDIT_SERVICE_URL" , "" ).strip ())
94+ """Auto-detect from CODEX_AUDIT_SERVICE_URL (no API keys needed)."""
95+ has_service = bool (os .environ .get ("CODEX_AUDIT_SERVICE_URL" , "" ).strip ())
96+ if not has_service :
97+ return cls .safety (reviewers = [])
98+ return cls .safety (
99+ reviewers = [AiProviderConfig .claude (), AiProviderConfig .gpt ()],
100+ verifier = AiProviderConfig .codex_vps (),
101+ )
113102
114- reviewers : list [AiProviderConfig ] = []
115- if has_api_key :
116- reviewers .append (AiProviderConfig .claude ())
117- if has_gpt :
118- reviewers .append (AiProviderConfig .gpt ())
119103
120- verifier = AiProviderConfig .codex_vps () if has_codex else None
121-
122- if reviewers :
123- return cls .safety (reviewers = reviewers , verifier = verifier )
124- if has_codex :
125- return cls .reliability (primary = AiProviderConfig .codex_vps ())
126- return cls .safety (reviewers = [])
127-
128-
129- # ── AI Service Client ────────────────────────────────────────────────
104+ # ── Client ───────────────────────────────────────────────────────────
130105
131106
132107class AiServiceClient :
133108 def __init__ (self , config : AiServiceConfig ):
134109 self .config = config
135110
136- def review (self , prompt : str , * , timeout : float = 45.0 ) -> list ["AiCallResult" ]:
111+ def review (self , prompt : str , * , timeout : float = 120.0 ) -> list ["AiCallResult" ]:
112+ """Run all reviewers (analyze/sync)."""
137113 import concurrent .futures
138114 if not self .config .reviewers :
139115 return []
140116 with concurrent .futures .ThreadPoolExecutor (max_workers = min (len (self .config .reviewers ), 3 )) as pool :
141- futures = {pool .submit (self ._call_provider , c , prompt , timeout ): c for c in self .config .reviewers }
117+ futures = {pool .submit (self ._call , c , prompt , timeout ): c for c in self .config .reviewers }
142118 results = []
143119 for f in concurrent .futures .as_completed (futures ):
144120 try :
@@ -150,92 +126,70 @@ def review(self, prompt: str, *, timeout: float = 45.0) -> list["AiCallResult"]:
150126 def verify (self , prompt : str , * , timeout : float = 600.0 ) -> "AiCallResult | None" :
151127 if self .config .verifier is None :
152128 return None
153- return self ._call_provider (self .config .verifier , prompt , timeout )
129+ return self ._call (self .config .verifier , prompt , timeout )
154130
155131 def execute (self , prompt : str , * , timeout : float = 600.0 ) -> "AiCallResult" :
156132 if self .config .primary is not None :
157- r = self ._call_provider (self .config .primary , prompt , timeout )
133+ r = self ._call (self .config .primary , prompt , timeout )
158134 if r .success :
159135 return r
160136 for fb in self .config .fallback :
161- r = self ._call_provider (fb , prompt , timeout )
137+ r = self ._call (fb , prompt , timeout )
162138 if r .success :
163139 return AiCallResult (provider = r .provider , success = True , output = r .output , raw = r .raw ,
164- note = f "Fallback after primary failed" )
140+ note = "Fallback after primary failed" )
165141 return AiCallResult .unavailable ("all" , "All providers exhausted" )
166142
167- # ── Core call routing ────────────────────────────────────────
168-
169- def _call_provider (self , provider : AiProviderConfig , prompt : str , timeout : float ) -> "AiCallResult" :
170- if provider .provider == AiProviderId .CODEX_VPS :
171- return self ._call_codex_vps (provider , prompt , timeout )
172- return self ._call_llm_api (provider , prompt , timeout )
173-
174- def _call_llm_api (self , provider : AiProviderConfig , prompt : str , timeout : float ) -> "AiCallResult" :
175- """Call Claude or GPT directly via API (uses repo secret)."""
176- api_key = provider .resolve_api_key ()
177- if not api_key :
178- return AiCallResult .unavailable (provider .label , "API key not configured" )
179-
180- try :
181- from quant_strategy_plugins .ai_audit import AiAuditEndpoint , call_ai_audit
182-
183- endpoint = AiAuditEndpoint (
184- name = f"ai_provider_{ provider .provider .value } " ,
185- api_key = api_key ,
186- provider = "anthropic" if provider .provider == AiProviderId .CLAUDE else "openai" ,
187- model = provider .model ,
188- base_url = provider .base_url or (
189- "https://api.anthropic.com/v1" if provider .provider == AiProviderId .CLAUDE else "https://api.openai.com/v1"
190- ),
191- )
192- raw = call_ai_audit (endpoint , [{"role" : "user" , "content" : prompt }], timeout = timeout )
193- output = raw if isinstance (raw , str ) else json .dumps (raw )
194- return AiCallResult (provider = provider .label , success = True , output = output , raw = raw )
195- except ImportError :
196- return AiCallResult .unavailable (provider .label , "quant_strategy_plugins not installed" )
197- except Exception as exc :
198- return AiCallResult .unavailable (provider .label , str (exc ))
199-
200- def _call_codex_vps (self , provider : AiProviderConfig , prompt : str , timeout : float ) -> "AiCallResult" :
201- """Call Codex VPS via the async job API."""
143+ def _call (self , provider : AiProviderConfig , prompt : str , timeout : float ) -> "AiCallResult" :
144+ """Call the AiGateway — all providers use the same endpoint."""
202145 service_url = provider .resolve_service_url ()
203146 if not service_url :
204147 return AiCallResult .unavailable (provider .label , "CODEX_AUDIT_SERVICE_URL not configured" )
205148
206149 try :
207150 token = _fetch_oidc_token ()
208- audience = os .environ .get (provider .audience_env , "quant-codex-audit" )
209151 base_url = service_url .rstrip ("/" )
210152
211153 payload = json .dumps ({
212- "source_repository" : provider .source_repo ,
213- "source_ref" : provider .source_ref ,
214- "task" : "strategy_review" ,
215- "mode" : "review_only" ,
154+ "task" : provider .task ,
155+ "model" : provider .model ,
216156 "prompt" : prompt ,
217157 "timeout_seconds" : int (timeout ),
218- "model" : "" , # service default model
158+ "source_repository" : os .environ .get ("AI_GATEWAY_SOURCE_REPO" , "QuantStrategyLab/QuantStrategyLifecycle" ),
159+ "source_ref" : "main" ,
160+ "mode" : "review_only" ,
219161 }).encode ("utf-8" )
220162
163+ sync = provider .task == "analyze"
221164 req = urllib .request .Request (
222165 f"{ base_url } /v1/codex-audit/jobs" , data = payload , method = "POST" ,
223166 headers = {"Authorization" : f"Bearer { token } " , "Content-Type" : "application/json" ,
224- "Accept" : "application/json" , "User-Agent" : "strategy-lifecycle" },
167+ "Accept" : "application/json" , "User-Agent" : "quant- strategy-lifecycle" },
225168 )
226169 with urllib .request .urlopen (req , timeout = 30 ) as resp :
227- submit = json .loads (resp .read ().decode ("utf-8" ))
228- job_id = submit .get ("job_id" )
170+ result = json .loads (resp .read ().decode ("utf-8" ))
171+
172+ if sync :
173+ # Analyze returns result inline
174+ status = result .get ("status" )
175+ if status == "succeeded" :
176+ return AiCallResult (provider = provider .label , success = True ,
177+ output = str (result .get ("output" , "" )), raw = result )
178+ return AiCallResult (provider = provider .label , success = False ,
179+ output = result .get ("error" , "unknown" ), raw = result )
180+
181+ # Execute returns async job_id → poll
182+ job_id = result .get ("job_id" )
229183 if not isinstance (job_id , str ) or not job_id :
230- return AiCallResult .unavailable (provider .label , "No job_id" )
184+ return AiCallResult .unavailable (provider .label , "No job_id from gateway " )
231185
232186 deadline = time .time () + timeout + 60
233187 while time .time () < deadline :
234188 time .sleep (5 )
235189 req2 = urllib .request .Request (
236190 f"{ base_url } /v1/codex-audit/jobs/{ job_id } " , method = "GET" ,
237191 headers = {"Authorization" : f"Bearer { token } " , "Accept" : "application/json" ,
238- "User-Agent" : "strategy-lifecycle" },
192+ "User-Agent" : "quant- strategy-lifecycle" },
239193 )
240194 try :
241195 with urllib .request .urlopen (req2 , timeout = 30 ) as resp2 :
@@ -246,7 +200,7 @@ def _call_codex_vps(self, provider: AiProviderConfig, prompt: str, timeout: floa
246200 if status == "succeeded" :
247201 return AiCallResult (provider = provider .label , success = True ,
248202 output = str (job .get ("output" , "" )), raw = job )
249- elif status == "failed" :
203+ if status == "failed" :
250204 return AiCallResult (provider = provider .label , success = False ,
251205 output = job .get ("error" , "unknown" ), raw = job )
252206 return AiCallResult .unavailable (provider .label , "Timeout" )
@@ -271,8 +225,10 @@ def _fetch_oidc_token(audience: str = "quant-codex-audit") -> str:
271225 token_url = os .environ .get ("ACTIONS_ID_TOKEN_REQUEST_URL" , "" )
272226 token_bearer = os .environ .get ("ACTIONS_ID_TOKEN_REQUEST_TOKEN" , "" )
273227 if token_url and token_bearer :
274- req = urllib .request .Request (f"{ token_url } &audience={ audience } " ,
275- headers = {"Authorization" : f"Bearer { token_bearer } " })
228+ req = urllib .request .Request (
229+ f"{ token_url } &audience={ audience } " ,
230+ headers = {"Authorization" : f"Bearer { token_bearer } " },
231+ )
276232 with urllib .request .urlopen (req , timeout = 10 ) as resp :
277233 return str (json .loads (resp .read ().decode ("utf-8" )).get ("value" , "" ))
278234 return os .environ .get ("CODEX_AUDIT_SERVICE_TOKEN" , "" ).strip ()
0 commit comments