soliplex 0.42__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.
Files changed (48) hide show
  1. soliplex/agents.py +137 -0
  2. soliplex/agui/__init__.py +380 -0
  3. soliplex/agui/features.py +47 -0
  4. soliplex/agui/parser.py +577 -0
  5. soliplex/agui/persistence.py +938 -0
  6. soliplex/agui/util.py +10 -0
  7. soliplex/auth.py +10 -0
  8. soliplex/authn.py +77 -0
  9. soliplex/authz/__init__.py +92 -0
  10. soliplex/authz/schema.py +460 -0
  11. soliplex/cli.py +992 -0
  12. soliplex/completions.py +93 -0
  13. soliplex/config.py +2960 -0
  14. soliplex/examples.py +273 -0
  15. soliplex/haiku_chat.py +257 -0
  16. soliplex/installation.py +478 -0
  17. soliplex/log_ingest.py +48 -0
  18. soliplex/loggers.py +82 -0
  19. soliplex/main.py +225 -0
  20. soliplex/mcp_auth.py +105 -0
  21. soliplex/mcp_client.py +94 -0
  22. soliplex/mcp_server.py +94 -0
  23. soliplex/models.py +725 -0
  24. soliplex/ollama.py +179 -0
  25. soliplex/quizzes.py +107 -0
  26. soliplex/secrets.py +170 -0
  27. soliplex/tools.py +192 -0
  28. soliplex/tui/cli.py +47 -0
  29. soliplex/tui/main.py +1273 -0
  30. soliplex/tui/rest_api.py +266 -0
  31. soliplex/tui/serve.py +10 -0
  32. soliplex/util.py +244 -0
  33. soliplex/views/__init__.py +114 -0
  34. soliplex/views/agui.py +754 -0
  35. soliplex/views/authn.py +171 -0
  36. soliplex/views/authz.py +163 -0
  37. soliplex/views/completions.py +97 -0
  38. soliplex/views/installation.py +138 -0
  39. soliplex/views/log_ingest.py +67 -0
  40. soliplex/views/quizzes.py +107 -0
  41. soliplex/views/rooms.py +308 -0
  42. soliplex/views/streaming.py +53 -0
  43. soliplex-0.42.dist-info/METADATA +357 -0
  44. soliplex-0.42.dist-info/RECORD +48 -0
  45. soliplex-0.42.dist-info/WHEEL +5 -0
  46. soliplex-0.42.dist-info/entry_points.txt +4 -0
  47. soliplex-0.42.dist-info/licenses/LICENSE +21 -0
  48. soliplex-0.42.dist-info/top_level.txt +1 -0
