soliplex 0.85.4__tar.gz → 0.86__tar.gz
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.
- {soliplex-0.85.4/src/soliplex.egg-info → soliplex-0.86}/PKG-INFO +2 -2
- {soliplex-0.85.4 → soliplex-0.86}/pyproject.toml +3 -2
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/__init__.py +1 -63
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/persistence.py +4 -2
- soliplex-0.86/src/soliplex/agui/sse_delivery.py +287 -0
- soliplex-0.86/src/soliplex/alembic_migrations/versions/f13fea6c1953_soliplex_v0_86.py +64 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authn.py +123 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authz/__init__.py +52 -8
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authz/persistence.py +130 -58
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authz/schema.py +40 -10
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/ask.py +2 -1
- soliplex-0.86/src/soliplex/cli/audit/admin_users.py +334 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/installation.py +30 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/room_authz.py +70 -31
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/serve.py +1 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/authsystem.py +19 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/installation.py +73 -10
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/rooms.py +67 -0
- soliplex-0.86/src/soliplex/config/sse_delivery.py +261 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/installation.py +50 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/loggers.py +22 -5
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/main.py +2 -1
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/models.py +72 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/__init__.py +60 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/agui.py +60 -3
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/authn.py +180 -28
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/authz.py +71 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/installation.py +11 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/room_file_uploads.py +32 -10
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/templates/consent.html.mako +1 -1
- soliplex-0.86/src/soliplex/views/templates/cookie-too-large.html.mako +90 -0
- {soliplex-0.85.4 → soliplex-0.86/src/soliplex.egg-info}/PKG-INFO +2 -2
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/SOURCES.txt +5 -1
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/requires.txt +1 -1
- soliplex-0.85.4/src/soliplex/cli/audit/admin_users.py +0 -142
- {soliplex-0.85.4 → soliplex-0.86}/LICENSE +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/MANIFEST.in +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/README.md +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/example/skills/bare-bones/SKILL.md +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/example/skills/stanzas/SKILL.md +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/setup.cfg +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/skills/soliplex-docs/SKILL.md +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agents.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/parser.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/schema.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/util.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/__init__.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/env.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/216d48e1e2e5_soliplex_v0_53.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/4b63e5f3f39e_soliplex_v0_51_1.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/63edaa5987f6_soliplex_v0_67.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/a1c7d3e90b42_soliplex_v0_80.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/b7e2f41c9d05_soliplex_v0_82.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/d5009d4f9874_soliplex_v0_44.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/capabilities/filesystem.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/capabilities/rag_audit.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/admin_users.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/__init__.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/_common.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/completions.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/databases.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/environment.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/interpolation.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/logfire.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/logging.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/oidc.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/ollama.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/quizzes.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/room_authz.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/rooms.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/secrets.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/skills.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/cli_util.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/database.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/main.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/ollama.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/types.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/completions.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/__init__.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/_utils.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/agents.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/agui.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/completions.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/exceptions.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/interpolation.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/logfire.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/meta.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/middleware.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/quizzes.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/rag.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/routing.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/secrets.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/skills.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/tools.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/examples.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/log_ingest.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/mcp_auth.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/mcp_client.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/mcp_server.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/ollama.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/quizzes.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/rag_audit.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/sandbox_audit.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/secrets.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/titles.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/__init__.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/agui_run_feedback.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/file_uploads.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/rag.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/cli.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/main.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/rest_api.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/serve.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/util.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/completions.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/log_ingest.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/quizzes.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/rooms.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/sandbox_workdirs.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/stats.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/streaming.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/thread_file_uploads.py +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/dependency_links.txt +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/entry_points.txt +0 -0
- {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: soliplex
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.86
|
|
4
4
|
Summary: An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface.
|
|
5
5
|
Author-email: Enfold <info@enfoldsystems.net>
|
|
6
6
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -20,7 +20,7 @@ Requires-Dist: fastapi
|
|
|
20
20
|
Requires-Dist: fastmcp<5.0,>=4.0.2
|
|
21
21
|
Requires-Dist: fakeredis!=2.35.0
|
|
22
22
|
Requires-Dist: greenlet
|
|
23
|
-
Requires-Dist: haiku.rag-slim<0.
|
|
23
|
+
Requires-Dist: haiku.rag-slim<0.99,>=0.98.0
|
|
24
24
|
Requires-Dist: idna>=3.15
|
|
25
25
|
Requires-Dist: itsdangerous
|
|
26
26
|
Requires-Dist: joserfc<1.7,>=1.6.7
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "soliplex"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.86"
|
|
8
8
|
description = "An AI-powered Retrieval-Augmented Generation (RAG) system with a modern web interface."
|
|
9
9
|
authors = [{ name = "Enfold", email = "info@enfoldsystems.net" }]
|
|
10
10
|
readme = "README.md"
|
|
@@ -26,7 +26,7 @@ dependencies = [
|
|
|
26
26
|
"fastmcp >= 4.0.2, < 5.0",
|
|
27
27
|
"fakeredis != 2.35.0", # brownbag
|
|
28
28
|
"greenlet",
|
|
29
|
-
"haiku.rag-slim >= 0.
|
|
29
|
+
"haiku.rag-slim >= 0.98.0, < 0.99",
|
|
30
30
|
"idna >= 3.15",
|
|
31
31
|
"itsdangerous",
|
|
32
32
|
"joserfc >= 1.6.7, < 1.7", # CVE-2026-48990
|
|
@@ -111,6 +111,7 @@ filterwarnings = [
|
|
|
111
111
|
]
|
|
112
112
|
markers = [
|
|
113
113
|
"needs_llm: functest requiring an LLM",
|
|
114
|
+
"sse_timing: functest asserting loopback SSE delivery timing",
|
|
114
115
|
]
|
|
115
116
|
|
|
116
117
|
[tool.coverage.run]
|
|
@@ -1,39 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import abc
|
|
4
|
-
import collections
|
|
4
|
+
import collections
|
|
5
5
|
import datetime
|
|
6
6
|
import enum
|
|
7
7
|
import typing
|
|
8
8
|
|
|
9
9
|
import fastapi
|
|
10
10
|
from ag_ui import core as agui_core
|
|
11
|
-
from pydantic_ai import ui as ai_ui
|
|
12
11
|
from sqlalchemy.ext import asyncio as sqla_asyncio
|
|
13
12
|
|
|
14
13
|
AGUI_Events = list[agui_core.Event]
|
|
15
|
-
AGUI_EventStream = collections.abc.AsyncIterator[agui_core.Event]
|
|
16
14
|
AGUI_State = dict[str, typing.Any]
|
|
17
15
|
|
|
18
16
|
|
|
19
|
-
_COMPACTIBLE_TYPES = { # event_type: compacting_attr
|
|
20
|
-
agui_core.EventType.TEXT_MESSAGE_CONTENT: "message_id",
|
|
21
|
-
agui_core.EventType.THINKING_TEXT_MESSAGE_CONTENT: "message_id",
|
|
22
|
-
agui_core.EventType.REASONING_MESSAGE_CONTENT: "message_id",
|
|
23
|
-
agui_core.EventType.TOOL_CALL_ARGS: "tool_call_id",
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
# Events which terminate a run's event stream
|
|
28
|
-
#
|
|
29
|
-
TERMINAL_EVENT_TYPES = frozenset(
|
|
30
|
-
[
|
|
31
|
-
agui_core.EventType.RUN_FINISHED,
|
|
32
|
-
agui_core.EventType.RUN_ERROR,
|
|
33
|
-
]
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
|
|
37
17
|
class AGUI_Exception(ValueError):
|
|
38
18
|
status_code = 400
|
|
39
19
|
|
|
@@ -617,48 +597,6 @@ class ThreadStorage(abc.ABC):
|
|
|
617
597
|
) -> RunFeedbackReviewEntry: ...
|
|
618
598
|
|
|
619
599
|
|
|
620
|
-
async def compact_event_stream(stream: AGUI_EventStream):
|
|
621
|
-
compacting: agui_core.Event = None
|
|
622
|
-
compacting_id: str = None
|
|
623
|
-
compacting_attr: str = None
|
|
624
|
-
|
|
625
|
-
async for event in stream:
|
|
626
|
-
if compacting is not None:
|
|
627
|
-
event_id = getattr(event, compacting_attr, None)
|
|
628
|
-
if event.type == compacting.type and event_id == compacting_id:
|
|
629
|
-
compacting.delta += event.delta
|
|
630
|
-
else:
|
|
631
|
-
to_yield, compacting = compacting, None
|
|
632
|
-
yield to_yield
|
|
633
|
-
yield event
|
|
634
|
-
|
|
635
|
-
else:
|
|
636
|
-
compacting_attr = _COMPACTIBLE_TYPES.get(event.type)
|
|
637
|
-
if compacting_attr is not None:
|
|
638
|
-
compacting = event.model_copy()
|
|
639
|
-
compacting_id = getattr(event, compacting_attr, None)
|
|
640
|
-
else:
|
|
641
|
-
yield event
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
async def with_final_state(
|
|
645
|
-
*,
|
|
646
|
-
stream: AGUI_EventStream,
|
|
647
|
-
deps: ai_ui.StateHandler | None,
|
|
648
|
-
) -> AGUI_EventStream:
|
|
649
|
-
"""Yield events from 'stream', adding a final AG-UI state snapshot.
|
|
650
|
-
|
|
651
|
-
The snapshot precedes the run's terminal event: AG-UI clients may stop
|
|
652
|
-
reading after a terminal event, and on replay the parser only accepts a
|
|
653
|
-
snapshot while the run is still in the RUNNING state.
|
|
654
|
-
"""
|
|
655
|
-
async for event in stream:
|
|
656
|
-
if deps is not None and event.type in TERMINAL_EVENT_TYPES:
|
|
657
|
-
yield agui_core.StateSnapshotEvent(snapshot=deps.state)
|
|
658
|
-
|
|
659
|
-
yield event
|
|
660
|
-
|
|
661
|
-
|
|
662
600
|
async def get_the_threads(request: fastapi.Request) -> ThreadStorage:
|
|
663
601
|
from . import persistence
|
|
664
602
|
|
|
@@ -14,6 +14,7 @@ from sqlalchemy.ext import asyncio as sqla_asyncio
|
|
|
14
14
|
from soliplex import agui
|
|
15
15
|
from soliplex.agui import persistence as agui_persistence
|
|
16
16
|
from soliplex.agui import schema as agui_schema
|
|
17
|
+
from soliplex.agui import sse_delivery as agui_sse_delivery
|
|
17
18
|
from soliplex.agui import util as agui_util
|
|
18
19
|
|
|
19
20
|
FeedbackReviewStatus = agui.FeedbackReviewStatus
|
|
@@ -341,6 +342,7 @@ class ThreadStorage(agui.ThreadStorage):
|
|
|
341
342
|
await run.awaitable_attrs.events
|
|
342
343
|
await run.awaitable_attrs.run_agent_input
|
|
343
344
|
await run.awaitable_attrs.run_metadata
|
|
345
|
+
await run.awaitable_attrs.parent
|
|
344
346
|
|
|
345
347
|
return run
|
|
346
348
|
|
|
@@ -1025,11 +1027,11 @@ async def drive_agui_turn(
|
|
|
1025
1027
|
``run_stream_kwargs`` is forwarded verbatim to the Pydantic AI adapter.
|
|
1026
1028
|
"""
|
|
1027
1029
|
stream_kwargs = run_stream_kwargs or {}
|
|
1028
|
-
event_stream =
|
|
1030
|
+
event_stream = agui_sse_delivery.with_final_state(
|
|
1029
1031
|
stream=adapter.run_stream(**stream_kwargs),
|
|
1030
1032
|
deps=stream_kwargs.get("deps"),
|
|
1031
1033
|
)
|
|
1032
|
-
compacted =
|
|
1034
|
+
compacted = agui_sse_delivery.compact_event_stream(event_stream)
|
|
1033
1035
|
async for event in compacted:
|
|
1034
1036
|
try:
|
|
1035
1037
|
await save_single_event(
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# AG-UI event-stream delivery shaping machinery
|
|
3
|
+
# ============================================================================
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import collections.abc
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import logfire
|
|
10
|
+
from ag_ui import core as agui_core
|
|
11
|
+
from pydantic_ai import ui as ai_ui
|
|
12
|
+
|
|
13
|
+
from soliplex.config import sse_delivery as config_sse_delivery
|
|
14
|
+
|
|
15
|
+
AGUI_EventStream = collections.abc.AsyncIterator[agui_core.Event]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_COMPACTIBLE_TYPES = { # event_type: compacting_attr
|
|
19
|
+
agui_core.EventType.TEXT_MESSAGE_CONTENT: "message_id",
|
|
20
|
+
agui_core.EventType.THINKING_TEXT_MESSAGE_CONTENT: "message_id",
|
|
21
|
+
agui_core.EventType.REASONING_MESSAGE_CONTENT: "message_id",
|
|
22
|
+
agui_core.EventType.TOOL_CALL_ARGS: "tool_call_id",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#
|
|
26
|
+
# Events which terminate a run's event stream
|
|
27
|
+
#
|
|
28
|
+
TERMINAL_EVENT_TYPES = frozenset(
|
|
29
|
+
[
|
|
30
|
+
agui_core.EventType.RUN_FINISHED,
|
|
31
|
+
agui_core.EventType.RUN_ERROR,
|
|
32
|
+
]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def compact_event_stream(stream: AGUI_EventStream):
|
|
37
|
+
compacting: agui_core.Event = None
|
|
38
|
+
compacting_id: str = None
|
|
39
|
+
compacting_attr: str = None
|
|
40
|
+
|
|
41
|
+
async for event in stream:
|
|
42
|
+
if compacting is not None:
|
|
43
|
+
event_id = getattr(event, compacting_attr, None)
|
|
44
|
+
if event.type == compacting.type and event_id == compacting_id:
|
|
45
|
+
compacting.delta += event.delta
|
|
46
|
+
else:
|
|
47
|
+
to_yield, compacting = compacting, None
|
|
48
|
+
yield to_yield
|
|
49
|
+
yield event
|
|
50
|
+
|
|
51
|
+
else:
|
|
52
|
+
compacting_attr = _COMPACTIBLE_TYPES.get(event.type)
|
|
53
|
+
if compacting_attr is not None:
|
|
54
|
+
compacting = event.model_copy()
|
|
55
|
+
compacting_id = getattr(event, compacting_attr, None)
|
|
56
|
+
else:
|
|
57
|
+
yield event
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_hold_clock = time.monotonic
|
|
61
|
+
_wait = asyncio.wait
|
|
62
|
+
_TIMEOUT = object()
|
|
63
|
+
_BACKGROUND_PUMPS: set[asyncio.Task] = set()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _PumpState:
|
|
67
|
+
def __init__(self):
|
|
68
|
+
self.closing = False
|
|
69
|
+
self.error: Exception | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _pump(
|
|
73
|
+
stream: AGUI_EventStream,
|
|
74
|
+
queue: asyncio.Queue,
|
|
75
|
+
state: _PumpState,
|
|
76
|
+
) -> _PumpState:
|
|
77
|
+
"""Iterate and close 'stream' in this one task, putting events on 'queue'
|
|
78
|
+
|
|
79
|
+
Returns 'state', holding any error from iterating or closing the
|
|
80
|
+
stream. 'state.closing' is set before closing, after which the task is
|
|
81
|
+
no longer cancelled. If the task is cancelled, the error is logged, as
|
|
82
|
+
nobody will read it.
|
|
83
|
+
"""
|
|
84
|
+
cancelled = False
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
async for event in stream:
|
|
88
|
+
await queue.put(event)
|
|
89
|
+
except asyncio.CancelledError:
|
|
90
|
+
cancelled = True
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
state.error = exc
|
|
93
|
+
|
|
94
|
+
state.closing = True
|
|
95
|
+
aclose = getattr(stream, "aclose", None)
|
|
96
|
+
if aclose is not None:
|
|
97
|
+
try:
|
|
98
|
+
await aclose()
|
|
99
|
+
except Exception as close_exc:
|
|
100
|
+
if state.error is None:
|
|
101
|
+
state.error = close_exc
|
|
102
|
+
else:
|
|
103
|
+
state.error.add_note(
|
|
104
|
+
f"Closing the stream also failed: {close_exc!r}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
if cancelled or asyncio.current_task().cancelling():
|
|
108
|
+
if state.error is not None:
|
|
109
|
+
logfire.error(
|
|
110
|
+
"AG-UI event stream failed after cancellation: {error!r}",
|
|
111
|
+
error=state.error,
|
|
112
|
+
)
|
|
113
|
+
raise asyncio.CancelledError
|
|
114
|
+
|
|
115
|
+
return state
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _log_undelivered(error: Exception):
|
|
119
|
+
logfire.error(
|
|
120
|
+
"AG-UI event stream failed after its consumer closed: {error!r}",
|
|
121
|
+
error=error,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _forget_background_pump(pump: asyncio.Task):
|
|
126
|
+
"""Drop a pump left closing by a cancelled consumer, logging its error"""
|
|
127
|
+
_BACKGROUND_PUMPS.discard(pump)
|
|
128
|
+
if not pump.cancelled() and pump.exception() is None:
|
|
129
|
+
error = pump.result().error
|
|
130
|
+
if error is not None:
|
|
131
|
+
_log_undelivered(error)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _next_item(queue: asyncio.Queue, pump: asyncio.Future, timeout):
|
|
135
|
+
"""Return the next queued event, else the pump's result once it is done
|
|
136
|
+
|
|
137
|
+
Returns '_TIMEOUT' if neither arrives within 'timeout' seconds.
|
|
138
|
+
"""
|
|
139
|
+
if queue.empty() and not pump.done():
|
|
140
|
+
getter = asyncio.ensure_future(queue.get())
|
|
141
|
+
try:
|
|
142
|
+
await _wait(
|
|
143
|
+
{getter, pump},
|
|
144
|
+
timeout=timeout,
|
|
145
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
146
|
+
)
|
|
147
|
+
finally:
|
|
148
|
+
if not getter.done():
|
|
149
|
+
getter.cancel()
|
|
150
|
+
await asyncio.wait({getter})
|
|
151
|
+
|
|
152
|
+
if not getter.cancelled():
|
|
153
|
+
return getter.result()
|
|
154
|
+
|
|
155
|
+
if not queue.empty():
|
|
156
|
+
return queue.get_nowait()
|
|
157
|
+
|
|
158
|
+
if pump.done():
|
|
159
|
+
if pump.cancelled():
|
|
160
|
+
raise asyncio.CancelledError
|
|
161
|
+
return pump.result()
|
|
162
|
+
|
|
163
|
+
return _TIMEOUT
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def coalesce_event_stream(
|
|
167
|
+
stream: AGUI_EventStream,
|
|
168
|
+
*,
|
|
169
|
+
max_deltas: int,
|
|
170
|
+
max_bytes: int,
|
|
171
|
+
max_ms: int,
|
|
172
|
+
) -> AGUI_EventStream:
|
|
173
|
+
"""Merge deltas as 'compact_event_stream' does, within bounds
|
|
174
|
+
|
|
175
|
+
The merged event is yielded once it holds 'max_deltas' deltas, or
|
|
176
|
+
'max_bytes' bytes of UTF-8 text, or has been held about 'max_ms'
|
|
177
|
+
milliseconds (a target, checked when the event loop runs this
|
|
178
|
+
generator), or when any other event arrives. The upstream is
|
|
179
|
+
iterated and closed in a separate task, so the time bound applies
|
|
180
|
+
while the upstream is quiet.
|
|
181
|
+
"""
|
|
182
|
+
queue = asyncio.Queue(maxsize=1)
|
|
183
|
+
state = _PumpState()
|
|
184
|
+
pump = asyncio.create_task(_pump(stream, queue, state))
|
|
185
|
+
delivered = False
|
|
186
|
+
held = held_attr = held_id = None
|
|
187
|
+
held_count = 0
|
|
188
|
+
deadline = 0.0
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
while True:
|
|
192
|
+
timeout = None
|
|
193
|
+
if held is not None:
|
|
194
|
+
timeout = max(0.0, deadline - _hold_clock())
|
|
195
|
+
|
|
196
|
+
item = await _next_item(queue, pump, timeout)
|
|
197
|
+
|
|
198
|
+
if held is not None and _hold_clock() >= deadline:
|
|
199
|
+
to_yield, held = held, None
|
|
200
|
+
yield to_yield
|
|
201
|
+
|
|
202
|
+
if item is _TIMEOUT:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
if item is state:
|
|
206
|
+
if held is not None:
|
|
207
|
+
yield held
|
|
208
|
+
delivered = True
|
|
209
|
+
if state.error is not None:
|
|
210
|
+
raise state.error
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
held is not None
|
|
215
|
+
and item.type == held.type
|
|
216
|
+
and getattr(item, held_attr, None) == held_id
|
|
217
|
+
):
|
|
218
|
+
held.delta += item.delta
|
|
219
|
+
held_count += 1
|
|
220
|
+
else:
|
|
221
|
+
if held is not None:
|
|
222
|
+
to_yield, held = held, None
|
|
223
|
+
yield to_yield
|
|
224
|
+
|
|
225
|
+
held_attr = _COMPACTIBLE_TYPES.get(item.type)
|
|
226
|
+
if held_attr is None:
|
|
227
|
+
yield item
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
held = item.model_copy()
|
|
231
|
+
held_id = getattr(item, held_attr, None)
|
|
232
|
+
held_count = 1
|
|
233
|
+
deadline = _hold_clock() + max_ms / 1000
|
|
234
|
+
|
|
235
|
+
if (
|
|
236
|
+
held_count >= max_deltas
|
|
237
|
+
or len(held.delta.encode("utf-8")) >= max_bytes
|
|
238
|
+
):
|
|
239
|
+
to_yield, held = held, None
|
|
240
|
+
yield to_yield
|
|
241
|
+
|
|
242
|
+
finally:
|
|
243
|
+
if not state.closing:
|
|
244
|
+
pump.cancel()
|
|
245
|
+
try:
|
|
246
|
+
await asyncio.shield(pump)
|
|
247
|
+
except asyncio.CancelledError:
|
|
248
|
+
if asyncio.current_task().cancelling():
|
|
249
|
+
_BACKGROUND_PUMPS.add(pump)
|
|
250
|
+
pump.add_done_callback(_forget_background_pump)
|
|
251
|
+
raise
|
|
252
|
+
else:
|
|
253
|
+
if not delivered and state.error is not None:
|
|
254
|
+
_log_undelivered(state.error)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def apply_delivery_strategy(
|
|
258
|
+
stream: AGUI_EventStream,
|
|
259
|
+
delivery: config_sse_delivery.SSEDeliveryConfig,
|
|
260
|
+
) -> AGUI_EventStream:
|
|
261
|
+
if delivery.strategy == config_sse_delivery.SSEDeliveryStrategy.BOUNDED:
|
|
262
|
+
return coalesce_event_stream(
|
|
263
|
+
stream,
|
|
264
|
+
max_deltas=delivery.max_deltas,
|
|
265
|
+
max_bytes=delivery.max_bytes,
|
|
266
|
+
max_ms=delivery.max_ms,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return compact_event_stream(stream)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
async def with_final_state(
|
|
273
|
+
*,
|
|
274
|
+
stream: AGUI_EventStream,
|
|
275
|
+
deps: ai_ui.StateHandler | None,
|
|
276
|
+
) -> AGUI_EventStream:
|
|
277
|
+
"""Yield events from 'stream', adding a final AG-UI state snapshot.
|
|
278
|
+
|
|
279
|
+
The snapshot precedes the run's terminal event: AG-UI clients may stop
|
|
280
|
+
reading after a terminal event, and on replay the parser only accepts a
|
|
281
|
+
snapshot while the run is still in the RUNNING state.
|
|
282
|
+
"""
|
|
283
|
+
async for event in stream:
|
|
284
|
+
if deps is not None and event.type in TERMINAL_EVENT_TYPES:
|
|
285
|
+
yield agui_core.StateSnapshotEvent(snapshot=deps.state)
|
|
286
|
+
|
|
287
|
+
yield event
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""soliplex-v0.86
|
|
2
|
+
|
|
3
|
+
Authz: add 'ACLEntry.permission', with new type 'Permission'
|
|
4
|
+
|
|
5
|
+
Revision ID: f13fea6c1953
|
|
6
|
+
Revises: b7e2f41c9d05
|
|
7
|
+
Create Date: 2026-10-20 12:04:57.539891
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
from alembic import op
|
|
15
|
+
|
|
16
|
+
# revision identifiers, used by Alembic.
|
|
17
|
+
revision: str = "f13fea6c1953"
|
|
18
|
+
down_revision: str | typing.Sequence[str] | None = "b7e2f41c9d05"
|
|
19
|
+
branch_labels: str | typing.Sequence[str] | None = None
|
|
20
|
+
depends_on: str | typing.Sequence[str] | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def upgrade(engine_name: str) -> None:
|
|
24
|
+
"""Upgrade schema."""
|
|
25
|
+
globals()[f"upgrade_{engine_name}"]()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def downgrade(engine_name: str) -> None:
|
|
29
|
+
"""Downgrade schema."""
|
|
30
|
+
globals()[f"downgrade_{engine_name}"]()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def upgrade_agui() -> None:
|
|
34
|
+
"""Upgrade agui schema."""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def downgrade_agui() -> None:
|
|
39
|
+
"""Downgrade agui schema."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def upgrade_authz() -> None:
|
|
44
|
+
"""Upgrade authz schema."""
|
|
45
|
+
# Postgres: 'add_column' does not emit 'CREATE TYPE' the way
|
|
46
|
+
# 'create_table' does
|
|
47
|
+
if op.get_context().dialect.name == "postgresql":
|
|
48
|
+
op.execute("CREATE TYPE permission AS ENUM ('READ', 'WRITE', 'ADMIN')")
|
|
49
|
+
op.add_column(
|
|
50
|
+
"room_acl_entries",
|
|
51
|
+
sa.Column(
|
|
52
|
+
"permission",
|
|
53
|
+
sa.Enum("READ", "WRITE", "ADMIN", name="permission"),
|
|
54
|
+
server_default="READ",
|
|
55
|
+
nullable=False,
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def downgrade_authz() -> None:
|
|
61
|
+
"""Downgrade authz schema."""
|
|
62
|
+
op.drop_column("room_acl_entries", "permission")
|
|
63
|
+
if op.get_context().dialect.name == "postgresql":
|
|
64
|
+
op.execute("DROP TYPE permission")
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""Soliplex authentication support"""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
3
5
|
import typing
|
|
4
6
|
|
|
5
7
|
import fastapi
|
|
8
|
+
import itsdangerous
|
|
6
9
|
import jwt
|
|
7
10
|
import starlette.config
|
|
8
11
|
from authlib.integrations import starlette_client
|
|
@@ -26,6 +29,18 @@ _oauth = None
|
|
|
26
29
|
JWT_VALIDATION_NO_TOKEN = "JWT validation failed (no token)"
|
|
27
30
|
JWT_VALIDATION_INVALID_TOKEN = "JWT validation failed (invalid token)"
|
|
28
31
|
|
|
32
|
+
INSECURE_COOKIES_ENV_VAR = "_SOLIPLEX_INSECURE_SESSION_COOKIE"
|
|
33
|
+
|
|
34
|
+
AUTHN_COOKIE_NAME = "soliplex_authn"
|
|
35
|
+
AUTHN_COOKIE_SALT = "soliplex.authn.cookie"
|
|
36
|
+
AUTHN_COOKIE_SECRET_NAME = "SESSION_MIDDLEWARE_TOKEN"
|
|
37
|
+
AUTHN_COOKIE_ALWAYS_CLAIMS = ("sub",)
|
|
38
|
+
# Browsers reject cookies whose name+value exceeds 4096 bytes (RFC 6265bis;
|
|
39
|
+
# enforced by Chrome and Firefox, and no more generous in Safari in
|
|
40
|
+
# practice). 'soliplex_authn' consumes 14 of them, leaving the round 4000
|
|
41
|
+
# as the budget for the serialized value alone.
|
|
42
|
+
AUTHN_COOKIE_MAX_SIZE = 4000
|
|
43
|
+
|
|
29
44
|
|
|
30
45
|
def _get_authsystem_config(
|
|
31
46
|
*,
|
|
@@ -153,3 +168,111 @@ def validate_access_token(
|
|
|
153
168
|
return None, f"{loggers.AUTHN_JWT_BIND_CLAIM}:{claim}"
|
|
154
169
|
|
|
155
170
|
return payload, None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def secure_cookies() -> bool:
|
|
174
|
+
"""Should Soliplex's cookies carry the 'Secure' attribute?
|
|
175
|
+
|
|
176
|
+
'True' unless the '_SOLIPLEX_INSECURE_SESSION_COOKIE' environment
|
|
177
|
+
variable is 'Y'.
|
|
178
|
+
"""
|
|
179
|
+
return os.environ.get(INSECURE_COOKIES_ENV_VAR) != "Y"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _authn_cookie_serializer(
|
|
183
|
+
the_installation: installation.Installation,
|
|
184
|
+
) -> itsdangerous.URLSafeSerializer:
|
|
185
|
+
secret_key = the_installation.get_secret(AUTHN_COOKIE_SECRET_NAME)
|
|
186
|
+
return itsdangerous.URLSafeSerializer(secret_key, salt=AUTHN_COOKIE_SALT)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def set_authn_cookie(
|
|
190
|
+
response: fastapi.Response,
|
|
191
|
+
*,
|
|
192
|
+
the_installation: installation.Installation,
|
|
193
|
+
auth_system: config_authsystem.OIDCAuthSystemConfig,
|
|
194
|
+
claims: UserClaims,
|
|
195
|
+
logger: loggers.LogWrapper,
|
|
196
|
+
) -> bool:
|
|
197
|
+
"""Set the signed authn cookie on 'response'
|
|
198
|
+
|
|
199
|
+
The cookie holds the claims named in 'AUTHN_COOKIE_ALWAYS_CLAIMS' and
|
|
200
|
+
in 'auth_system.cookie_claims' (those present in 'claims'), and
|
|
201
|
+
expires at 'claims["exp"]'. Returns True when the cookie was set.
|
|
202
|
+
|
|
203
|
+
A value larger than 'AUTHN_COOKIE_MAX_SIZE' is logged as an error,
|
|
204
|
+
and the cookie is cleared from the response rather than set: the
|
|
205
|
+
browser ignores an oversized 'Set-Cookie' entirely (see
|
|
206
|
+
'AUTHN_COOKIE_MAX_SIZE'), so merely not setting it would leave
|
|
207
|
+
whatever 'soliplex_authn' cookie the browser already holds --
|
|
208
|
+
possibly left by a different visitor's sign-in -- authorizing the
|
|
209
|
+
wrong person until it expires.
|
|
210
|
+
"""
|
|
211
|
+
names = (*AUTHN_COOKIE_ALWAYS_CLAIMS, *auth_system.cookie_claims)
|
|
212
|
+
selected = {name: claims[name] for name in names if name in claims}
|
|
213
|
+
envelope = (auth_system.id, claims["exp"], selected)
|
|
214
|
+
value = _authn_cookie_serializer(the_installation).dumps(envelope)
|
|
215
|
+
|
|
216
|
+
cookie_set = len(value) <= AUTHN_COOKIE_MAX_SIZE
|
|
217
|
+
|
|
218
|
+
if cookie_set:
|
|
219
|
+
response.set_cookie(
|
|
220
|
+
AUTHN_COOKIE_NAME,
|
|
221
|
+
value,
|
|
222
|
+
max_age=max(int(claims["exp"] - time.time()), 0),
|
|
223
|
+
path="/",
|
|
224
|
+
secure=secure_cookies(),
|
|
225
|
+
httponly=True,
|
|
226
|
+
samesite="lax",
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
logger.error(loggers.AUTHN_COOKIE_TOO_LARGE, size=len(value))
|
|
230
|
+
clear_authn_cookie(response)
|
|
231
|
+
|
|
232
|
+
return cookie_set
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def clear_authn_cookie(response: fastapi.Response):
|
|
236
|
+
"""Remove the authn cookie via 'response'"""
|
|
237
|
+
response.delete_cookie(
|
|
238
|
+
AUTHN_COOKIE_NAME,
|
|
239
|
+
path="/",
|
|
240
|
+
secure=secure_cookies(),
|
|
241
|
+
httponly=True,
|
|
242
|
+
samesite="lax",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def load_authn_cookie(
|
|
247
|
+
the_installation: installation.Installation,
|
|
248
|
+
value: str,
|
|
249
|
+
) -> tuple[UserClaims | None, None | str]:
|
|
250
|
+
"""Return '(claims, None)' from authn cookie's 'value' if valid.
|
|
251
|
+
|
|
252
|
+
Return '(None, reason)' if not:
|
|
253
|
+
- the signature does not verify;
|
|
254
|
+
- 'value' is not a well formed '(authsystem_id, exp, claims)' envelope;
|
|
255
|
+
- the unpacked 'exp' has expired;
|
|
256
|
+
- the unpacked 'authsystem_id' matches no configured auth system.
|
|
257
|
+
"""
|
|
258
|
+
try:
|
|
259
|
+
envelope = _authn_cookie_serializer(the_installation).loads(value)
|
|
260
|
+
except itsdangerous.BadSignature:
|
|
261
|
+
return None, loggers.AUTHN_COOKIE_BAD_SIGNATURE
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
authsystem_id, exp, claims = envelope
|
|
265
|
+
except ValueError:
|
|
266
|
+
return None, loggers.AUTHN_COOKIE_BAD_ENVELOPE
|
|
267
|
+
|
|
268
|
+
if exp <= time.time():
|
|
269
|
+
return None, loggers.AUTHN_COOKIE_EXPIRED
|
|
270
|
+
|
|
271
|
+
authsystem_map = {
|
|
272
|
+
auth_system.id
|
|
273
|
+
for auth_system in the_installation.oidc_auth_system_configs
|
|
274
|
+
}
|
|
275
|
+
if authsystem_id not in authsystem_map:
|
|
276
|
+
return None, loggers.AUTHN_COOKIE_UNKNOWN_IDP
|
|
277
|
+
|
|
278
|
+
return claims, None
|