-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathllm_client.py
More file actions
530 lines (459 loc) · 16.2 KB
/
Copy pathllm_client.py
File metadata and controls
530 lines (459 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
"""
LLM Client Abstraction
Unified interface for interacting with language models.
Supports multiple backends and provides consistent error handling.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from math import ceil
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class LLMResponse:
"""Response from LLM."""
text: str
model: str
tokens_used: int = 0
cost: float = 0.0
latency: float = 0.0
raw_response: Optional[Dict] = None
@dataclass(frozen=True)
class UsageRecord:
"""Provider-neutral usage emitted after every successful model call."""
provider: str
model: str
input_tokens: int
output_tokens: int
cost: float = 0.0
estimated: bool = False
@property
def total_tokens(self) -> int:
return self.input_tokens + self.output_tokens
@dataclass(frozen=True)
class TokenPricing:
"""Explicit model pricing in USD per one million tokens."""
input_per_million: float
output_per_million: float
def cost(self, input_tokens: int, output_tokens: int) -> float:
return (
input_tokens * self.input_per_million
+ output_tokens * self.output_per_million
) / 1_000_000
class LLMClient(ABC):
"""Abstract base class for LLM clients."""
def __init__(self) -> None:
self.usage_records: List[UsageRecord] = []
self._usage_callback: Optional[Callable[[UsageRecord], None]] = None
def set_usage_callback(
self,
callback: Optional[Callable[[UsageRecord], None]],
) -> None:
self._usage_callback = callback
def _record_usage(self, record: UsageRecord) -> None:
self.usage_records.append(record)
if self._usage_callback:
self._usage_callback(record)
@property
def total_tokens(self) -> int:
return sum(record.total_tokens for record in self.usage_records)
@property
def total_cost(self) -> float:
return sum(record.cost for record in self.usage_records)
@abstractmethod
async def generate(
self,
prompt: str,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> str:
"""Generate text from prompt."""
pass
@abstractmethod
async def generate_structured(
self,
prompt: str,
schema: Dict[str, Any],
model: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""Generate structured output."""
pass
class MockLLMClient(LLMClient):
"""
Mock LLM client for testing without API calls.
Returns deterministic responses based on prompt patterns.
"""
def __init__(self, seed: int = 42):
super().__init__()
self.seed = seed
self.call_count = 0
self.token_estimate_per_call = 100
async def generate(
self,
prompt: str,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> str:
"""Generate mock response."""
self.call_count += 1
# Simulate latency
await asyncio.sleep(0.01)
# Pattern-based responses
if "plan" in prompt.lower():
text = json.dumps({
"steps": [
{"description": "Analyze the problem", "dependencies": []},
{"description": "Break down into subtasks", "dependencies": [0]},
{"description": "Execute each subtask", "dependencies": [1]},
{"description": "Verify results", "dependencies": [2]}
]
})
elif "verify" in prompt.lower() or "evaluate" in prompt.lower():
text = json.dumps({
"score": 0.85,
"complete": True,
"feedback": "Task completed successfully"
})
elif "calculate" in prompt.lower() or "math" in prompt.lower():
# Extract numbers and perform simple operations
import re
numbers = [int(n) for n in re.findall(r'\d+', prompt)]
if numbers:
result = sum(numbers)
text = json.dumps({"result": result})
else:
text = json.dumps({"result": 42})
else:
text = f"Mock response {self.call_count}: Acknowledged task."
self._record_usage(
UsageRecord(
provider="mock",
model=model or "mock-deterministic",
input_tokens=ceil(len(prompt) / 4),
output_tokens=ceil(len(text) / 4),
estimated=True,
)
)
return text
async def generate_structured(
self,
prompt: str,
schema: Dict[str, Any],
model: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""Generate mock structured response."""
text = await self.generate(prompt, model, **kwargs)
try:
return json.loads(text)
except json.JSONDecodeError:
# Return default based on schema
return {k: self._default_value(v) for k, v in schema.get("properties", {}).items()}
def _default_value(self, schema: Dict) -> Any:
"""Generate default value from schema."""
type_map = {
"string": "",
"integer": 0,
"number": 0.0,
"boolean": False,
"array": [],
"object": {}
}
return type_map.get(schema.get("type"), None)
class AnthropicClient(LLMClient):
"""Anthropic Claude client."""
def __init__(
self,
api_key: Optional[str] = None,
pricing: Optional[TokenPricing] = None,
):
super().__init__()
self.pricing = pricing
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
if not self.api_key:
raise ValueError("Anthropic API key required")
try:
from anthropic import AsyncAnthropic
self.client = AsyncAnthropic(api_key=self.api_key)
except ImportError:
raise ImportError("anthropic package required. Install with: pip install anthropic")
async def generate(
self,
prompt: str,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> str:
"""Generate with Claude."""
model = model or "claude-3-haiku-20240307"
max_tokens = max_tokens or 1000
start_time = time.time()
try:
response = await self.client.messages.create(
model=model,
max_tokens=max_tokens,
temperature=temperature,
messages=[{"role": "user", "content": prompt}],
**kwargs
)
latency = time.time() - start_time
text = response.content[0].text if response.content else ""
usage = getattr(response, "usage", None)
input_tokens = int(getattr(usage, "input_tokens", 0))
output_tokens = int(getattr(usage, "output_tokens", 0))
self._record_usage(
UsageRecord(
provider="anthropic",
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=self.pricing.cost(input_tokens, output_tokens)
if self.pricing else 0.0,
)
)
logger.debug(f"Claude response: {len(text)} chars in {latency:.2f}s")
return text
except Exception as e:
logger.error(f"Claude generation failed: {e}")
raise
async def generate_structured(
self,
prompt: str,
schema: Dict[str, Any],
model: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""Generate structured output."""
# Add schema to prompt
structured_prompt = f"""{prompt}
Respond with a JSON object matching this schema:
{json.dumps(schema, indent=2)}
Respond with ONLY the JSON object, no other text.
"""
response = await self.generate(structured_prompt, model, **kwargs)
# Extract JSON
try:
# Try to parse directly
return json.loads(response)
except json.JSONDecodeError:
# Try to extract JSON from markdown
if "```json" in response:
json_str = response.split("```json")[1].split("```")[0]
return json.loads(json_str)
elif "```" in response:
json_str = response.split("```")[1].split("```")[0]
return json.loads(json_str)
else:
raise ValueError(f"Could not parse JSON from response: {response[:200]}")
class OpenAIClient(LLMClient):
"""OpenAI GPT client."""
def __init__(
self,
api_key: Optional[str] = None,
pricing: Optional[TokenPricing] = None,
):
super().__init__()
self.pricing = pricing
self.provider_name = "openai"
self.default_model = "gpt-3.5-turbo"
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
if not self.api_key:
raise ValueError("OpenAI API key required")
try:
from openai import AsyncOpenAI
self.client = AsyncOpenAI(api_key=self.api_key)
except ImportError:
raise ImportError("openai package required. Install with: pip install openai")
async def generate(
self,
prompt: str,
model: Optional[str] = None,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
**kwargs
) -> str:
"""Generate with GPT."""
model = model or self.default_model
max_tokens = max_tokens or 1000
start_time = time.time()
try:
response = await self.client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=temperature,
max_tokens=max_tokens,
**kwargs
)
latency = time.time() - start_time
text = response.choices[0].message.content or ""
usage = getattr(response, "usage", None)
input_tokens = int(getattr(usage, "prompt_tokens", 0))
output_tokens = int(getattr(usage, "completion_tokens", 0))
reported_cost = float(getattr(usage, "cost", 0.0) or 0.0)
self._record_usage(
UsageRecord(
provider=self.provider_name,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=self.pricing.cost(input_tokens, output_tokens)
if self.pricing else reported_cost,
)
)
logger.debug(f"GPT response: {len(text)} chars in {latency:.2f}s")
return text
except Exception as e:
logger.error("%s generation failed: %s", self.provider_name, e)
raise
async def generate_structured(
self,
prompt: str,
schema: Dict[str, Any],
model: Optional[str] = None,
**kwargs
) -> Dict[str, Any]:
"""Generate structured output using JSON mode."""
model = model or self.default_model
response = await self.client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
**kwargs
)
text = response.choices[0].message.content or ""
usage = getattr(response, "usage", None)
input_tokens = int(getattr(usage, "prompt_tokens", 0))
output_tokens = int(getattr(usage, "completion_tokens", 0))
reported_cost = float(getattr(usage, "cost", 0.0) or 0.0)
self._record_usage(
UsageRecord(
provider=self.provider_name,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
cost=self.pricing.cost(input_tokens, output_tokens)
if self.pricing else reported_cost,
)
)
return json.loads(text)
class OpenRouterClient(OpenAIClient):
"""OpenRouter adapter using its OpenAI-compatible chat API."""
def __init__(
self,
api_key: Optional[str] = None,
*,
model: Optional[str] = None,
pricing: Optional[TokenPricing] = None,
app_name: str = "Loop Engineering",
app_url: Optional[str] = None,
):
LLMClient.__init__(self)
self.api_key = api_key or os.environ.get("OPENROUTER_API_KEY")
if not self.api_key:
raise ValueError("OpenRouter API key required")
self.default_model = model or os.environ.get("OPENROUTER_MODEL")
if not self.default_model:
raise ValueError(
"OpenRouter model required via model= or OPENROUTER_MODEL"
)
self.pricing = pricing
self.provider_name = "openrouter"
try:
from openai import AsyncOpenAI
except ImportError as exc:
raise ImportError(
"openai package required for OpenRouter. Install with: pip install openai"
) from exc
headers = {"X-Title": app_name}
if app_url:
headers["HTTP-Referer"] = app_url
self.client = AsyncOpenAI(
api_key=self.api_key,
base_url="https://openrouter.ai/api/v1",
default_headers=headers,
)
class ScriptedLLMClient(LLMClient):
"""Deterministic provider for reproducible scenarios and benchmarks."""
def __init__(
self,
responses: List[str],
*,
input_tokens_per_call: int = 10,
output_tokens_per_call: int = 10,
cost_per_call: float = 0.0,
):
super().__init__()
if not responses:
raise ValueError("ScriptedLLMClient requires at least one response")
self.responses = list(responses)
self.input_tokens_per_call = input_tokens_per_call
self.output_tokens_per_call = output_tokens_per_call
self.cost_per_call = cost_per_call
self.call_count = 0
async def generate(
self,
prompt: str,
model: Optional[str] = None,
temperature: float = 0.0,
max_tokens: Optional[int] = None,
**kwargs,
) -> str:
if self.call_count >= len(self.responses):
raise RuntimeError("Scripted response sequence exhausted")
text = self.responses[self.call_count]
self.call_count += 1
self._record_usage(
UsageRecord(
provider="scripted",
model=model or "scripted",
input_tokens=self.input_tokens_per_call,
output_tokens=self.output_tokens_per_call,
cost=self.cost_per_call,
)
)
return text
async def generate_structured(
self,
prompt: str,
schema: Dict[str, Any],
model: Optional[str] = None,
**kwargs,
) -> Dict[str, Any]:
return json.loads(await self.generate(prompt, model=model, **kwargs))
def create_llm_client(
provider: str = "mock",
api_key: Optional[str] = None,
**kwargs
) -> LLMClient:
"""
Factory function to create LLM clients.
Args:
provider: "mock", "anthropic", or "openai"
api_key: API key (optional, will use env var if not provided)
**kwargs: Additional arguments for client
Returns:
Configured LLM client
"""
if provider == "mock":
return MockLLMClient(**kwargs)
elif provider == "scripted":
return ScriptedLLMClient(**kwargs)
elif provider == "anthropic":
return AnthropicClient(api_key=api_key, **kwargs)
elif provider == "openai":
return OpenAIClient(api_key=api_key, **kwargs)
elif provider == "openrouter":
return OpenRouterClient(api_key=api_key, **kwargs)
else:
raise ValueError(f"Unknown provider: {provider}")