soliplex/agents.py ADDED
@@ -0,0 +1,137 @@
1
+ import dataclasses
2
+ import typing
3
+ from collections import abc
4
+
5
+ import pydantic_ai
6
+ from pydantic_ai import agent as ai_agent
7
+ from pydantic_ai import mcp as ai_mcp
8
+ from pydantic_ai import tools as ai_tools
9
+ from pydantic_ai.models import google as google_models
10
+ from pydantic_ai.models import openai as openai_models
11
+ from pydantic_ai.providers import google as google_providers
12
+ from pydantic_ai.providers import ollama as ollama_providers
13
+ from pydantic_ai.providers import openai as openai_providers
14
+
15
+ from soliplex import agui
16
+ from soliplex import config
17
+ from soliplex import mcp_client
18
+ from soliplex import models
19
+
20
+ ToolConfigMap = dict[str, typing.Any]
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class AgentDependencies:
25
+ """Agent dependencies implementing StateHandler protocol.
26
+
27
+ The `state` field is required by pydantic-ai's StateHandler protocol.
28
+ AG-UI will inject the client's state into this field for each run.
29
+ """
30
+
31
+ the_installation: typing.Any # installation.Installation
32
+ user: models.UserProfile = None # TBD make required
33
+ tool_configs: ToolConfigMap = None
34
+ state: agui.AGUI_State = dataclasses.field(default_factory=dict)
35
+
36
+
37
+ SoliplexAgent = ai_agent.AbstractAgent[AgentDependencies, typing.Any]
38
+ AgentFactory = abc.Callable[
39
+ [
40
+ config.AgentConfig,
41
+ ToolConfigMap,
42
+ config.MCP_ClientToolsetConfigMap,
43
+ ],
44
+ SoliplexAgent,
45
+ ]
46
+
47
+ # Cache for agents to avoid recreating them
48
+ _agent_cache: dict[str, pydantic_ai.Agent] = {}
49
+
50
+
51
+ def make_ai_tool(tool_config: config.ToolConfig) -> ai_tools.Tool:
52
+ tool_func = tool_config.tool_with_config
53
+
54
+ return ai_tools.Tool(
55
+ tool_func,
56
+ name=tool_config.tool_id,
57
+ )
58
+
59
+
60
+ def make_mcp_client_toolset(
61
+ toolset_config: config.MCP_ClientToolsetConfig,
62
+ ) -> ai_mcp.MCPServer:
63
+ toolset_klass = mcp_client.TOOLSET_CLASS_BY_KIND[toolset_config.kind]
64
+ return toolset_klass(**toolset_config.tool_kwargs)
65
+
66
+
67
+ def _get_default_agent_from_configs(
68
+ agent_config: config.AgentConfig,
69
+ tool_configs: ToolConfigMap,
70
+ mcp_client_toolset_configs: config.MCP_ClientToolsetConfigMap,
71
+ ) -> SoliplexAgent:
72
+ """Build a Pydantic AI agent from a config"""
73
+ provider_kw = agent_config.llm_provider_kw
74
+
75
+ if agent_config.provider_type == config.LLMProviderType.GOOGLE:
76
+ provider = google_providers.GoogleProvider(**provider_kw)
77
+ model = google_models.GoogleModel(
78
+ model_name=agent_config.model_name,
79
+ provider=provider,
80
+ )
81
+
82
+ elif agent_config.provider_type == config.LLMProviderType.OLLAMA:
83
+ provider_kw["api_key"] = "dummy"
84
+ provider = ollama_providers.OllamaProvider(**provider_kw)
85
+ model = openai_models.OpenAIChatModel(
86
+ model_name=agent_config.model_name,
87
+ provider=provider,
88
+ )
89
+ else:
90
+ provider = openai_providers.OpenAIProvider(**provider_kw)
91
+ model = openai_models.OpenAIChatModel(
92
+ model_name=agent_config.model_name,
93
+ provider=provider,
94
+ )
95
+
96
+ tools = [
97
+ make_ai_tool(tool_config) for tool_config in tool_configs.values()
98
+ ]
99
+ toolsets = [
100
+ make_mcp_client_toolset(mctc)
101
+ for mctc in mcp_client_toolset_configs.values()
102
+ ]
103
+
104
+ return pydantic_ai.Agent(
105
+ model=model,
106
+ model_settings=agent_config.model_settings,
107
+ tools=tools,
108
+ toolsets=toolsets,
109
+ instructions=agent_config.get_system_prompt(),
110
+ deps_type=AgentDependencies,
111
+ )
112
+
113
+
114
+ def get_agent_from_configs(
115
+ agent_config: config.AgentConfig,
116
+ tool_configs: ToolConfigMap,
117
+ mcp_client_toolset_configs: config.MCP_ClientToolsetConfigMap,
118
+ ) -> SoliplexAgent:
119
+ """Get or create an agent from the specified agent and tool configs."""
120
+
121
+ if agent_config.id not in _agent_cache:
122
+ if agent_config.kind == "default":
123
+ agent = _get_default_agent_from_configs(
124
+ agent_config,
125
+ tool_configs,
126
+ mcp_client_toolset_configs,
127
+ )
128
+
129
+ else:
130
+ agent = agent_config.factory(
131
+ tool_configs=tool_configs,
132
+ mcp_client_toolset_configs=mcp_client_toolset_configs,
133
+ )
134
+
135
+ _agent_cache[agent_config.id] = agent
136
+
137
+ return _agent_cache[agent_config.id]
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import collections.abc
5
+ import datetime
6
+ import typing
7
+
8
+ import fastapi
9
+ from ag_ui import core as agui_core
10
+ from sqlalchemy.ext import asyncio as sqla_asyncio
11
+
12
+ AGUI_Events = list[agui_core.Event]
13
+ AGUI_EventStream = collections.abc.AsyncIterator[agui_core.Event]
14
+ AGUI_State = dict[str, typing.Any]
15
+
16
+
17
+ _COMPACTIBLE_TYPES = {
18
+ agui_core.EventType.TEXT_MESSAGE_CONTENT,
19
+ agui_core.EventType.THINKING_TEXT_MESSAGE_CONTENT,
20
+ }
21
+
22
+
23
+ async def compact_event_stream(stream: AGUI_EventStream):
24
+ compacting: agui_core.Event = None
25
+ compacting_id: str = None
26
+
27
+ async for event in stream:
28
+ if compacting is not None:
29
+ event_id = getattr(event, "message_id", None)
30
+ if event.type == compacting.type and event_id == compacting_id:
31
+ compacting.delta += event.delta
32
+ else:
33
+ to_yield, compacting = compacting, None
34
+ yield to_yield
35
+ yield event
36
+
37
+ else:
38
+ if event.type in _COMPACTIBLE_TYPES:
39
+ compacting = event.model_copy()
40
+ compacting_id = getattr(event, "message_id", None)
41
+ else:
42
+ yield event
43
+
44
+
45
+ class AGUI_Exception(ValueError):
46
+ status_code = 400
47
+
48
+
49
+ class UnknownThread(AGUI_Exception):
50
+ status_code = 404
51
+
52
+ def __init__(self, user_name: str, thread_id: str):
53
+ self.user_name = user_name
54
+ self.thread_id = thread_id
55
+ message = f"Unknown thread: UUID {thread_id} for user {user_name}"
56
+ super().__init__(message)
57
+
58
+
59
+ class UnknownRun(AGUI_Exception):
60
+ status_code = 404
61
+
62
+ def __init__(self, run_id: str):
63
+ self.run_id = run_id
64
+ super().__init__(
65
+ f"Unknown run: UUID {run_id} does not exist in thread"
66
+ )
67
+
68
+
69
+ class ThreadRoomMismatch(AGUI_Exception):
70
+ def __init__(self, room_id: str, thread_room_id: str):
71
+ self.room_id = room_id
72
+ self.thread_room_id = thread_room_id
73
+ super().__init__(
74
+ f"Thread room ID '{thread_room_id}' "
75
+ f"does not match room ID '{room_id}'"
76
+ )
77
+
78
+
79
+ class MissingParentRun(AGUI_Exception):
80
+ def __init__(self, parent_run_id: str):
81
+ self.parent_run_id = parent_run_id
82
+ super().__init__(
83
+ f"Unknown parent run: UUID {parent_run_id} "
84
+ f"does not exist in thread"
85
+ )
86
+
87
+
88
+ class RunAlreadyStarted(AGUI_Exception):
89
+ def __init__(self, user_name: str, thread_id: str, run_id: str):
90
+ self.user_name = user_name
91
+ self.thread_id = thread_id
92
+ self.run_id = run_id
93
+ super().__init__(f"Run already started: UUID {run_id}")
94
+
95
+
96
+ #
97
+ # ABCs defined here are notional contracts.
98
+ #
99
+
100
+
101
+ RunUsageStats = collections.namedtuple(
102
+ "RunUsageStats",
103
+ [
104
+ "input_tokens",
105
+ "output_tokens",
106
+ "requests",
107
+ "tool_calls",
108
+ ],
109
+ )
110
+
111
+
112
+ class RunUsage(abc.ABC):
113
+ """LLM usage for a run"""
114
+
115
+ input_tokens: int
116
+ """LLM input tokens consumed"""
117
+
118
+ output_tokens: int
119
+ """LLM output tokens consumed"""
120
+
121
+ requests: int
122
+ """LLM requests made"""
123
+
124
+ tool_calls: int
125
+ """LLM tool_calls made"""
126
+
127
+ @abc.abstractmethod
128
+ def as_tuple(self) -> RunUsageStats:
129
+ """Return values as a tuple."""
130
+
131
+
132
+ class RunMetadata(abc.ABC):
133
+ """User-defined metadata for a run"""
134
+
135
+ label: str
136
+ """Label for a run (similar to a git tag)"""
137
+
138
+
139
+ class Run(abc.ABC):
140
+ """Input data and events for an AGUI run
141
+
142
+ Runs are not accessed directly: use 'ThreadStorage'.
143
+ """
144
+
145
+ thread_id: str
146
+ """ID of the thread in which the run was created"""
147
+
148
+ run_id: str
149
+ """Unique ID for a run"""
150
+
151
+ parent_run_id: str | None
152
+ """ID of the parent run"""
153
+
154
+ run_usage: RunUsage | None
155
+ """Optional LLM usage data for a run"""
156
+
157
+ run_metadata: RunMetadata | None
158
+ """Optional user-defined metadata for a run"""
159
+
160
+ run_input: agui_core.RunAgentInput
161
+ """Input from the client-request which initiates the AG-UI run"""
162
+
163
+ created: datetime.datetime
164
+ """Timestamp"""
165
+
166
+ @abc.abstractmethod
167
+ async def list_events(self) -> AGUI_Events:
168
+ """Return AGUI events for the run"""
169
+
170
+
171
+ class ThreadMetadata(abc.ABC):
172
+ """Optional user-defined thread metadata"""
173
+
174
+ name: str
175
+ """Name for the thread"""
176
+
177
+ description: str
178
+ """Description for the thread"""
179
+
180
+
181
+ class Thread(abc.ABC):
182
+ """Hold a set of AGUI runs sharing the same 'thread_id'
183
+
184
+ Runs are not accessed directly: use 'ThreadStorage'.
185
+ """
186
+
187
+ thread_id: str
188
+ """Unique ID for the thread"""
189
+
190
+ room_id: str
191
+ """ID for room in which the thread was created"""
192
+
193
+ thread_metadata: ThreadMetadata | None
194
+ """Optional thread metadata"""
195
+
196
+ created: datetime.datetime
197
+ """Timestamp"""
198
+
199
+ @abc.abstractmethod
200
+ async def list_runs(self) -> list[Run]:
201
+ """Return runs for this thread"""
202
+
203
+
204
+ class ThreadStorage(abc.ABC):
205
+ @abc.abstractmethod
206
+ async def list_user_threads(
207
+ self,
208
+ *,
209
+ user_name: str,
210
+ room_id: str = None,
211
+ ) -> list[Thread]:
212
+ """Return a list of the user's threads.
213
+
214
+ If 'room_id' is passed, filter the threads to those created
215
+ in that room.
216
+ """
217
+
218
+ @abc.abstractmethod
219
+ async def get_thread(
220
+ self,
221
+ *,
222
+ user_name: str,
223
+ room_id: str,
224
+ thread_id: str,
225
+ ) -> Thread:
226
+ """Return the actual thread instance
227
+
228
+ N.B.: caller must treat the instance as read-only!
229
+ """
230
+
231
+ @abc.abstractmethod
232
+ async def new_thread(
233
+ self,
234
+ *,
235
+ user_name: str,
236
+ room_id: str,
237
+ thread_metadata: ThreadMetadata | dict = None,
238
+ initial_run: bool = True,
239
+ ) -> Thread:
240
+ """Create a new thread
241
+
242
+ If 'thread_metadata' is a dict, convert it to a 'ThreadMetadata'
243
+ instance.
244
+ """
245
+
246
+ @abc.abstractmethod
247
+ async def update_thread_metadata(
248
+ self,
249
+ *,
250
+ user_name: str,
251
+ room_id: str,
252
+ thread_id: str,
253
+ thread_metadata: ThreadMetadata | dict = None,
254
+ ) -> Thread:
255
+ """Update thread instance with the given metadata, or None
256
+
257
+ If 'thread_metadata' is a dict, convert it to a 'ThreadMetadata'
258
+ instance.
259
+
260
+ If 'thread_metadata' is None, or an empty dict, remove any existing
261
+ metadata on the thread.
262
+ """
263
+
264
+ @abc.abstractmethod
265
+ async def delete_thread(
266
+ self,
267
+ *,
268
+ user_name: str,
269
+ room_id: str,
270
+ thread_id: str,
271
+ ) -> None:
272
+ """Remove a thread"""
273
+
274
+ @abc.abstractmethod
275
+ async def new_run(
276
+ self,
277
+ *,
278
+ user_name: str,
279
+ room_id: str,
280
+ thread_id: str,
281
+ run_metadata: RunMetadata = None,
282
+ parent_run_id: str = None,
283
+ ) -> Run:
284
+ """Create a new run for the thread
285
+
286
+ If 'run_metadata' is a dict, convert it to a 'RunMetadata' instance.
287
+
288
+ If 'parent_run_id' is passed, ensure it is valid.
289
+ """
290
+
291
+ @abc.abstractmethod
292
+ async def get_run(
293
+ self,
294
+ user_name: str,
295
+ room_id: str,
296
+ thread_id: str,
297
+ run_id: str,
298
+ ) -> Run:
299
+ """Return an existing run for a thread"""
300
+
301
+ @abc.abstractmethod
302
+ async def add_run_input(
303
+ self,
304
+ *,
305
+ user_name: str,
306
+ room_id: str,
307
+ thread_id: str,
308
+ run_id: str,
309
+ run_input: agui_core.RunAgentInput,
310
+ ) -> Run:
311
+ """Update a run with the given 'run_agent_input'"""
312
+
313
+ @abc.abstractmethod
314
+ async def update_run_metadata(
315
+ self,
316
+ *,
317
+ user_name: str,
318
+ room_id: str,
319
+ thread_id: str,
320
+ run_id: str,
321
+ run_metadata: RunMetadata | dict = None,
322
+ ) -> Run:
323
+ """Update a run with the given metadata
324
+
325
+ If 'run_metadata' is a dict, convert it to a 'RunMetadata' instance.
326
+
327
+ If 'run_metadata' is None, or an empty dict, remove any existing
328
+ metadata on the run.
329
+ """
330
+
331
+ @abc.abstractmethod
332
+ async def save_run_events(
333
+ self,
334
+ *,
335
+ user_name: str,
336
+ room_id: str,
337
+ thread_id: str,
338
+ run_id: str,
339
+ events: AGUI_Events,
340
+ ) -> AGUI_Events:
341
+ """Save the events for a gven run"""
342
+
343
+ @abc.abstractmethod
344
+ async def save_run_usage(
345
+ self,
346
+ *,
347
+ user_name: str,
348
+ room_id: str,
349
+ thread_id: str,
350
+ run_id: str,
351
+ input_tokens: int,
352
+ output_tokens: int,
353
+ requests: int,
354
+ tool_calls: int,
355
+ ):
356
+ """Save the run usage statistics"""
357
+
358
+ @abc.abstractmethod
359
+ async def save_run_feedback(
360
+ self,
361
+ *,
362
+ user_name: str,
363
+ room_id: str,
364
+ thread_id: str,
365
+ run_id: str,
366
+ feedback: str,
367
+ reason: str,
368
+ ):
369
+ """Save the run feedback"""
370
+
371
+
372
+ async def get_the_threads(request: fastapi.Request) -> ThreadStorage:
373
+ from . import persistence
374
+
375
+ engine = request.state.threads_engine
376
+ async with sqla_asyncio.AsyncSession(bind=engine) as session:
377
+ yield persistence.ThreadStorage(session)
378
+
379
+
380
+ depend_the_threads = fastapi.Depends(get_the_threads)
@@ -0,0 +1,47 @@
1
+ """Features defined by soliplex"""
2
+
3
+ import pydantic
4
+ from haiku.rag.agents.chat import state as hr_chat_state
5
+ from haiku.rag.agents.research import models as hr_graph_models
6
+
7
+ FILTER_DOCUMENTS_FEATURE = "filter_documents"
8
+ ASK_HISTORY_FEATURE = "ask_history"
9
+ HAIKU_CHAT_FEATURE = hr_chat_state.AGUI_STATE_KEY
10
+
11
+ KW_ONLY_NONE = pydantic.Field(kw_only=True, default=None)
12
+
13
+
14
+ class FilterDocuments(pydantic.BaseModel):
15
+ """Documents selected by the user to be used to answer a question
16
+
17
+ This model describes the 'filter_documents' key in the AG-UI state.
18
+
19
+ If 'document_ids' is empty or None, or if the 'filter_documents'
20
+ key is not present in the AG-UI state, no filter is applied: the
21
+ 'ask_with_rich_citations' tool will return all documents matching
22
+ the query from the LLM.
23
+ """
24
+
25
+ document_ids: list[str] | None = KW_ONLY_NONE
26
+
27
+
28
+ class QuestionResponseCitations(pydantic.BaseModel):
29
+ """Single question to the 'ask_with_rich_citations' tool
30
+
31
+ Includes response generated by the tool and accompanying citations.
32
+ """
33
+
34
+ question: str
35
+ response: str
36
+ citations: list[hr_graph_models.Citation] = []
37
+
38
+
39
+ class AskedAndAnswered(pydantic.BaseModel):
40
+ """History of questions asked and their replies + citations
41
+
42
+ This model describes the 'ask_history' key in the AG-UI state.
43
+
44
+ Each call to the 'ask_with_rich_citations' took append
45
+ """
46
+
47
+ questions: list[QuestionResponseCitations] = []