Skip to content

Commit f2805ad

Browse files
Pigbibiclaude
andcommitted
refactor: AiServiceClient → thin AiGateway client
All AI calls now route through AiGateway (VPS): - analyze task → LlmAdapter (sync, no repo needed) - execute task → CodexAdapter (async, repo clone) No API keys needed in this repo. Only CODEX_AUDIT_SERVICE_URL. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 97f197d commit f2805ad

1 file changed

Lines changed: 67 additions & 111 deletions

File tree

Lines changed: 67 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
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

1921
from __future__ import annotations
2022

2123
import enum
2224
import json
2325
import os
24-
import re
2526
import time
2627
import urllib.error
2728
import urllib.request
28-
from collections.abc import Mapping, Sequence
29+
from collections.abc import Sequence
2930
from dataclasses import dataclass
3031
from typing import Any
3132

@@ -45,44 +46,30 @@ class AiPattern(str, enum.Enum):
4546
class 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

132107
class 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

Comments
 (0)