jarviscore-framework 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- examples/calculator_agent_example.py +77 -0
- examples/multi_agent_workflow.py +132 -0
- examples/research_agent_example.py +76 -0
- jarviscore/__init__.py +54 -0
- jarviscore/cli/__init__.py +7 -0
- jarviscore/cli/__main__.py +33 -0
- jarviscore/cli/check.py +404 -0
- jarviscore/cli/smoketest.py +371 -0
- jarviscore/config/__init__.py +7 -0
- jarviscore/config/settings.py +128 -0
- jarviscore/core/__init__.py +7 -0
- jarviscore/core/agent.py +163 -0
- jarviscore/core/mesh.py +463 -0
- jarviscore/core/profile.py +64 -0
- jarviscore/docs/API_REFERENCE.md +932 -0
- jarviscore/docs/CONFIGURATION.md +753 -0
- jarviscore/docs/GETTING_STARTED.md +600 -0
- jarviscore/docs/TROUBLESHOOTING.md +424 -0
- jarviscore/docs/USER_GUIDE.md +983 -0
- jarviscore/execution/__init__.py +94 -0
- jarviscore/execution/code_registry.py +298 -0
- jarviscore/execution/generator.py +268 -0
- jarviscore/execution/llm.py +430 -0
- jarviscore/execution/repair.py +283 -0
- jarviscore/execution/result_handler.py +332 -0
- jarviscore/execution/sandbox.py +555 -0
- jarviscore/execution/search.py +281 -0
- jarviscore/orchestration/__init__.py +18 -0
- jarviscore/orchestration/claimer.py +101 -0
- jarviscore/orchestration/dependency.py +143 -0
- jarviscore/orchestration/engine.py +292 -0
- jarviscore/orchestration/status.py +96 -0
- jarviscore/p2p/__init__.py +23 -0
- jarviscore/p2p/broadcaster.py +353 -0
- jarviscore/p2p/coordinator.py +364 -0
- jarviscore/p2p/keepalive.py +361 -0
- jarviscore/p2p/swim_manager.py +290 -0
- jarviscore/profiles/__init__.py +6 -0
- jarviscore/profiles/autoagent.py +264 -0
- jarviscore/profiles/customagent.py +137 -0
- jarviscore_framework-0.1.0.dist-info/METADATA +136 -0
- jarviscore_framework-0.1.0.dist-info/RECORD +55 -0
- jarviscore_framework-0.1.0.dist-info/WHEEL +5 -0
- jarviscore_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
- jarviscore_framework-0.1.0.dist-info/top_level.txt +3 -0
- tests/conftest.py +44 -0
- tests/test_agent.py +165 -0
- tests/test_autoagent.py +140 -0
- tests/test_autoagent_day4.py +186 -0
- tests/test_customagent.py +248 -0
- tests/test_integration.py +293 -0
- tests/test_llm_fallback.py +185 -0
- tests/test_mesh.py +356 -0
- tests/test_p2p_integration.py +375 -0
- tests/test_remote_sandbox.py +116 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified LLM Client - All providers in one file with zero-config setup
|
|
3
|
+
Supports: vLLM, Azure OpenAI, Gemini, Claude with automatic fallback
|
|
4
|
+
"""
|
|
5
|
+
import asyncio
|
|
6
|
+
import aiohttp
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, Dict, List, Any
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Try importing optional LLM SDKs
|
|
15
|
+
try:
|
|
16
|
+
import google.generativeai as genai
|
|
17
|
+
GEMINI_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
GEMINI_AVAILABLE = False
|
|
20
|
+
logger.debug("Gemini SDK not available (pip install google-generativeai)")
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from openai import AsyncAzureOpenAI
|
|
24
|
+
AZURE_AVAILABLE = True
|
|
25
|
+
except ImportError:
|
|
26
|
+
AZURE_AVAILABLE = False
|
|
27
|
+
logger.debug("Azure OpenAI SDK not available (pip install openai)")
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from anthropic import Anthropic
|
|
31
|
+
CLAUDE_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
CLAUDE_AVAILABLE = False
|
|
34
|
+
logger.debug("Claude SDK not available (pip install anthropic)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LLMProvider(Enum):
|
|
38
|
+
"""Available LLM providers."""
|
|
39
|
+
VLLM = "vllm"
|
|
40
|
+
AZURE = "azure"
|
|
41
|
+
GEMINI = "gemini"
|
|
42
|
+
CLAUDE = "claude"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Token pricing per 1M tokens (updated 2025)
|
|
46
|
+
TOKEN_PRICING = {
|
|
47
|
+
"gpt-4o": {"input": 3.00, "output": 15.00, "cached": 1.50},
|
|
48
|
+
"gpt-4": {"input": 30.00, "output": 60.00, "cached": 15.00},
|
|
49
|
+
"gpt-3.5-turbo": {"input": 0.50, "output": 1.50, "cached": 0.25},
|
|
50
|
+
"gemini-1.5-pro": {"input": 1.25, "output": 5.00, "cached": 0.31},
|
|
51
|
+
"gemini-1.5-flash": {"input": 0.10, "output": 0.30, "cached": 0.03},
|
|
52
|
+
"claude-opus-4": {"input": 15.00, "output": 75.00, "cached": 3.75},
|
|
53
|
+
"claude-sonnet-4": {"input": 3.00, "output": 15.00, "cached": 0.75},
|
|
54
|
+
"claude-haiku-3.5": {"input": 1.00, "output": 5.00, "cached": 0.25},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UnifiedLLMClient:
|
|
59
|
+
"""
|
|
60
|
+
Zero-config LLM client with automatic provider detection and fallback.
|
|
61
|
+
|
|
62
|
+
Philosophy: Developer writes NOTHING. Framework tries providers in order:
|
|
63
|
+
1. vLLM (local, free)
|
|
64
|
+
2. Azure OpenAI (if configured)
|
|
65
|
+
3. Gemini (if configured)
|
|
66
|
+
4. Claude (if configured)
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
client = UnifiedLLMClient()
|
|
70
|
+
response = await client.generate("Write Python code to add 2+2")
|
|
71
|
+
# Framework automatically picks best available provider
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, config: Optional[Dict] = None):
|
|
75
|
+
"""
|
|
76
|
+
Initialize LLM client with zero-config defaults.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
config: Optional config dict. If None, auto-detects from environment via Pydantic.
|
|
80
|
+
"""
|
|
81
|
+
# Load from Pydantic settings first
|
|
82
|
+
from jarviscore.config import settings
|
|
83
|
+
|
|
84
|
+
# Merge: Pydantic settings as base, config dict as override
|
|
85
|
+
self.config = settings.model_dump()
|
|
86
|
+
if config:
|
|
87
|
+
self.config.update(config)
|
|
88
|
+
|
|
89
|
+
# Provider clients
|
|
90
|
+
self.vllm_endpoint = None
|
|
91
|
+
self.azure_client = None
|
|
92
|
+
self.gemini_client = None
|
|
93
|
+
self.claude_client = None
|
|
94
|
+
|
|
95
|
+
# Provider order (tries in this sequence)
|
|
96
|
+
self.provider_order = []
|
|
97
|
+
|
|
98
|
+
# Initialize all available providers
|
|
99
|
+
self._setup_providers()
|
|
100
|
+
|
|
101
|
+
logger.info(f"LLM Client initialized with providers: {[p.value for p in self.provider_order]}")
|
|
102
|
+
|
|
103
|
+
def _setup_providers(self):
|
|
104
|
+
"""Auto-detect and setup available LLM providers."""
|
|
105
|
+
|
|
106
|
+
# 1. Try Claude first (primary provider)
|
|
107
|
+
if CLAUDE_AVAILABLE:
|
|
108
|
+
claude_key = self.config.get('claude_api_key') or self.config.get('anthropic_api_key')
|
|
109
|
+
claude_endpoint = self.config.get('claude_endpoint')
|
|
110
|
+
if claude_key:
|
|
111
|
+
try:
|
|
112
|
+
# Support custom Claude endpoint (e.g., Azure-hosted Claude)
|
|
113
|
+
if claude_endpoint:
|
|
114
|
+
self.claude_client = Anthropic(
|
|
115
|
+
api_key=claude_key,
|
|
116
|
+
base_url=claude_endpoint
|
|
117
|
+
)
|
|
118
|
+
logger.info(f"✓ Claude provider available with custom endpoint: {claude_endpoint}")
|
|
119
|
+
else:
|
|
120
|
+
self.claude_client = Anthropic(api_key=claude_key)
|
|
121
|
+
logger.info("✓ Claude provider available")
|
|
122
|
+
self.provider_order.append(LLMProvider.CLAUDE)
|
|
123
|
+
except Exception as e:
|
|
124
|
+
logger.warning(f"Failed to setup Claude: {e}")
|
|
125
|
+
|
|
126
|
+
# 2. Try vLLM (local, free)
|
|
127
|
+
vllm_endpoint = self.config.get('llm_endpoint') or self.config.get('vllm_endpoint')
|
|
128
|
+
if vllm_endpoint:
|
|
129
|
+
self.vllm_endpoint = vllm_endpoint.rstrip('/')
|
|
130
|
+
self.provider_order.append(LLMProvider.VLLM)
|
|
131
|
+
logger.info(f"✓ vLLM provider available: {self.vllm_endpoint}")
|
|
132
|
+
|
|
133
|
+
# 3. Try Azure OpenAI
|
|
134
|
+
if AZURE_AVAILABLE:
|
|
135
|
+
azure_key = self.config.get('azure_api_key') or self.config.get('azure_openai_key')
|
|
136
|
+
azure_endpoint = self.config.get('azure_endpoint') or self.config.get('azure_openai_endpoint')
|
|
137
|
+
|
|
138
|
+
if azure_key and azure_endpoint:
|
|
139
|
+
try:
|
|
140
|
+
self.azure_client = AsyncAzureOpenAI(
|
|
141
|
+
api_key=azure_key,
|
|
142
|
+
azure_endpoint=azure_endpoint,
|
|
143
|
+
api_version=self.config.get('azure_api_version', '2024-02-15-preview'),
|
|
144
|
+
timeout=self.config.get('llm_timeout', 120)
|
|
145
|
+
)
|
|
146
|
+
self.provider_order.append(LLMProvider.AZURE)
|
|
147
|
+
logger.info("✓ Azure OpenAI provider available")
|
|
148
|
+
except Exception as e:
|
|
149
|
+
logger.warning(f"Failed to setup Azure OpenAI: {e}")
|
|
150
|
+
|
|
151
|
+
# 4. Try Gemini
|
|
152
|
+
if GEMINI_AVAILABLE:
|
|
153
|
+
gemini_key = self.config.get('gemini_api_key')
|
|
154
|
+
if gemini_key:
|
|
155
|
+
try:
|
|
156
|
+
genai.configure(api_key=gemini_key)
|
|
157
|
+
model_name = self.config.get('gemini_model', 'gemini-1.5-flash')
|
|
158
|
+
self.gemini_client = genai.GenerativeModel(model_name)
|
|
159
|
+
self.provider_order.append(LLMProvider.GEMINI)
|
|
160
|
+
logger.info(f"✓ Gemini provider available: {model_name}")
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.warning(f"Failed to setup Gemini: {e}")
|
|
163
|
+
|
|
164
|
+
if not self.provider_order:
|
|
165
|
+
logger.warning(
|
|
166
|
+
"⚠️ No LLM providers configured! Set at least one:\n"
|
|
167
|
+
" - llm_endpoint for vLLM\n"
|
|
168
|
+
" - azure_api_key + azure_endpoint for Azure\n"
|
|
169
|
+
" - gemini_api_key for Gemini\n"
|
|
170
|
+
" - claude_api_key for Claude"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def generate(
|
|
174
|
+
self,
|
|
175
|
+
prompt: str,
|
|
176
|
+
messages: Optional[List[Dict]] = None,
|
|
177
|
+
temperature: float = 0.7,
|
|
178
|
+
max_tokens: int = 4000,
|
|
179
|
+
**kwargs
|
|
180
|
+
) -> Dict[str, Any]:
|
|
181
|
+
"""
|
|
182
|
+
Generate completion with automatic provider fallback.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
prompt: Text prompt (if messages not provided)
|
|
186
|
+
messages: OpenAI-style message list [{"role": "user", "content": "..."}]
|
|
187
|
+
temperature: Sampling temperature (0-1)
|
|
188
|
+
max_tokens: Maximum tokens to generate
|
|
189
|
+
**kwargs: Additional provider-specific options
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
{
|
|
193
|
+
"content": "generated text",
|
|
194
|
+
"provider": "vllm|azure|gemini|claude",
|
|
195
|
+
"tokens": {"input": 100, "output": 200, "total": 300},
|
|
196
|
+
"cost_usd": 0.015,
|
|
197
|
+
"model": "gpt-4o"
|
|
198
|
+
}
|
|
199
|
+
"""
|
|
200
|
+
# Convert prompt to messages if needed
|
|
201
|
+
if not messages:
|
|
202
|
+
messages = [{"role": "user", "content": prompt}]
|
|
203
|
+
|
|
204
|
+
# Try each provider in order
|
|
205
|
+
last_error = None
|
|
206
|
+
for provider in self.provider_order:
|
|
207
|
+
try:
|
|
208
|
+
logger.debug(f"Trying provider: {provider.value}")
|
|
209
|
+
|
|
210
|
+
if provider == LLMProvider.VLLM:
|
|
211
|
+
return await self._call_vllm(messages, temperature, max_tokens, **kwargs)
|
|
212
|
+
elif provider == LLMProvider.AZURE:
|
|
213
|
+
return await self._call_azure(messages, temperature, max_tokens, **kwargs)
|
|
214
|
+
elif provider == LLMProvider.GEMINI:
|
|
215
|
+
return await self._call_gemini(messages, temperature, max_tokens, **kwargs)
|
|
216
|
+
elif provider == LLMProvider.CLAUDE:
|
|
217
|
+
return await self._call_claude(messages, temperature, max_tokens, **kwargs)
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
last_error = e
|
|
221
|
+
logger.warning(f"Provider {provider.value} failed: {e}")
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
# All providers failed
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
f"All LLM providers failed. Last error: {last_error}\n"
|
|
227
|
+
f"Tried: {[p.value for p in self.provider_order]}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
async def _call_vllm(self, messages: List[Dict], temperature: float, max_tokens: int, **kwargs) -> Dict:
|
|
231
|
+
"""Call vLLM endpoint."""
|
|
232
|
+
if not self.vllm_endpoint:
|
|
233
|
+
raise RuntimeError("vLLM endpoint not configured")
|
|
234
|
+
|
|
235
|
+
endpoint = self.vllm_endpoint
|
|
236
|
+
if not endpoint.endswith('/v1/chat/completions'):
|
|
237
|
+
endpoint = f"{endpoint}/v1/chat/completions"
|
|
238
|
+
|
|
239
|
+
payload = {
|
|
240
|
+
"model": self.config.get('llm_model', 'default'),
|
|
241
|
+
"messages": messages,
|
|
242
|
+
"temperature": temperature,
|
|
243
|
+
"max_tokens": max_tokens,
|
|
244
|
+
"stream": False
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
timeout = aiohttp.ClientTimeout(total=self.config.get('llm_timeout', 120))
|
|
248
|
+
start_time = time.time()
|
|
249
|
+
|
|
250
|
+
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
251
|
+
async with session.post(endpoint, json=payload) as response:
|
|
252
|
+
if response.status != 200:
|
|
253
|
+
error = await response.text()
|
|
254
|
+
raise RuntimeError(f"vLLM error {response.status}: {error}")
|
|
255
|
+
|
|
256
|
+
data = await response.json()
|
|
257
|
+
duration = time.time() - start_time
|
|
258
|
+
|
|
259
|
+
content = data['choices'][0]['message']['content']
|
|
260
|
+
usage = data.get('usage', {})
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"content": content,
|
|
264
|
+
"provider": "vllm",
|
|
265
|
+
"tokens": {
|
|
266
|
+
"input": usage.get('prompt_tokens', 0),
|
|
267
|
+
"output": usage.get('completion_tokens', 0),
|
|
268
|
+
"total": usage.get('total_tokens', 0)
|
|
269
|
+
},
|
|
270
|
+
"cost_usd": 0.0, # vLLM is free (local)
|
|
271
|
+
"model": payload['model'],
|
|
272
|
+
"duration_seconds": duration
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async def _call_azure(self, messages: List[Dict], temperature: float, max_tokens: int, **kwargs) -> Dict:
|
|
276
|
+
"""Call Azure OpenAI."""
|
|
277
|
+
if not self.azure_client:
|
|
278
|
+
raise RuntimeError("Azure client not initialized")
|
|
279
|
+
|
|
280
|
+
deployment = self.config.get('azure_deployment', 'gpt-4o')
|
|
281
|
+
start_time = time.time()
|
|
282
|
+
|
|
283
|
+
response = await self.azure_client.chat.completions.create(
|
|
284
|
+
model=deployment,
|
|
285
|
+
messages=messages,
|
|
286
|
+
temperature=temperature,
|
|
287
|
+
max_tokens=max_tokens
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
duration = time.time() - start_time
|
|
291
|
+
content = response.choices[0].message.content
|
|
292
|
+
usage = response.usage
|
|
293
|
+
|
|
294
|
+
# Calculate cost
|
|
295
|
+
pricing = TOKEN_PRICING.get(deployment, {"input": 3.0, "output": 15.0})
|
|
296
|
+
cost = (usage.prompt_tokens * pricing['input'] +
|
|
297
|
+
usage.completion_tokens * pricing['output']) / 1_000_000
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
"content": content,
|
|
301
|
+
"provider": "azure",
|
|
302
|
+
"tokens": {
|
|
303
|
+
"input": usage.prompt_tokens,
|
|
304
|
+
"output": usage.completion_tokens,
|
|
305
|
+
"total": usage.total_tokens
|
|
306
|
+
},
|
|
307
|
+
"cost_usd": cost,
|
|
308
|
+
"model": deployment,
|
|
309
|
+
"duration_seconds": duration
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async def _call_gemini(self, messages: List[Dict], temperature: float, max_tokens: int, **kwargs) -> Dict:
|
|
313
|
+
"""Call Google Gemini."""
|
|
314
|
+
if not self.gemini_client:
|
|
315
|
+
raise RuntimeError("Gemini client not initialized")
|
|
316
|
+
|
|
317
|
+
# Convert messages to Gemini format
|
|
318
|
+
prompt = self._messages_to_prompt(messages)
|
|
319
|
+
|
|
320
|
+
start_time = time.time()
|
|
321
|
+
response = await asyncio.to_thread(
|
|
322
|
+
self.gemini_client.generate_content,
|
|
323
|
+
prompt,
|
|
324
|
+
generation_config={
|
|
325
|
+
"temperature": temperature,
|
|
326
|
+
"max_output_tokens": max_tokens
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
duration = time.time() - start_time
|
|
330
|
+
|
|
331
|
+
content = response.text
|
|
332
|
+
|
|
333
|
+
# Estimate tokens (Gemini doesn't always return usage)
|
|
334
|
+
input_tokens = len(prompt.split()) * 1.3 # rough estimate
|
|
335
|
+
output_tokens = len(content.split()) * 1.3
|
|
336
|
+
|
|
337
|
+
model_name = self.config.get('gemini_model', 'gemini-1.5-flash')
|
|
338
|
+
pricing = TOKEN_PRICING.get(model_name, {"input": 0.10, "output": 0.30})
|
|
339
|
+
cost = (input_tokens * pricing['input'] + output_tokens * pricing['output']) / 1_000_000
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
"content": content,
|
|
343
|
+
"provider": "gemini",
|
|
344
|
+
"tokens": {
|
|
345
|
+
"input": int(input_tokens),
|
|
346
|
+
"output": int(output_tokens),
|
|
347
|
+
"total": int(input_tokens + output_tokens)
|
|
348
|
+
},
|
|
349
|
+
"cost_usd": cost,
|
|
350
|
+
"model": model_name,
|
|
351
|
+
"duration_seconds": duration
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async def _call_claude(self, messages: List[Dict], temperature: float, max_tokens: int, **kwargs) -> Dict:
|
|
355
|
+
"""Call Anthropic Claude."""
|
|
356
|
+
if not self.claude_client:
|
|
357
|
+
raise RuntimeError("Claude client not initialized")
|
|
358
|
+
|
|
359
|
+
# Separate system message from conversation
|
|
360
|
+
system_msg = None
|
|
361
|
+
conv_messages = []
|
|
362
|
+
for msg in messages:
|
|
363
|
+
if msg['role'] == 'system':
|
|
364
|
+
system_msg = msg['content']
|
|
365
|
+
else:
|
|
366
|
+
conv_messages.append(msg)
|
|
367
|
+
|
|
368
|
+
model = self.config.get('claude_model', 'claude-sonnet-4')
|
|
369
|
+
start_time = time.time()
|
|
370
|
+
|
|
371
|
+
# Prepare request kwargs
|
|
372
|
+
request_kwargs = {
|
|
373
|
+
"model": model,
|
|
374
|
+
"max_tokens": max_tokens,
|
|
375
|
+
"temperature": temperature,
|
|
376
|
+
"messages": conv_messages
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Only add system if it exists (Claude API requires it to be string or not present)
|
|
380
|
+
if system_msg:
|
|
381
|
+
request_kwargs["system"] = system_msg
|
|
382
|
+
|
|
383
|
+
response = await asyncio.to_thread(
|
|
384
|
+
self.claude_client.messages.create,
|
|
385
|
+
**request_kwargs
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
duration = time.time() - start_time
|
|
389
|
+
content = response.content[0].text
|
|
390
|
+
|
|
391
|
+
# Calculate cost
|
|
392
|
+
pricing = TOKEN_PRICING.get(model, {"input": 3.0, "output": 15.0})
|
|
393
|
+
cost = (response.usage.input_tokens * pricing['input'] +
|
|
394
|
+
response.usage.output_tokens * pricing['output']) / 1_000_000
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
"content": content,
|
|
398
|
+
"provider": "claude",
|
|
399
|
+
"tokens": {
|
|
400
|
+
"input": response.usage.input_tokens,
|
|
401
|
+
"output": response.usage.output_tokens,
|
|
402
|
+
"total": response.usage.input_tokens + response.usage.output_tokens
|
|
403
|
+
},
|
|
404
|
+
"cost_usd": cost,
|
|
405
|
+
"model": model,
|
|
406
|
+
"duration_seconds": duration
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
def _messages_to_prompt(self, messages: List[Dict]) -> str:
|
|
410
|
+
"""Convert OpenAI message format to plain text prompt."""
|
|
411
|
+
parts = []
|
|
412
|
+
for msg in messages:
|
|
413
|
+
role = msg['role']
|
|
414
|
+
content = msg['content']
|
|
415
|
+
if role == 'system':
|
|
416
|
+
parts.append(f"System: {content}")
|
|
417
|
+
elif role == 'user':
|
|
418
|
+
parts.append(f"User: {content}")
|
|
419
|
+
elif role == 'assistant':
|
|
420
|
+
parts.append(f"Assistant: {content}")
|
|
421
|
+
return "\n\n".join(parts)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def create_llm_client(config: Optional[Dict] = None) -> UnifiedLLMClient:
|
|
425
|
+
"""
|
|
426
|
+
Factory function to create LLM client.
|
|
427
|
+
|
|
428
|
+
Zero-config: Just call this and it auto-detects providers.
|
|
429
|
+
"""
|
|
430
|
+
return UnifiedLLMClient(config)
|