mmrech commited on
Commit
cb0390a
Β·
verified Β·
1 Parent(s): 026774a

Add OpenRouter support for Claude Opus 4.5

Browse files
agent/src/openrouter_client.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OpenRouter Client for TSA Agent
3
+
4
+ Provides Claude access via OpenRouter API when direct Anthropic API is not available.
5
+ Supports Claude Opus 4.5 and other models through OpenRouter's unified API.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import os
11
+ from typing import AsyncIterator, Optional
12
+ import httpx
13
+
14
+ # OpenRouter model mappings
15
+ OPENROUTER_MODELS = {
16
+ "claude-opus-4.5": "anthropic/claude-opus-4",
17
+ "claude-opus-4": "anthropic/claude-opus-4",
18
+ "claude-sonnet-4": "anthropic/claude-sonnet-4",
19
+ "claude-3.5-sonnet": "anthropic/claude-3.5-sonnet",
20
+ "claude-3-opus": "anthropic/claude-3-opus",
21
+ "claude-3-sonnet": "anthropic/claude-3-sonnet",
22
+ "claude-3-haiku": "anthropic/claude-3-haiku",
23
+ }
24
+
25
+
26
+ class OpenRouterClient:
27
+ """
28
+ OpenRouter API client for accessing Claude models.
29
+
30
+ This client provides a compatible interface for the TSA Agent
31
+ when using OpenRouter instead of direct Anthropic API access.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: Optional[str] = None,
37
+ model: str = "claude-opus-4.5",
38
+ base_url: str = "https://openrouter.ai/api/v1",
39
+ ):
40
+ """
41
+ Initialize the OpenRouter client.
42
+
43
+ Args:
44
+ api_key: OpenRouter API key (or set OPENROUTER_API_KEY env var)
45
+ model: Model to use (will be mapped to OpenRouter format)
46
+ base_url: OpenRouter API base URL
47
+ """
48
+ self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY")
49
+ if not self.api_key:
50
+ raise ValueError("OpenRouter API key required. Set OPENROUTER_API_KEY or pass api_key.")
51
+
52
+ # Map model name to OpenRouter format
53
+ self.model = OPENROUTER_MODELS.get(model, f"anthropic/{model}")
54
+ self.base_url = base_url
55
+ self.conversation_history: list[dict] = []
56
+ self.system_prompt: Optional[str] = None
57
+
58
+ def set_system_prompt(self, prompt: str) -> None:
59
+ """Set the system prompt for conversations."""
60
+ self.system_prompt = prompt
61
+
62
+ def clear_history(self) -> None:
63
+ """Clear conversation history."""
64
+ self.conversation_history = []
65
+
66
+ async def chat(
67
+ self,
68
+ message: str,
69
+ max_tokens: int = 4096,
70
+ temperature: float = 0.7,
71
+ ) -> str:
72
+ """
73
+ Send a message and get a response.
74
+
75
+ Args:
76
+ message: User message
77
+ max_tokens: Maximum tokens in response
78
+ temperature: Sampling temperature
79
+
80
+ Returns:
81
+ Assistant's response text
82
+ """
83
+ # Add user message to history
84
+ self.conversation_history.append({
85
+ "role": "user",
86
+ "content": message
87
+ })
88
+
89
+ # Build messages array
90
+ messages = []
91
+ if self.system_prompt:
92
+ messages.append({
93
+ "role": "system",
94
+ "content": self.system_prompt
95
+ })
96
+ messages.extend(self.conversation_history)
97
+
98
+ # Make API request
99
+ async with httpx.AsyncClient(timeout=120.0) as client:
100
+ response = await client.post(
101
+ f"{self.base_url}/chat/completions",
102
+ headers={
103
+ "Authorization": f"Bearer {self.api_key}",
104
+ "Content-Type": "application/json",
105
+ "HTTP-Referer": "https://tsa-shiny-agent.app",
106
+ "X-Title": "TSA Shiny Agent",
107
+ },
108
+ json={
109
+ "model": self.model,
110
+ "messages": messages,
111
+ "max_tokens": max_tokens,
112
+ "temperature": temperature,
113
+ }
114
+ )
115
+
116
+ if response.status_code != 200:
117
+ error_text = response.text
118
+ raise Exception(f"OpenRouter API error {response.status_code}: {error_text}")
119
+
120
+ result = response.json()
121
+
122
+ # Extract assistant message
123
+ assistant_message = result["choices"][0]["message"]["content"]
124
+
125
+ # Add to history
126
+ self.conversation_history.append({
127
+ "role": "assistant",
128
+ "content": assistant_message
129
+ })
130
+
131
+ return assistant_message
132
+
133
+ async def chat_stream(
134
+ self,
135
+ message: str,
136
+ max_tokens: int = 4096,
137
+ temperature: float = 0.7,
138
+ ) -> AsyncIterator[str]:
139
+ """
140
+ Send a message and stream the response.
141
+
142
+ Args:
143
+ message: User message
144
+ max_tokens: Maximum tokens in response
145
+ temperature: Sampling temperature
146
+
147
+ Yields:
148
+ Response text chunks
149
+ """
150
+ # Add user message to history
151
+ self.conversation_history.append({
152
+ "role": "user",
153
+ "content": message
154
+ })
155
+
156
+ # Build messages array
157
+ messages = []
158
+ if self.system_prompt:
159
+ messages.append({
160
+ "role": "system",
161
+ "content": self.system_prompt
162
+ })
163
+ messages.extend(self.conversation_history)
164
+
165
+ full_response = []
166
+
167
+ async with httpx.AsyncClient(timeout=120.0) as client:
168
+ async with client.stream(
169
+ "POST",
170
+ f"{self.base_url}/chat/completions",
171
+ headers={
172
+ "Authorization": f"Bearer {self.api_key}",
173
+ "Content-Type": "application/json",
174
+ "HTTP-Referer": "https://tsa-shiny-agent.app",
175
+ "X-Title": "TSA Shiny Agent",
176
+ },
177
+ json={
178
+ "model": self.model,
179
+ "messages": messages,
180
+ "max_tokens": max_tokens,
181
+ "temperature": temperature,
182
+ "stream": True,
183
+ }
184
+ ) as response:
185
+ if response.status_code != 200:
186
+ error_text = await response.aread()
187
+ raise Exception(f"OpenRouter API error {response.status_code}: {error_text}")
188
+
189
+ async for line in response.aiter_lines():
190
+ if line.startswith("data: "):
191
+ data = line[6:]
192
+ if data == "[DONE]":
193
+ break
194
+ try:
195
+ chunk = json.loads(data)
196
+ if "choices" in chunk and len(chunk["choices"]) > 0:
197
+ delta = chunk["choices"][0].get("delta", {})
198
+ content = delta.get("content", "")
199
+ if content:
200
+ full_response.append(content)
201
+ yield content
202
+ except json.JSONDecodeError:
203
+ continue
204
+
205
+ # Add complete response to history
206
+ self.conversation_history.append({
207
+ "role": "assistant",
208
+ "content": "".join(full_response)
209
+ })
210
+
211
+
212
+ # TSA-specific system prompt
213
+ TSA_OPENROUTER_SYSTEM_PROMPT = """You are an expert Trial Sequential Analysis (TSA) assistant specializing in cumulative meta-analysis with sequential monitoring boundaries.
214
+
215
+ ## Your Expertise
216
+
217
+ You have deep knowledge of:
218
+
219
+ ### Meta-Analysis Fundamentals
220
+ - **Effect Models**: Fixed effect assumes one true effect; Random Effects (DerSimonian-Laird, Sidik-Jonkman) accounts for heterogeneity
221
+ - **Effect Measures**: Odds Ratio (OR), Relative Risk (RR), Risk Difference (RD), Mean Difference (MD)
222
+ - **Heterogeneity**: Q-statistic, IΒ² (low <25%, moderate 25-75%, high >75%), τ² (tau-squared)
223
+
224
+ ### Trial Sequential Analysis
225
+ - **Optimal Information Size (OIS)**: The meta-analysis equivalent of sample size calculation
226
+ - **Alpha Spending Functions**:
227
+ - O'Brien-Fleming: Conservative early, preserves power for final analysis
228
+ - Pocock: Uniform spending, allows earlier stopping
229
+ - **Z-curve Interpretation**: Crossing boundaries indicates sufficient evidence
230
+
231
+ ### Result Interpretation
232
+ - Confidence intervals and their clinical significance
233
+ - Publication bias assessment using funnel plots
234
+ - Cumulative meta-analysis trends
235
+
236
+ ## Communication Style
237
+
238
+ - Use clear, accessible language
239
+ - Provide context for statistical concepts
240
+ - Give actionable recommendations
241
+ - Format results with markdown for readability
242
+ - When explaining statistics, use analogies when helpful
243
+
244
+ ## Important Notes
245
+
246
+ - Always validate input data before calculations
247
+ - Warn users about limitations (e.g., small sample sizes, high heterogeneity)
248
+ - Recommend appropriate analysis settings based on data characteristics
249
+ - If uncertain, suggest consulting a biostatistician
250
+ """
251
+
252
+
253
+ class TSAOpenRouterAgent:
254
+ """
255
+ TSA Agent using OpenRouter for Claude access.
256
+
257
+ This is an alternative to the main TSAAgent when using OpenRouter
258
+ instead of direct Anthropic API access.
259
+ """
260
+
261
+ def __init__(
262
+ self,
263
+ api_key: Optional[str] = None,
264
+ model: str = "claude-opus-4.5",
265
+ tsa_api_url: str = "http://localhost:8000",
266
+ ):
267
+ """
268
+ Initialize the TSA OpenRouter Agent.
269
+
270
+ Args:
271
+ api_key: OpenRouter API key
272
+ model: Claude model to use
273
+ tsa_api_url: URL of the TSA Plumber API
274
+ """
275
+ self.client = OpenRouterClient(api_key=api_key, model=model)
276
+ self.client.set_system_prompt(TSA_OPENROUTER_SYSTEM_PROMPT)
277
+ self.tsa_api_url = tsa_api_url
278
+ self.http_client = httpx.AsyncClient(base_url=tsa_api_url, timeout=30.0)
279
+
280
+ async def chat(self, message: str) -> str:
281
+ """
282
+ Send a message and get a response.
283
+
284
+ The agent will use the TSA Plumber API for calculations
285
+ and Claude via OpenRouter for natural language understanding.
286
+ """
287
+ # For now, just use the OpenRouter client directly
288
+ # In a full implementation, we'd parse the message,
289
+ # determine if TSA API calls are needed, make them,
290
+ # and incorporate results into the response
291
+ return await self.client.chat(message)
292
+
293
+ def new_session(self) -> None:
294
+ """Start a new conversation session."""
295
+ self.client.clear_history()
296
+
297
+ async def close(self) -> None:
298
+ """Close the agent and release resources."""
299
+ await self.http_client.aclose()
300
+
301
+
302
+ async def test_openrouter():
303
+ """Test the OpenRouter client."""
304
+ client = OpenRouterClient(model="claude-opus-4.5")
305
+ client.set_system_prompt("You are a helpful assistant. Be concise.")
306
+
307
+ response = await client.chat("What is 2+2? Reply in one word.")
308
+ print(f"Response: {response}")
309
+
310
+
311
+ if __name__ == "__main__":
312
+ asyncio.run(test_openrouter())
agent/src/tsa_agent.py CHANGED
@@ -8,22 +8,40 @@ This agent specializes in:
8
  - Interpreting results and providing guidance
9
 
10
  The agent communicates with the R Plumber API which bridges to the Java TSA.jar
 
 
 
 
11
  """
