aipyapp-qpython 0.1.24.20__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.
aipyapp/aipy/llm.py ADDED
@@ -0,0 +1,540 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import time
5
+ import json
6
+ from collections import Counter, defaultdict
7
+ from dataclasses import dataclass, field
8
+ from abc import ABC, abstractmethod
9
+
10
+ import openai
11
+ import requests
12
+ from rich.live import Live
13
+ from rich.panel import Panel
14
+ from rich.text import Text
15
+ from rich.markdown import Markdown
16
+
17
+ from .i18n import T
18
+ from .plugin import event_bus
19
+
20
+ @dataclass
21
+ class ChatMessage:
22
+ role: str
23
+ content: str
24
+ reason: str = None
25
+ usage: Counter = field(default_factory=Counter)
26
+
27
+ class LineReceiver(list):
28
+ def __init__(self):
29
+ super().__init__()
30
+ self.buffer = ""
31
+
32
+ @property
33
+ def content(self):
34
+ return '\n'.join(self)
35
+
36
+ def feed(self, data: str):
37
+ self.buffer += data
38
+ new_lines = []
39
+
40
+ while '\n' in self.buffer:
41
+ line, self.buffer = self.buffer.split('\n', 1)
42
+ self.append(line)
43
+ new_lines.append(line)
44
+
45
+ return new_lines
46
+
47
+ class LiveManager:
48
+ def __init__(self, console, name):
49
+ self.live = None
50
+ self.name = name
51
+ self.console = console
52
+ self.lr = LineReceiver()
53
+ self.title = f"{self.name} {T('llm_response')}"
54
+ self.response_panel = None
55
+ self.full_response = None
56
+
57
+ def __enter__(self):
58
+ console = self.console if self.console.quiet else None
59
+ self.live = Live(console=console, auto_refresh=False, vertical_overflow='visible', transient=True)
60
+ self.live.__enter__()
61
+ status = self.console.status(f"[dim white]{self.name} {T('thinking')}...", spinner='runner')
62
+ response_panel = Panel(status, title=f"[blue] 🤖️ {self.title} ")
63
+ self.live.update(response_panel, refresh=True)
64
+ return self
65
+
66
+ def process_chunk(self, content):
67
+ if not content: return
68
+ lines = self.lr.feed(content)
69
+ if not lines: return
70
+
71
+ content = '\n'.join(lines)
72
+ event_bus.broadcast('response_stream', {'llm': self.name, 'content': content})
73
+ if hasattr(self.console, 'gui'):
74
+ self.console.print(content, end="", highlight=False)
75
+
76
+ full_response = self.lr.content
77
+ try:
78
+ md = Markdown(full_response)
79
+ response_panel = Panel(md, title=f"[green] 🤖️ {self.title} ")
80
+ except Exception:
81
+ text = Text(full_response)
82
+ response_panel = Panel(text, title=f"[yellow] 🤖️ {self.title} ")
83
+ self.live.update(response_panel, refresh=True)
84
+ self.response_panel = response_panel
85
+ self.full_response = full_response
86
+
87
+ def __exit__(self, exc_type, exc_val, exc_tb):
88
+ if self.lr.buffer:
89
+ self.process_chunk('\n')
90
+ self.live.__exit__(exc_type, exc_val, exc_tb)
91
+
92
+ class ChatHistory:
93
+ def __init__(self):
94
+ self.messages = []
95
+ self._total_tokens = Counter()
96
+
97
+ def __len__(self):
98
+ return len(self.messages)
99
+
100
+ def json(self):
101
+ return [msg.__dict__ for msg in self.messages]
102
+
103
+ def add(self, role, content):
104
+ self.add_message(ChatMessage(role=role, content=content))
105
+
106
+ def add_message(self, message: ChatMessage):
107
+ self.messages.append(message)
108
+ self._total_tokens += message.usage
109
+
110
+ def get_usage(self):
111
+ return iter(row.usage for row in self.messages if row.role == "assistant")
112
+
113
+ def get_summary(self):
114
+ summary = {'time': 0, 'input_tokens': 0, 'output_tokens': 0, 'total_tokens': 0}
115
+ summary.update(dict(self._total_tokens))
116
+ summary['rounds'] = sum(1 for row in self.messages if row.role == "assistant")
117
+ return summary
118
+
119
+ def get_messages(self):
120
+ return [{"role": msg.role, "content": msg.content} for msg in self.messages]
121
+
122
+ class BaseClient(ABC):
123
+ MODEL = None
124
+ BASE_URL = None
125
+ RPS = 2
126
+ MAX_TOKENS = 8192
127
+ PARAMS = {}
128
+
129
+ def __init__(self, config):
130
+ self.name = None
131
+ self.console = None
132
+ self.max_tokens = config.get("max_tokens") or self.MAX_TOKENS
133
+ self._model = config.get("model") or self.MODEL
134
+ self._timeout = config.get("timeout")
135
+ self._api_key = config.get("api_key")
136
+ self._base_url = config.get("base_url") or self.BASE_URL
137
+ self._stream = config.get("stream", True)
138
+ self._client = None
139
+ self._params = {}
140
+ if self.PARAMS:
141
+ self._params.update(self.PARAMS)
142
+ temperature = config.get("temperature")
143
+ if temperature != None and temperature >= 0 and temperature <= 1:
144
+ self._params['temperature'] = temperature
145
+
146
+ def __repr__(self):
147
+ return f"{self.__class__.__name__}<{self.name}>({self._model}, {self.max_tokens})"
148
+
149
+ def is_stopped(self):
150
+ return event_bus.is_stopped()
151
+
152
+ def usable(self):
153
+ return self._model
154
+
155
+ def _get_client(self):
156
+ return self._client
157
+
158
+ @abstractmethod
159
+ def get_completion(self, messages):
160
+ pass
161
+
162
+ def add_system_prompt(self, history, system_prompt):
163
+ history.add("system", system_prompt)
164
+
165
+ @abstractmethod
166
+ def _parse_usage(self, response):
167
+ pass
168
+
169
+ @abstractmethod
170
+ def _parse_stream_response(self, response):
171
+ pass
172
+
173
+ @abstractmethod
174
+ def _parse_response(self, response):
175
+ pass
176
+
177
+ def parse_response(self, response):
178
+ if self._stream:
179
+ response = self._parse_stream_response(response)
180
+ else:
181
+ response = self._parse_response(response)
182
+ return response
183
+
184
+ def __call__(self, history, prompt, system_prompt=None):
185
+ # We shall only send system prompt once
186
+ if not history and system_prompt:
187
+ self.add_system_prompt(history, system_prompt)
188
+ history.add("user", prompt)
189
+
190
+ start = time.time()
191
+ self.console.record = False
192
+ with self.console.status(f"[dim white]{T('sending_task', self.name)} ..."):
193
+ response = self.get_completion(history.get_messages())
194
+ self.console.record = True
195
+ end = time.time()
196
+ if response:
197
+ msg = self.parse_response(response)
198
+ msg.usage['time'] = round(end - start, 3)
199
+ history.add_message(msg)
200
+ response = msg.content
201
+ return response
202
+
203
+ # https://platform.openai.com/docs/api-reference/chat/create
204
+ # https://api-docs.deepseek.com/api/create-chat-completion
205
+ class OpenAIBaseClient(BaseClient):
206
+ def usable(self):
207
+ return super().usable() and self._api_key
208
+
209
+ def _get_client(self):
210
+ return openai.Client(api_key=self._api_key, base_url=self._base_url, timeout=self._timeout)
211
+
212
+ def add_system_prompt(self, history, system_prompt):
213
+ history.add("system", system_prompt)
214
+
215
+ def _parse_usage(self, usage):
216
+ usage = Counter({'total_tokens': usage.total_tokens,
217
+ 'input_tokens': usage.prompt_tokens,
218
+ 'output_tokens': usage.completion_tokens})
219
+ return usage
220
+
221
+ def _parse_stream_response(self, response):
222
+ usage = Counter()
223
+ with LiveManager(self.console, self.name) as lm:
224
+ for chunk in response:
225
+ if hasattr(chunk, 'usage') and chunk.usage is not None:
226
+ usage = self._parse_usage(chunk.usage)
227
+
228
+ if chunk.choices and chunk.choices[0].delta.content:
229
+ content = chunk.choices[0].delta.content
230
+ lm.process_chunk(content)
231
+
232
+ if self.is_stopped():
233
+ break
234
+ response_panel = lm.response_panel
235
+ full_response = lm.full_response
236
+ if response_panel: self.console.print(response_panel)
237
+ #segments = self.console.render(response_panel)
238
+ #self.console._record_buffer.extend(segments)
239
+ return ChatMessage(role="assistant", content=full_response, usage=usage)
240
+
241
+ def _parse_response(self, response):
242
+ message = response.choices[0].message
243
+ reason = getattr(message, "reasoning_content", None)
244
+ return ChatMessage(
245
+ role=message.role,
246
+ content=message.content,
247
+ reason=reason,
248
+ usage=self._parse_usage(response.usage)
249
+ )
250
+
251
+ def get_completion(self, messages):
252
+ if not self._client:
253
+ self._client = self._get_client()
254
+ try:
255
+ response = self._client.chat.completions.create(
256
+ model = self._model,
257
+ messages = messages,
258
+ stream=self._stream,
259
+ max_tokens = self.max_tokens,
260
+ **self._params
261
+ )
262
+ except Exception as e:
263
+ self.console.print(f"❌ [bold red]{self.name} API {T('call_failed')}: [yellow]{str(e)}")
264
+ response = None
265
+ return response
266
+
267
+ # https://github.com/ollama/ollama/blob/main/docs/api.md
268
+ class OllamaClient(BaseClient):
269
+ def __init__(self, config):
270
+ super().__init__(config)
271
+ self._session = requests.Session()
272
+
273
+ def usable(self):
274
+ return super().usable() and self._base_url
275
+
276
+ def _parse_usage(self, response):
277
+ ret = {'input_tokens': response['prompt_eval_count'], 'output_tokens': response['eval_count']}
278
+ ret['total_tokens'] = ret['input_tokens'] + ret['output_tokens']
279
+ return ret
280
+
281
+ def _parse_stream_response(self, response):
282
+ with LiveManager(self.console, self.name) as lm:
283
+ for chunk in response.iter_lines():
284
+ chunk = chunk.decode(encoding='utf-8')
285
+ msg = json.loads(chunk)
286
+ if msg['done']:
287
+ usage = self._parse_usage(msg)
288
+ break
289
+
290
+ if 'message' in msg and 'content' in msg['message'] and msg['message']['content']:
291
+ content = msg['message']['content']
292
+ lm.process_chunk(content)
293
+
294
+ if self.is_stopped():
295
+ break
296
+ response_panel = lm.response_panel
297
+ full_response = lm.full_response
298
+ if response_panel: self.console.print(response_panel)
299
+ return ChatMessage(role="assistant", content=full_response, usage=usage)
300
+
301
+ def _parse_response(self, response):
302
+ response = response.json()
303
+ msg = response["message"]
304
+ return ChatMessage(role=msg['role'], content=msg['content'], usage=self._parse_usage(response))
305
+
306
+ def get_completion(self, messages):
307
+ try:
308
+ response = self._session.post(
309
+ f"{self._base_url}/api/chat",
310
+ json={
311
+ "model": self._model,
312
+ "messages": messages,
313
+ "stream": self._stream,
314
+ "options": {"num_predict": self.max_tokens}
315
+ },
316
+ timeout=self._timeout,
317
+ **self._params
318
+ )
319
+ response.raise_for_status()
320
+ except Exception as e:
321
+ self.console.print(f"❌ [bold red]{self.name} API {T('call_failed')}: [yellow]{str(e)}")
322
+ response = None
323
+ return response
324
+
325
+ # https://docs.anthropic.com/en/api/messages
326
+ class ClaudeClient(BaseClient):
327
+ MODEL = "claude-3-7-sonnet-20250219"
328
+
329
+ def __init__(self, config):
330
+ super().__init__(config)
331
+ self._system_prompt = None
332
+
333
+ def _get_client(self):
334
+ import anthropic
335
+ return anthropic.Anthropic(api_key=self._api_key, timeout=self._timeout)
336
+
337
+ def usable(self):
338
+ return super().usable() and self._api_key
339
+
340
+ def _parse_usage(self, response):
341
+ usage = response.usage
342
+ ret = {'input_tokens': usage.input_tokens, 'output_tokens': usage.output_tokens}
343
+ ret['total_tokens'] = ret['input_tokens'] + ret['output_tokens']
344
+ return ret
345
+
346
+ def _parse_stream_response(self, response):
347
+ usage = Counter()
348
+ with LiveManager(self.console, self.name) as lm:
349
+ for event in response:
350
+ if hasattr(event, 'delta') and hasattr(event.delta, 'text') and event.delta.text:
351
+ content = event.delta.text
352
+ lm.process_chunk(content)
353
+ elif hasattr(event, 'message') and hasattr(event.message, 'usage') and event.message.usage:
354
+ usage['input_tokens'] += getattr(event.message.usage, 'input_tokens', 0)
355
+ usage['output_tokens'] += getattr(event.message.usage, 'output_tokens', 0)
356
+ elif hasattr(event, 'usage') and event.usage:
357
+ usage['input_tokens'] += getattr(event.usage, 'input_tokens', 0)
358
+ usage['output_tokens'] += getattr(event.usage, 'output_tokens', 0)
359
+
360
+ if self.is_stopped():
361
+ break
362
+
363
+ response_panel = lm.response_panel
364
+ full_response = lm.full_response
365
+ usage['total_tokens'] = usage['input_tokens'] + usage['output_tokens']
366
+ if response_panel: self.console.print(response_panel)
367
+ return ChatMessage(role="assistant", content=full_response, usage=usage)
368
+
369
+ def _parse_response(self, response):
370
+ content = response.content[0].text
371
+ role = response.role
372
+ return ChatMessage(role=role, content=content, usage=self._parse_usage(response))
373
+
374
+ def add_system_prompt(self, history, system_prompt):
375
+ self._system_prompt = system_prompt
376
+
377
+ def get_completion(self, messages):
378
+ if not self._client:
379
+ self._client = self._get_client()
380
+ try:
381
+ message = self._client.messages.create(
382
+ model = self._model,
383
+ messages = messages,
384
+ stream=self._stream,
385
+ system=self._system_prompt,
386
+ max_tokens = self.max_tokens,
387
+ **self._params
388
+ )
389
+ except Exception as e:
390
+ self.console.print(f"❌ [bold red]{self.name} API {T('call_failed')}: [yellow]{str(e)}")
391
+ message = None
392
+ return message
393
+
394
+ class OpenAIClient(OpenAIBaseClient):
395
+ MODEL = 'gpt-4o'
396
+ PARAMS = {'stream_options': {'include_usage': True}}
397
+
398
+ class GeminiClient(OpenAIBaseClient):
399
+ BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/'
400
+ MODEL = 'gemini-2.5-pro-exp-03-25'
401
+ PARAMS = {'stream_options': {'include_usage': True}}
402
+
403
+ class DeepSeekClient(OpenAIBaseClient):
404
+ BASE_URL = 'https://api.deepseek.com'
405
+ MODEL = 'deepseek-chat'
406
+
407
+ class GrokClient(OpenAIBaseClient):
408
+ BASE_URL = 'https://api.x.ai/v1/'
409
+ MODEL = 'grok-3-mini'
410
+ PARAMS = {'stream_options': {'include_usage': True}}
411
+
412
+ class TrustClient(OpenAIBaseClient):
413
+ BASE_URL = 'https://api.trustoken.ai/v1'
414
+ MODEL = 'auto'
415
+ PARAMS = {'stream_options': {'include_usage': True}}
416
+
417
+ class AzureOpenAIClient(OpenAIBaseClient):
418
+ MODEL = 'gpt-4o'
419
+
420
+ def __init__(self, config):
421
+ super().__init__(config)
422
+ self._end_point = config.get('endpoint')
423
+
424
+ def usable(self):
425
+ return super().usable() and self._end_point
426
+
427
+ def _get_client(self):
428
+ from openai import AzureOpenAI
429
+ return AzureOpenAI(azure_endpoint=self._end_point, api_key=self._api_key, api_version="2024-02-01")
430
+
431
+ class PGPTAIClient(OpenAIBaseClient):
432
+ BASE_URL = "https://openai.pgpt.cloud/v1/"
433
+ MODEL = "auto"
434
+
435
+ class LLM(object):
436
+ CLIENTS = {
437
+ "openai": OpenAIClient,
438
+ "ollama": OllamaClient,
439
+ "claude": ClaudeClient,
440
+ "gemini": GeminiClient,
441
+ "pgptai": PGPTAIClient,
442
+ "deepseek": DeepSeekClient,
443
+ 'grok': GrokClient,
444
+ 'trust': TrustClient,
445
+ 'azure': AzureOpenAIClient
446
+ }
447
+
448
+ def __init__(self, settings, console,system_prompt=None):
449
+ self.llms = {}
450
+ self.console = console
451
+ self.default = None
452
+ self._last = None
453
+ self.history = ChatHistory()
454
+ self.system_prompt = system_prompt
455
+ names = defaultdict(set)
456
+ for name, config in settings.llm.items():
457
+ if not config.get('enable', True):
458
+ names['disabled'].add(name)
459
+ continue
460
+
461
+ try:
462
+ client = self.get_client(config)
463
+ except Exception as e:
464
+ self.console.print_exception()
465
+ names['error'].add(name)
466
+ continue
467
+
468
+ names['enabled'].add(name)
469
+ client.name = name
470
+ client.console = console
471
+ self.llms[name] = client
472
+
473
+ if config.get('default', False) and not self.default:
474
+ self.default = client
475
+ names['default'] = name
476
+
477
+ if not self.default:
478
+ name = list(self.llms.keys())[0]
479
+ self.default = self.llms[name]
480
+ names['default'] = name
481
+ self.current = self.default
482
+ self.names = names
483
+
484
+ def __len__(self):
485
+ return len(self.llms)
486
+
487
+ def __repr__(self):
488
+ return f"Current: {'default' if self.current == self.default else self.current}, Default: {self.default}"
489
+
490
+ @property
491
+ def enabled(self):
492
+ return self.names['enabled']
493
+
494
+ @property
495
+ def last(self):
496
+ return self._last.name if self._last else None
497
+
498
+ def get_last_message(self, role='assistant'):
499
+ return self.history.get_last_message(role)
500
+
501
+ def clear(self):
502
+ self.history = ChatHistory()
503
+
504
+ def get_client(self, config):
505
+ proto = config.get("type", "openai")
506
+
507
+ client = self.CLIENTS.get(proto.lower())
508
+ if not client:
509
+ raise ValueError(f"Unsupported LLM provider: {proto}")
510
+ return client(config)
511
+
512
+ def __contains__(self, name):
513
+ return name in self.llms
514
+
515
+ def use(self, name):
516
+ llm = self.llms.get(name)
517
+ if llm and llm.usable():
518
+ self.current = llm
519
+ return True
520
+ return False
521
+
522
+ def __call__(self, instruction, *, system_prompt=None, name=None):
523
+ """ LLM 选择规则
524
+ 1. 如果 name 为 None, 使用 current
525
+ 2. 如果 name 存在,使用 name 对应的
526
+ 3. 使用 default
527
+ """
528
+ if not name:
529
+ llm = self.current
530
+ else:
531
+ llm = self.llms.get(name, self.default)
532
+
533
+ if not llm.usable():
534
+ self.console.print(f"[red]LLM: {name} {T('not usable')}")
535
+ return None
536
+
537
+ self._last = llm
538
+ ret = llm(self.history, instruction, system_prompt=system_prompt)
539
+ event_bus.broadcast('response_complete', {'llm': llm.name, 'content': ret})
540
+ return ret
aipyapp/aipy/plugin.py ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ import threading
6
+ import traceback
7
+ import importlib.util
8
+ from typing import Callable, Any, Dict, List
9
+
10
+ class EventBus:
11
+ def __init__(self):
12
+ self._listeners: Dict[str, List[Callable[..., Any]]] = {}
13
+ self._stop_event = threading.Event()
14
+
15
+ def stop(self):
16
+ self._stop_event.set()
17
+
18
+ def is_stopped(self):
19
+ return self._stop_event.is_set()
20
+
21
+ def __repr__(self):
22
+ return repr(self._listeners)
23
+
24
+ def register(self, event_name: str, handler: Callable[..., Any]):
25
+ self._listeners.setdefault(event_name, []).append(handler)
26
+
27
+ def broadcast(self, event_name: str, *args, **kwargs):
28
+ for handler in self._listeners.get(event_name, []):
29
+ try:
30
+ handler(*args, **kwargs)
31
+ except Exception as e:
32
+ traceback.print_exc()
33
+
34
+ def pipeline(self, event_name: str, data, **kwargs):
35
+ for handler in self._listeners.get(event_name, []):
36
+ try:
37
+ data = handler(data, **kwargs)
38
+ except Exception as e:
39
+ traceback.print_exc()
40
+ return data
41
+
42
+ def collect(self, event_name: str, *args, **kwargs):
43
+ try:
44
+ ret = [handler(*args, **kwargs) for handler in self._listeners.get(event_name, [])]
45
+ except Exception as e:
46
+ ret = []
47
+ traceback.print_exc()
48
+ return ret
49
+
50
+ def __call__(self, event_name: str, *args, **kwargs):
51
+ return self.pipeline(event_name, *args, **kwargs)
52
+
53
+ event_bus = EventBus()
54
+
55
+ class PluginManager:
56
+ def __init__(self, plugin_dir: str):
57
+ self.plugin_dir = plugin_dir
58
+ self.plugins: Dict[str, Any] = {}
59
+
60
+ def load_plugins(self):
61
+ if not os.path.exists(self.plugin_dir):
62
+ return
63
+
64
+ for fname in os.listdir(self.plugin_dir):
65
+ if fname.endswith(".py") and not fname.startswith("_"):
66
+ self._load_plugin(os.path.join(self.plugin_dir, fname))
67
+
68
+ def _load_plugin(self, filepath: str):
69
+ plugin_id = os.path.basename(filepath)[:-3]
70
+
71
+ spec = importlib.util.spec_from_file_location(plugin_id, filepath)
72
+ module = importlib.util.module_from_spec(spec)
73
+ spec.loader.exec_module(module)
74
+
75
+ plugin_cls = getattr(module, "Plugin", None)
76
+ if not plugin_cls or not callable(plugin_cls):
77
+ return
78
+
79
+ plugin = plugin_cls()
80
+
81
+ for attr_name in dir(plugin):
82
+ if attr_name.startswith("on_") and len(attr_name) > 3:
83
+ handler = getattr(plugin, attr_name)
84
+ if callable(handler):
85
+ event_bus.register(attr_name[3:], handler)
86
+
87
+ self.plugins[plugin_id] = plugin