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.
Files changed (127) hide show
  1. {soliplex-0.85.4/src/soliplex.egg-info → soliplex-0.86}/PKG-INFO +2 -2
  2. {soliplex-0.85.4 → soliplex-0.86}/pyproject.toml +3 -2
  3. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/__init__.py +1 -63
  4. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/persistence.py +4 -2
  5. soliplex-0.86/src/soliplex/agui/sse_delivery.py +287 -0
  6. soliplex-0.86/src/soliplex/alembic_migrations/versions/f13fea6c1953_soliplex_v0_86.py +64 -0
  7. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authn.py +123 -0
  8. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authz/__init__.py +52 -8
  9. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authz/persistence.py +130 -58
  10. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/authz/schema.py +40 -10
  11. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/ask.py +2 -1
  12. soliplex-0.86/src/soliplex/cli/audit/admin_users.py +334 -0
  13. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/installation.py +30 -0
  14. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/room_authz.py +70 -31
  15. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/serve.py +1 -0
  16. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/authsystem.py +19 -0
  17. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/installation.py +73 -10
  18. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/rooms.py +67 -0
  19. soliplex-0.86/src/soliplex/config/sse_delivery.py +261 -0
  20. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/installation.py +50 -0
  21. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/loggers.py +22 -5
  22. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/main.py +2 -1
  23. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/models.py +72 -0
  24. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/__init__.py +60 -0
  25. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/agui.py +60 -3
  26. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/authn.py +180 -28
  27. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/authz.py +71 -0
  28. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/installation.py +11 -0
  29. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/room_file_uploads.py +32 -10
  30. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/templates/consent.html.mako +1 -1
  31. soliplex-0.86/src/soliplex/views/templates/cookie-too-large.html.mako +90 -0
  32. {soliplex-0.85.4 → soliplex-0.86/src/soliplex.egg-info}/PKG-INFO +2 -2
  33. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/SOURCES.txt +5 -1
  34. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/requires.txt +1 -1
  35. soliplex-0.85.4/src/soliplex/cli/audit/admin_users.py +0 -142
  36. {soliplex-0.85.4 → soliplex-0.86}/LICENSE +0 -0
  37. {soliplex-0.85.4 → soliplex-0.86}/MANIFEST.in +0 -0
  38. {soliplex-0.85.4 → soliplex-0.86}/README.md +0 -0
  39. {soliplex-0.85.4 → soliplex-0.86}/example/skills/bare-bones/SKILL.md +0 -0
  40. {soliplex-0.85.4 → soliplex-0.86}/example/skills/stanzas/SKILL.md +0 -0
  41. {soliplex-0.85.4 → soliplex-0.86}/setup.cfg +0 -0
  42. {soliplex-0.85.4 → soliplex-0.86}/skills/soliplex-docs/SKILL.md +0 -0
  43. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agents.py +0 -0
  44. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/parser.py +0 -0
  45. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/schema.py +0 -0
  46. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/agui/util.py +0 -0
  47. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/__init__.py +0 -0
  48. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/env.py +0 -0
  49. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/216d48e1e2e5_soliplex_v0_53.py +0 -0
  50. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/4b63e5f3f39e_soliplex_v0_51_1.py +0 -0
  51. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/63edaa5987f6_soliplex_v0_67.py +0 -0
  52. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/a1c7d3e90b42_soliplex_v0_80.py +0 -0
  53. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/b7e2f41c9d05_soliplex_v0_82.py +0 -0
  54. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/alembic_migrations/versions/d5009d4f9874_soliplex_v0_44.py +0 -0
  55. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/capabilities/filesystem.py +0 -0
  56. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/capabilities/rag_audit.py +0 -0
  57. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/admin_users.py +0 -0
  58. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/__init__.py +0 -0
  59. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/_common.py +0 -0
  60. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/completions.py +0 -0
  61. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/databases.py +0 -0
  62. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/environment.py +0 -0
  63. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/interpolation.py +0 -0
  64. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/logfire.py +0 -0
  65. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/logging.py +0 -0
  66. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/oidc.py +0 -0
  67. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/ollama.py +0 -0
  68. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/quizzes.py +0 -0
  69. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/room_authz.py +0 -0
  70. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/rooms.py +0 -0
  71. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/secrets.py +0 -0
  72. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/audit/skills.py +0 -0
  73. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/cli_util.py +0 -0
  74. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/database.py +0 -0
  75. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/main.py +0 -0
  76. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/ollama.py +0 -0
  77. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/cli/types.py +0 -0
  78. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/completions.py +0 -0
  79. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/__init__.py +0 -0
  80. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/_utils.py +0 -0
  81. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/agents.py +0 -0
  82. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/agui.py +0 -0
  83. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/completions.py +0 -0
  84. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/exceptions.py +0 -0
  85. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/interpolation.py +0 -0
  86. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/logfire.py +0 -0
  87. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/meta.py +0 -0
  88. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/middleware.py +0 -0
  89. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/quizzes.py +0 -0
  90. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/rag.py +0 -0
  91. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/routing.py +0 -0
  92. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/secrets.py +0 -0
  93. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/skills.py +0 -0
  94. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/config/tools.py +0 -0
  95. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/examples.py +0 -0
  96. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/log_ingest.py +0 -0
  97. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/mcp_auth.py +0 -0
  98. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/mcp_client.py +0 -0
  99. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/mcp_server.py +0 -0
  100. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/ollama.py +0 -0
  101. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/quizzes.py +0 -0
  102. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/rag_audit.py +0 -0
  103. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/sandbox_audit.py +0 -0
  104. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/secrets.py +0 -0
  105. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/skills/bwrap_sandbox/SKILL.md +0 -0
  106. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/skills/bwrap_sandbox/__init__.py +0 -0
  107. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/titles.py +0 -0
  108. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/__init__.py +0 -0
  109. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/agui_run_feedback.py +0 -0
  110. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/file_uploads.py +0 -0
  111. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tools/rag.py +0 -0
  112. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/cli.py +0 -0
  113. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/main.py +0 -0
  114. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/rest_api.py +0 -0
  115. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/tui/serve.py +0 -0
  116. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/util.py +0 -0
  117. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/completions.py +0 -0
  118. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/log_ingest.py +0 -0
  119. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/quizzes.py +0 -0
  120. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/rooms.py +0 -0
  121. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/sandbox_workdirs.py +0 -0
  122. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/stats.py +0 -0
  123. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/streaming.py +0 -0
  124. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex/views/thread_file_uploads.py +0 -0
  125. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/dependency_links.txt +0 -0
  126. {soliplex-0.85.4 → soliplex-0.86}/src/soliplex.egg-info/entry_points.txt +0 -0
  127. {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.85.4
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.98,>=0.97.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.85.4"
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.97.0, < 0.98",
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.abc
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 = agui.with_final_state(
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 = agui.compact_event_stream(event_stream)
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