12
 
13
  import asyncio
14
  import os
15
  from pathlib import Path
16
- from typing import Any
17
-
18
- from claude_agent_sdk import (
19
- ClaudeSDKClient,
20
- ClaudeAgentOptions,
21
- tool,
22
- create_sdk_mcp_server,
23
- AssistantMessage,
24
- TextBlock,
25
- ResultMessage,
26
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  # Import our custom tools
29
  from tools.meta_analysis_tool import (
@@ -262,7 +280,15 @@ class TSAAgent:
262
 
263
  async def interactive_mode():
264
  """Run the agent in interactive mode."""
265
- agent = TSAAgent()
 
 
 
 
 
 
 
 
266
 
267
  print("\n" + "="*60)
268
  print(" TSA AI Assistant - Trial Sequential Analysis Expert")
@@ -276,7 +302,8 @@ async def interactive_mode():
276
  print("Type 'new' to start a fresh session.")
277
  print("="*60 + "\n")
278
 
279
- await agent.start_session()
 
280
 
281
  try:
282
  while True:
@@ -289,8 +316,11 @@ async def interactive_mode():
289
  break
290
 
291
  if user_input.lower() == 'new':
292
- await agent.end_session()
293
- await agent.start_session()
 
 
 
294
  print("\nπŸ”„ Started new session.")
295
  continue
296
 
@@ -299,7 +329,10 @@ async def interactive_mode():
299
  print(response)
300
 
301
  finally:
302
- await agent.end_session()
 
 
 
303
 
304
  print("\nGoodbye! πŸ‘‹")
305
 
@@ -342,8 +375,16 @@ async def server_mode(host: str = "127.0.0.1", port: int = 8080):
342
  from aiohttp import web
343
  import json
344
 
345
- agent = TSAAgent()
346
- await agent.start_session()
 
 
 
 
 
 
 
 
347
 
348
  async def handle_chat(request: web.Request) -> web.Response:
349
  """Handle chat messages."""
@@ -370,14 +411,18 @@ async def server_mode(host: str = "127.0.0.1", port: int = 8080):
370
  """Health check endpoint."""
371
  return web.json_response({
372
  "status": "healthy",
373
- "session_active": agent.session_active,
 
374
  })
375
 
376
  async def handle_new_session(request: web.Request) -> web.Response:
377
  """Start a new session."""
378
  try:
379
- await agent.end_session()
380
- await agent.start_session()
 
 
 
381
  return web.json_response({"status": "new session started"})
382
  except Exception as e:
383
  return web.json_response(
@@ -431,7 +476,10 @@ async def server_mode(host: str = "127.0.0.1", port: int = 8080):
431
  except asyncio.CancelledError:
432
  pass
433
  finally:
434
- await agent.end_session()
 
 
 
435
  await runner.cleanup()
436
 
437
 
 
8
  - Interpreting results and providing guidance
9
 
10
  The agent communicates with the R Plumber API which bridges to the Java TSA.jar
11
+
12
+ Supports both:
13
+ - Direct Anthropic API (ANTHROPIC_API_KEY)
14
+ - OpenRouter API (OPENROUTER_API_KEY) for Claude access
15
  """
16
 
17
  import asyncio
18
  import os
19
  from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ # Check which API is available
23
+ USE_OPENROUTER = bool(os.environ.get("OPENROUTER_API_KEY")) and not bool(os.environ.get("ANTHROPIC_API_KEY"))
24
+
25
+ if not USE_OPENROUTER:
26
+ try:
27
+ from claude_agent_sdk import (
28
+ ClaudeSDKClient,
29
+ ClaudeAgentOptions,
30
+ tool,
31
+ create_sdk_mcp_server,
32
+ AssistantMessage,
33
+ TextBlock,
34
+ ResultMessage,
35
+ )
36
+ SDK_AVAILABLE = True
37
+ except ImportError:
38
+ SDK_AVAILABLE = False
39
+ USE_OPENROUTER = True # Fall back to OpenRouter if SDK not available
40
+ else:
41
+ SDK_AVAILABLE = False
42
+
43
+ # Import OpenRouter client
44
+ from openrouter_client import OpenRouterClient, TSAOpenRouterAgent, TSA_OPENROUTER_SYSTEM_PROMPT
45
 
46
  # Import our custom tools
47
  from tools.meta_analysis_tool import (
 
280
 
281
  async def interactive_mode():
282
  """Run the agent in interactive mode."""
283
+ # Choose agent based on available API
284
+ if USE_OPENROUTER:
285
+ print("\nπŸ”„ Using OpenRouter API (Claude Opus 4.5)")
286
+ agent = TSAOpenRouterAgent(model="claude-opus-4.5")
287
+ is_openrouter = True
288
+ else:
289
+ print("\nπŸ”„ Using Anthropic API")
290
+ agent = TSAAgent()
291
+ is_openrouter = False
292
 
293
  print("\n" + "="*60)
294
  print(" TSA AI Assistant - Trial Sequential Analysis Expert")
 
302
  print("Type 'new' to start a fresh session.")
303
  print("="*60 + "\n")
304
 
305
+ if not is_openrouter:
306
+ await agent.start_session()
307
 
308
  try:
309
  while True:
 
316
  break
317
 
318
  if user_input.lower() == 'new':
319
+ if is_openrouter:
320
+ agent.new_session()
321
+ else:
322
+ await agent.end_session()
323
+ await agent.start_session()
324
  print("\nπŸ”„ Started new session.")
325
  continue
326
 
 
329
  print(response)
330
 
331
  finally:
332
+ if is_openrouter:
333
+ await agent.close()
334
+ else:
335
+ await agent.end_session()
336
 
337
  print("\nGoodbye! πŸ‘‹")
338
 
 
375
  from aiohttp import web
376
  import json
377
 
378
+ # Choose agent based on available API
379
+ if USE_OPENROUTER:
380
+ print("πŸ”„ Using OpenRouter API (Claude Opus 4.5)")
381
+ agent = TSAOpenRouterAgent(model="claude-opus-4.5")
382
+ is_openrouter = True
383
+ else:
384
+ print("πŸ”„ Using Anthropic API")
385
+ agent = TSAAgent()
386
+ is_openrouter = False
387
+ await agent.start_session()
388
 
389
  async def handle_chat(request: web.Request) -> web.Response:
390
  """Handle chat messages."""
 
411
  """Health check endpoint."""
412
  return web.json_response({
413
  "status": "healthy",
414
+ "backend": "openrouter" if is_openrouter else "anthropic",
415
+ "session_active": True if is_openrouter else agent.session_active,
416
  })
417
 
418
  async def handle_new_session(request: web.Request) -> web.Response:
419
  """Start a new session."""
420
  try:
421
+ if is_openrouter:
422
+ agent.new_session()
423
+ else:
424
+ await agent.end_session()
425
+ await agent.start_session()
426
  return web.json_response({"status": "new session started"})
427
  except Exception as e:
428
  return web.json_response(
 
476
  except asyncio.CancelledError:
477
  pass
478
  finally:
479
+ if is_openrouter:
480
+ await agent.close()
481
+ else:
482
+ await agent.end_session()
483
  await runner.cleanup()
484
 
485