agentirc 1.0.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.
agentirc/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """agentirc -- AI-powered IRC agent using OpenAI-compatible Responses APIs."""
2
+
3
+ from .bot import ChatBot
4
+ from .config import ChatConfig
5
+
6
+ __all__ = [
7
+ "ChatBot",
8
+ "ChatConfig",
9
+ ]
agentirc/__main__.py ADDED
@@ -0,0 +1,97 @@
1
+ """Entry point: python -m agentirc"""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import logging
6
+ import os
7
+
8
+ from .config import ChatConfig
9
+ from .bot import ChatBot
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(description="AI-powered IRC agent")
14
+ parser.add_argument(
15
+ "--generate-key",
16
+ action="store_true",
17
+ help="Generate a Fernet encryption key for history persistence and exit",
18
+ )
19
+ parser.add_argument(
20
+ "--env-file",
21
+ metavar="PATH",
22
+ default=".env",
23
+ help="Path to .env file (default: .env)",
24
+ )
25
+ parser.add_argument(
26
+ "--debug",
27
+ action="store_true",
28
+ help="Enable debug logging",
29
+ )
30
+ parser.add_argument(
31
+ "--host",
32
+ metavar="HOST",
33
+ help="IRC server hostname (overrides IRC_HOST)",
34
+ )
35
+ parser.add_argument(
36
+ "--port",
37
+ type=int,
38
+ metavar="PORT",
39
+ help="IRC server port (overrides IRC_PORT)",
40
+ )
41
+ parser.add_argument(
42
+ "--nick",
43
+ metavar="NICK",
44
+ help="Bot nickname (overrides IRC_NICK)",
45
+ )
46
+ parser.add_argument(
47
+ "--channels",
48
+ metavar="CHANS",
49
+ help="Comma-separated channels to join (overrides IRC_CHANNELS)",
50
+ )
51
+ parser.add_argument(
52
+ "--tls",
53
+ action="store_true",
54
+ default=None,
55
+ help="Connect with TLS (overrides IRC_USE_TLS)",
56
+ )
57
+ parser.add_argument(
58
+ "--model",
59
+ metavar="MODEL",
60
+ help="Default model (overrides DEFAULT_MODEL)",
61
+ )
62
+ args = parser.parse_args()
63
+
64
+ if args.generate_key:
65
+ from cryptography.fernet import Fernet
66
+ print(Fernet.generate_key().decode())
67
+ return
68
+
69
+ # Load .env first, then apply CLI overrides (which win via direct set)
70
+ from ircbot.config import load_env
71
+ load_env(args.env_file)
72
+
73
+ if args.host:
74
+ os.environ["IRC_HOST"] = args.host
75
+ if args.port is not None:
76
+ os.environ["IRC_PORT"] = str(args.port)
77
+ if args.nick:
78
+ os.environ["IRC_NICK"] = args.nick
79
+ if args.channels:
80
+ os.environ["IRC_CHANNELS"] = args.channels
81
+ if args.tls:
82
+ os.environ["IRC_USE_TLS"] = "true"
83
+ if args.model:
84
+ os.environ["DEFAULT_MODEL"] = args.model
85
+
86
+ logging.basicConfig(
87
+ level=logging.DEBUG if args.debug else logging.INFO,
88
+ format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
89
+ )
90
+
91
+ config = ChatConfig.from_env()
92
+ bot = ChatBot(config)
93
+ asyncio.run(bot.run())
94
+
95
+
96
+ if __name__ == "__main__":
97
+ main()
agentirc/api.py ADDED
@@ -0,0 +1,369 @@
1
+ """OpenAI-compatible Responses API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ from typing import Any, Iterable
9
+
10
+ import httpx
11
+
12
+ from .tools import build_tools
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ class ResponsesClient:
18
+ """Subset of the agent-smithers Responses client adapted for IRC."""
19
+
20
+ LMSTUDIO_FALLBACK_USER_PROMPT = "Please continue the conversation."
21
+
22
+ def __init__(
23
+ self,
24
+ api_base: str,
25
+ api_key: str,
26
+ model: str,
27
+ system_prompt: str,
28
+ max_tokens: int,
29
+ enabled_tools: list[str],
30
+ provider: str = "openai",
31
+ ) -> None:
32
+ self.api_base = api_base.rstrip("/")
33
+ self.api_key = api_key
34
+ self.model = model
35
+ self.provider = provider
36
+ self.system_prompt = system_prompt
37
+ self.max_tokens = max_tokens
38
+ self.enabled_tools = list(enabled_tools)
39
+
40
+ @staticmethod
41
+ def _fallback_base_url(provider: str) -> str:
42
+ if provider == "lmstudio":
43
+ return "http://127.0.0.1:1234/v1"
44
+ if provider == "xai":
45
+ return "https://api.x.ai/v1"
46
+ return "https://api.openai.com/v1"
47
+
48
+ def _base_url(self, provider: str, api_base: str | None = None) -> str:
49
+ configured = str(api_base or self.api_base or "").strip()
50
+ base = configured or self._fallback_base_url(provider)
51
+ if base.endswith("/v1"):
52
+ return base
53
+ return f"{base}/v1"
54
+
55
+ def _headers(self, provider: str, api_key: str | None = None) -> dict[str, str]:
56
+ headers = {"Content-Type": "application/json"}
57
+ token = str(self.api_key if api_key is None else api_key).strip()
58
+ if token:
59
+ headers["Authorization"] = f"Bearer {token}"
60
+ return headers
61
+
62
+ @staticmethod
63
+ def _supports_instructions(provider: str) -> bool:
64
+ return provider != "xai"
65
+
66
+ @staticmethod
67
+ def _has_user_message(items: Iterable[dict[str, Any]]) -> bool:
68
+ for item in items:
69
+ if not isinstance(item, dict):
70
+ continue
71
+ if str(item.get("role") or "").strip() == "user" and str(item.get("content") or "").strip():
72
+ return True
73
+ return False
74
+
75
+ @classmethod
76
+ def _ensure_lmstudio_user_message(
77
+ cls,
78
+ items: Iterable[dict[str, Any]],
79
+ ) -> list[dict[str, Any]]:
80
+ final_items = [dict(item) for item in items if isinstance(item, dict)]
81
+ if cls._has_user_message(final_items):
82
+ return final_items
83
+ final_items.append({"role": "user", "content": cls.LMSTUDIO_FALLBACK_USER_PROMPT})
84
+ return final_items
85
+
86
+ @staticmethod
87
+ def _merge_include_items(existing: Any, additions: Iterable[str]) -> list[str]:
88
+ merged: list[str] = []
89
+ seen = set()
90
+ for value in existing if isinstance(existing, list) else []:
91
+ if isinstance(value, str) and value and value not in seen:
92
+ merged.append(value)
93
+ seen.add(value)
94
+ for value in additions:
95
+ if value and value not in seen:
96
+ merged.append(value)
97
+ seen.add(value)
98
+ return merged
99
+
100
+ @staticmethod
101
+ def _is_chat_model(provider: str, model_id: str) -> bool:
102
+ lowered = model_id.lower()
103
+ if provider == "lmstudio":
104
+ blocked_models = {
105
+ "text-embedding-nomic-embed-text-v1.5",
106
+ }
107
+ if lowered in blocked_models:
108
+ return False
109
+ return bool(model_id.strip())
110
+ if provider == "xai":
111
+ if not lowered.startswith("grok-"):
112
+ return False
113
+ blocked_fragments = ("imagine", "image", "video", "voice", "vision")
114
+ return not any(fragment in lowered for fragment in blocked_fragments)
115
+
116
+ prefixes = ("gpt-", "o1", "o3", "o4")
117
+ if not model_id.startswith(prefixes):
118
+ return False
119
+
120
+ blocked_fragments = (
121
+ "preview",
122
+ "audio",
123
+ "computer-use",
124
+ "transcribe",
125
+ "tts",
126
+ "image",
127
+ )
128
+ if any(fragment in lowered for fragment in blocked_fragments):
129
+ return False
130
+
131
+ if re.search(r"-\d{4}-\d{2}-\d{2}$", lowered):
132
+ return False
133
+
134
+ return True
135
+
136
+ @staticmethod
137
+ def build_input_items(
138
+ messages: Iterable[dict[str, Any]],
139
+ *,
140
+ include_system: bool = False,
141
+ ) -> tuple[str | None, list[dict[str, str]]]:
142
+ instructions: list[str] = []
143
+ input_items: list[dict[str, str]] = []
144
+ for message in messages:
145
+ role = str(message.get("role") or "").strip()
146
+ content = str(message.get("content") or "")
147
+ if not role or not content:
148
+ continue
149
+ if role == "system":
150
+ if include_system:
151
+ input_items.append({"role": role, "content": content})
152
+ else:
153
+ instructions.append(content)
154
+ continue
155
+ if role in {"user", "assistant"}:
156
+ input_items.append({"role": role, "content": content})
157
+ joined = "\n\n".join(part.strip() for part in instructions if part.strip()).strip()
158
+ return (joined or None, input_items)
159
+
160
+ def build_request_payload(
161
+ self,
162
+ *,
163
+ model: str,
164
+ messages: Iterable[dict[str, Any]] | None = None,
165
+ tools: list[dict[str, Any]] | None = None,
166
+ tool_choice: str | None = None,
167
+ previous_response_id: str | None = None,
168
+ input_items: list[dict[str, Any]] | None = None,
169
+ options: dict[str, Any] | None = None,
170
+ instructions: str | None = None,
171
+ provider: str | None = None,
172
+ ) -> dict[str, Any]:
173
+ provider_name = provider or self.provider
174
+ payload: dict[str, Any] = {"model": model}
175
+ derived_instructions = instructions
176
+ derived_input: list[dict[str, Any]] = []
177
+ supports_instructions = self._supports_instructions(provider_name)
178
+ if messages is not None:
179
+ derived_instructions, derived_input = self.build_input_items(
180
+ messages,
181
+ include_system=not supports_instructions,
182
+ )
183
+ if previous_response_id:
184
+ payload["previous_response_id"] = previous_response_id
185
+ if supports_instructions and derived_instructions and not previous_response_id:
186
+ payload["instructions"] = derived_instructions
187
+ final_input = input_items if input_items is not None else derived_input
188
+ if provider_name == "lmstudio" and messages is not None and input_items is None:
189
+ final_input = self._ensure_lmstudio_user_message(final_input)
190
+ if final_input:
191
+ payload["input"] = final_input
192
+ if tools:
193
+ payload["tools"] = tools
194
+ payload["tool_choice"] = tool_choice or "auto"
195
+ if options:
196
+ for key, value in options.items():
197
+ if value is not None:
198
+ payload[key] = value
199
+ payload["store"] = False
200
+
201
+ if provider_name == "xai" and any(
202
+ isinstance(tool, dict) and tool.get("type") in {"web_search", "x_search"}
203
+ for tool in (tools or [])
204
+ ):
205
+ payload["include"] = self._merge_include_items(
206
+ payload.get("include"),
207
+ ["no_inline_citations"],
208
+ )
209
+ return payload
210
+
211
+ async def create_response(
212
+ self,
213
+ *,
214
+ model: str,
215
+ messages: Iterable[dict[str, Any]] | None = None,
216
+ tools: list[dict[str, Any]] | None = None,
217
+ tool_choice: str | None = None,
218
+ previous_response_id: str | None = None,
219
+ input_items: list[dict[str, Any]] | None = None,
220
+ options: dict[str, Any] | None = None,
221
+ instructions: str | None = None,
222
+ provider: str | None = None,
223
+ api_base: str | None = None,
224
+ api_key: str | None = None,
225
+ ) -> dict[str, Any]:
226
+ provider_name = provider or self.provider
227
+ base_url = self._base_url(provider_name, api_base)
228
+ payload = self.build_request_payload(
229
+ model=model,
230
+ messages=messages,
231
+ tools=tools,
232
+ tool_choice=tool_choice,
233
+ previous_response_id=previous_response_id,
234
+ input_items=input_items,
235
+ options=options,
236
+ instructions=instructions,
237
+ provider=provider_name,
238
+ )
239
+ log.info(
240
+ "api POST %s/responses provider=%s model=%s tools=%d",
241
+ base_url,
242
+ provider_name,
243
+ model,
244
+ len(tools or []),
245
+ )
246
+ async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
247
+ response = await client.post(
248
+ f"{base_url}/responses",
249
+ headers=self._headers(provider_name, api_key),
250
+ json=payload,
251
+ )
252
+ response.raise_for_status()
253
+ return response.json()
254
+
255
+ async def ask(
256
+ self,
257
+ user_input: str,
258
+ previous_response_id: str | None = None,
259
+ *,
260
+ model: str | None = None,
261
+ system_prompt: str | None = None,
262
+ enabled_tools: list[str] | None = None,
263
+ provider: str | None = None,
264
+ api_base: str | None = None,
265
+ api_key: str | None = None,
266
+ max_tokens: int | None = None,
267
+ ) -> tuple[str, str | None]:
268
+ del max_tokens
269
+ provider_name = provider or self.provider
270
+ final_model = model or self.model
271
+ prompt = self.system_prompt if system_prompt is None else system_prompt
272
+ messages: list[dict[str, str]] = []
273
+ if prompt:
274
+ messages.append({"role": "system", "content": prompt})
275
+ messages.append({"role": "user", "content": user_input})
276
+ tools = build_tools(
277
+ self.enabled_tools if enabled_tools is None else enabled_tools,
278
+ provider=provider_name,
279
+ )
280
+ result = await self.create_response(
281
+ model=final_model,
282
+ messages=messages,
283
+ tools=tools,
284
+ previous_response_id=previous_response_id,
285
+ provider=provider_name,
286
+ api_base=api_base,
287
+ api_key=api_key,
288
+ )
289
+ log.debug("Raw API response: %s", json.dumps(result, indent=2))
290
+ text = self._extract_text(result)
291
+ response_id = result.get("id")
292
+ return text, response_id
293
+
294
+ async def ask_messages(
295
+ self,
296
+ messages: Iterable[dict[str, Any]],
297
+ *,
298
+ model: str | None = None,
299
+ enabled_tools: list[str] | None = None,
300
+ built_tools: list[dict[str, Any]] | None = None,
301
+ provider: str | None = None,
302
+ api_base: str | None = None,
303
+ api_key: str | None = None,
304
+ max_tokens: int | None = None,
305
+ ) -> tuple[str, str | None]:
306
+ provider_name = provider or self.provider
307
+ final_model = model or self.model
308
+ if built_tools is not None:
309
+ tools = built_tools
310
+ else:
311
+ tools = build_tools(
312
+ self.enabled_tools if enabled_tools is None else enabled_tools,
313
+ provider=provider_name,
314
+ )
315
+ options = {}
316
+ if max_tokens is not None:
317
+ options["max_output_tokens"] = max_tokens
318
+ result = await self.create_response(
319
+ model=final_model,
320
+ messages=messages,
321
+ tools=tools,
322
+ options=options or None,
323
+ provider=provider_name,
324
+ api_base=api_base,
325
+ api_key=api_key,
326
+ )
327
+ log.debug("Raw API response: %s", json.dumps(result, indent=2))
328
+ text = self._extract_text(result)
329
+ response_id = result.get("id")
330
+ return text, response_id
331
+
332
+ async def list_models(
333
+ self,
334
+ provider: str,
335
+ *,
336
+ api_base: str | None = None,
337
+ api_key: str | None = None,
338
+ ) -> list[str]:
339
+ base_url = self._base_url(provider, api_base)
340
+ log.info("api GET %s/models provider=%s", base_url, provider)
341
+ async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
342
+ response = await client.get(
343
+ f"{base_url}/models",
344
+ headers=self._headers(provider, api_key),
345
+ )
346
+ response.raise_for_status()
347
+ payload = response.json()
348
+ model_ids = [
349
+ str(item.get("id") or "").strip()
350
+ for item in payload.get("data", [])
351
+ if isinstance(item, dict)
352
+ ]
353
+ filtered = sorted({model_id for model_id in model_ids if self._is_chat_model(provider, model_id)})
354
+ return filtered or sorted({model_id for model_id in model_ids if model_id})
355
+
356
+ @staticmethod
357
+ def _extract_text(response: dict[str, Any]) -> str:
358
+ parts: list[str] = []
359
+ for item in response.get("output", []) or []:
360
+ if item.get("type") != "message":
361
+ continue
362
+ for content in item.get("content", []) or []:
363
+ if content.get("type") == "output_text":
364
+ text = str(content.get("text") or "")
365
+ if text:
366
+ parts.append(text)
367
+ if parts:
368
+ return "\n".join(parts).strip()
369
+ return str(response.get("output_text") or "").strip() or "(no response)"