aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a2__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.
- aethergraph/__init__.py +4 -10
- aethergraph/__main__.py +293 -0
- aethergraph/api/v1/__init__.py +0 -0
- aethergraph/api/v1/agents.py +46 -0
- aethergraph/api/v1/apps.py +70 -0
- aethergraph/api/v1/artifacts.py +415 -0
- aethergraph/api/v1/channels.py +89 -0
- aethergraph/api/v1/deps.py +168 -0
- aethergraph/api/v1/graphs.py +259 -0
- aethergraph/api/v1/identity.py +25 -0
- aethergraph/api/v1/memory.py +353 -0
- aethergraph/api/v1/misc.py +47 -0
- aethergraph/api/v1/pagination.py +29 -0
- aethergraph/api/v1/runs.py +568 -0
- aethergraph/api/v1/schemas.py +535 -0
- aethergraph/api/v1/session.py +323 -0
- aethergraph/api/v1/stats.py +201 -0
- aethergraph/api/v1/viz.py +152 -0
- aethergraph/config/config.py +22 -0
- aethergraph/config/loader.py +3 -2
- aethergraph/config/storage.py +209 -0
- aethergraph/contracts/__init__.py +0 -0
- aethergraph/contracts/services/__init__.py +0 -0
- aethergraph/contracts/services/artifacts.py +27 -14
- aethergraph/contracts/services/memory.py +45 -17
- aethergraph/contracts/services/metering.py +129 -0
- aethergraph/contracts/services/runs.py +50 -0
- aethergraph/contracts/services/sessions.py +87 -0
- aethergraph/contracts/services/state_stores.py +3 -0
- aethergraph/contracts/services/viz.py +44 -0
- aethergraph/contracts/storage/artifact_index.py +88 -0
- aethergraph/contracts/storage/artifact_store.py +99 -0
- aethergraph/contracts/storage/async_kv.py +34 -0
- aethergraph/contracts/storage/blob_store.py +50 -0
- aethergraph/contracts/storage/doc_store.py +35 -0
- aethergraph/contracts/storage/event_log.py +31 -0
- aethergraph/contracts/storage/vector_index.py +48 -0
- aethergraph/core/__init__.py +0 -0
- aethergraph/core/execution/forward_scheduler.py +13 -2
- aethergraph/core/execution/global_scheduler.py +21 -15
- aethergraph/core/execution/step_forward.py +10 -1
- aethergraph/core/graph/__init__.py +0 -0
- aethergraph/core/graph/graph_builder.py +8 -4
- aethergraph/core/graph/graph_fn.py +156 -15
- aethergraph/core/graph/graph_spec.py +8 -0
- aethergraph/core/graph/graphify.py +146 -27
- aethergraph/core/graph/node_spec.py +0 -2
- aethergraph/core/graph/node_state.py +3 -0
- aethergraph/core/graph/task_graph.py +39 -1
- aethergraph/core/runtime/__init__.py +0 -0
- aethergraph/core/runtime/ad_hoc_context.py +64 -4
- aethergraph/core/runtime/base_service.py +28 -4
- aethergraph/core/runtime/execution_context.py +13 -15
- aethergraph/core/runtime/graph_runner.py +222 -37
- aethergraph/core/runtime/node_context.py +510 -6
- aethergraph/core/runtime/node_services.py +12 -5
- aethergraph/core/runtime/recovery.py +15 -1
- aethergraph/core/runtime/run_manager.py +783 -0
- aethergraph/core/runtime/run_manager_local.py +204 -0
- aethergraph/core/runtime/run_registration.py +2 -2
- aethergraph/core/runtime/run_types.py +89 -0
- aethergraph/core/runtime/runtime_env.py +136 -7
- aethergraph/core/runtime/runtime_metering.py +71 -0
- aethergraph/core/runtime/runtime_registry.py +36 -13
- aethergraph/core/runtime/runtime_services.py +194 -6
- aethergraph/core/tools/builtins/toolset.py +1 -1
- aethergraph/core/tools/toolkit.py +5 -0
- aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
- aethergraph/plugins/agents/default_chat_agent.py +171 -0
- aethergraph/plugins/agents/shared.py +81 -0
- aethergraph/plugins/channel/adapters/webui.py +112 -112
- aethergraph/plugins/channel/routes/webui_routes.py +367 -102
- aethergraph/plugins/channel/utils/slack_utils.py +115 -59
- aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
- aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
- aethergraph/runtime/__init__.py +15 -0
- aethergraph/server/app_factory.py +190 -34
- aethergraph/server/clients/channel_client.py +202 -0
- aethergraph/server/http/channel_http_routes.py +116 -0
- aethergraph/server/http/channel_ws_routers.py +45 -0
- aethergraph/server/loading.py +117 -0
- aethergraph/server/server.py +131 -0
- aethergraph/server/server_state.py +240 -0
- aethergraph/server/start.py +227 -66
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
- aethergraph/server/ui_static/index.html +15 -0
- aethergraph/server/ui_static/logo.png +0 -0
- aethergraph/services/artifacts/__init__.py +0 -0
- aethergraph/services/artifacts/facade.py +1239 -132
- aethergraph/services/auth/{dev.py → authn.py} +0 -8
- aethergraph/services/auth/authz.py +100 -0
- aethergraph/services/channel/__init__.py +0 -0
- aethergraph/services/channel/channel_bus.py +19 -1
- aethergraph/services/channel/factory.py +13 -1
- aethergraph/services/channel/ingress.py +311 -0
- aethergraph/services/channel/queue_adapter.py +75 -0
- aethergraph/services/channel/session.py +502 -19
- aethergraph/services/container/default_container.py +122 -43
- aethergraph/services/continuations/continuation.py +6 -0
- aethergraph/services/continuations/stores/fs_store.py +19 -0
- aethergraph/services/eventhub/event_hub.py +76 -0
- aethergraph/services/kv/__init__.py +0 -0
- aethergraph/services/kv/ephemeral.py +244 -0
- aethergraph/services/llm/__init__.py +0 -0
- aethergraph/services/llm/generic_client copy.py +691 -0
- aethergraph/services/llm/generic_client.py +1288 -187
- aethergraph/services/llm/providers.py +3 -1
- aethergraph/services/llm/types.py +47 -0
- aethergraph/services/llm/utils.py +284 -0
- aethergraph/services/logger/std.py +3 -0
- aethergraph/services/mcp/__init__.py +9 -0
- aethergraph/services/mcp/http_client.py +38 -0
- aethergraph/services/mcp/service.py +225 -1
- aethergraph/services/mcp/stdio_client.py +41 -6
- aethergraph/services/mcp/ws_client.py +44 -2
- aethergraph/services/memory/__init__.py +0 -0
- aethergraph/services/memory/distillers/llm_long_term.py +234 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
- aethergraph/services/memory/distillers/long_term.py +225 -0
- aethergraph/services/memory/facade/__init__.py +3 -0
- aethergraph/services/memory/facade/chat.py +440 -0
- aethergraph/services/memory/facade/core.py +447 -0
- aethergraph/services/memory/facade/distillation.py +424 -0
- aethergraph/services/memory/facade/rag.py +410 -0
- aethergraph/services/memory/facade/results.py +315 -0
- aethergraph/services/memory/facade/retrieval.py +139 -0
- aethergraph/services/memory/facade/types.py +77 -0
- aethergraph/services/memory/facade/utils.py +43 -0
- aethergraph/services/memory/facade_dep.py +1539 -0
- aethergraph/services/memory/factory.py +9 -3
- aethergraph/services/memory/utils.py +10 -0
- aethergraph/services/metering/eventlog_metering.py +470 -0
- aethergraph/services/metering/noop.py +25 -4
- aethergraph/services/rag/__init__.py +0 -0
- aethergraph/services/rag/facade.py +279 -23
- aethergraph/services/rag/index_factory.py +2 -2
- aethergraph/services/rag/node_rag.py +317 -0
- aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
- aethergraph/services/registry/__init__.py +0 -0
- aethergraph/services/registry/agent_app_meta.py +419 -0
- aethergraph/services/registry/registry_key.py +1 -1
- aethergraph/services/registry/unified_registry.py +74 -6
- aethergraph/services/scope/scope.py +159 -0
- aethergraph/services/scope/scope_factory.py +164 -0
- aethergraph/services/state_stores/serialize.py +5 -0
- aethergraph/services/state_stores/utils.py +2 -1
- aethergraph/services/viz/__init__.py +0 -0
- aethergraph/services/viz/facade.py +413 -0
- aethergraph/services/viz/viz_service.py +69 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
- aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
- aethergraph/storage/artifacts/cas_store.py +422 -0
- aethergraph/storage/artifacts/fs_cas.py +18 -0
- aethergraph/storage/artifacts/s3_cas.py +14 -0
- aethergraph/storage/artifacts/utils.py +124 -0
- aethergraph/storage/blob/fs_blob.py +86 -0
- aethergraph/storage/blob/s3_blob.py +115 -0
- aethergraph/storage/continuation_store/fs_cont.py +283 -0
- aethergraph/storage/continuation_store/inmem_cont.py +146 -0
- aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
- aethergraph/storage/docstore/fs_doc.py +63 -0
- aethergraph/storage/docstore/sqlite_doc.py +31 -0
- aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
- aethergraph/storage/eventlog/fs_event.py +136 -0
- aethergraph/storage/eventlog/sqlite_event.py +47 -0
- aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
- aethergraph/storage/factory.py +432 -0
- aethergraph/storage/fs_utils.py +28 -0
- aethergraph/storage/graph_state_store/state_store.py +64 -0
- aethergraph/storage/kv/inmem_kv.py +103 -0
- aethergraph/storage/kv/layered_kv.py +52 -0
- aethergraph/storage/kv/sqlite_kv.py +39 -0
- aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
- aethergraph/storage/memory/event_persist.py +68 -0
- aethergraph/storage/memory/fs_persist.py +118 -0
- aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
- aethergraph/{services → storage}/memory/indices.py +31 -7
- aethergraph/storage/metering/meter_event.py +55 -0
- aethergraph/storage/runs/doc_store.py +280 -0
- aethergraph/storage/runs/inmen_store.py +82 -0
- aethergraph/storage/runs/sqlite_run_store.py +403 -0
- aethergraph/storage/sessions/doc_store.py +183 -0
- aethergraph/storage/sessions/inmem_store.py +110 -0
- aethergraph/storage/sessions/sqlite_session_store.py +399 -0
- aethergraph/storage/vector_index/chroma_index.py +138 -0
- aethergraph/storage/vector_index/faiss_index.py +179 -0
- aethergraph/storage/vector_index/sqlite_index.py +187 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/METADATA +138 -31
- aethergraph-0.1.0a2.dist-info/RECORD +356 -0
- aethergraph-0.1.0a2.dist-info/entry_points.txt +3 -0
- aethergraph/services/artifacts/factory.py +0 -35
- aethergraph/services/artifacts/fs_store.py +0 -656
- aethergraph/services/artifacts/jsonl_index.py +0 -123
- aethergraph/services/artifacts/sqlite_index.py +0 -209
- aethergraph/services/memory/distillers/episode.py +0 -116
- aethergraph/services/memory/distillers/rolling.py +0 -74
- aethergraph/services/memory/facade.py +0 -633
- aethergraph/services/memory/persist_fs.py +0 -40
- aethergraph/services/rag/index/base.py +0 -27
- aethergraph/services/rag/index/faiss_index.py +0 -121
- aethergraph/services/rag/index/sqlite_index.py +0 -134
- aethergraph-0.1.0a1.dist-info/RECORD +0 -182
- aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/WHEEL +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# /artifacts
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
import os
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
|
|
8
|
+
from fastapi.responses import RedirectResponse
|
|
9
|
+
|
|
10
|
+
from aethergraph.api.v1.pagination import decode_cursor, encode_cursor
|
|
11
|
+
from aethergraph.contracts.storage.artifact_index import Artifact
|
|
12
|
+
from aethergraph.core.runtime.runtime_services import current_services
|
|
13
|
+
|
|
14
|
+
from .deps import RequestIdentity, get_identity
|
|
15
|
+
from .schemas import (
|
|
16
|
+
ArtifactListResponse,
|
|
17
|
+
ArtifactMeta,
|
|
18
|
+
ArtifactSearchHit,
|
|
19
|
+
ArtifactSearchRequest,
|
|
20
|
+
ArtifactSearchResponse,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
router = APIRouter(tags=["artifacts"])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# -------- Helpers -------- #
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _tenant_label_filters(identity: RequestIdentity) -> dict[str, str]:
|
|
30
|
+
"""
|
|
31
|
+
Convert RequestIdentity into artifact label filters.
|
|
32
|
+
|
|
33
|
+
All modes (cloud/demo/local) get org_id + user_id set, so we just use that.
|
|
34
|
+
"""
|
|
35
|
+
org_id, user_id = identity.tenant_key
|
|
36
|
+
filters: dict[str, str] = {}
|
|
37
|
+
|
|
38
|
+
if org_id is not None:
|
|
39
|
+
filters["org_id"] = org_id
|
|
40
|
+
if user_id is not None:
|
|
41
|
+
filters["user_id"] = user_id
|
|
42
|
+
|
|
43
|
+
return filters
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_tags(labels: dict[str, Any]) -> list[str]:
|
|
47
|
+
"""
|
|
48
|
+
Conventions:
|
|
49
|
+
- labels["tags"] may be a list[str] or comma-separated str
|
|
50
|
+
"""
|
|
51
|
+
tags = labels.get("tags")
|
|
52
|
+
if isinstance(tags, list):
|
|
53
|
+
return [str(t) for t in tags]
|
|
54
|
+
if isinstance(tags, str):
|
|
55
|
+
return [t.strip() for t in tags.split(",") if t.strip()]
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_scope_id(a: Artifact) -> str | None:
|
|
60
|
+
"""
|
|
61
|
+
Conventions:
|
|
62
|
+
- labels["scope_id"] is preferred
|
|
63
|
+
- labels["scope"] is legacy
|
|
64
|
+
- fallback to run_id if no scope label found
|
|
65
|
+
"""
|
|
66
|
+
labels = a.labels or {}
|
|
67
|
+
scope = labels.get("scope_id") or labels.get("scope") # legacy
|
|
68
|
+
if scope is not None:
|
|
69
|
+
return str(scope)
|
|
70
|
+
return a.run_id # fallback to run_id if no scope label found
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _guess_mime(a: Artifact) -> str:
|
|
74
|
+
# 1) explicit mime wins
|
|
75
|
+
if a.mime:
|
|
76
|
+
return a.mime
|
|
77
|
+
|
|
78
|
+
# 2) infer from URI / filename
|
|
79
|
+
mime = None
|
|
80
|
+
if a.uri:
|
|
81
|
+
guessed, _ = mimetypes.guess_type(a.uri)
|
|
82
|
+
if guessed:
|
|
83
|
+
mime = guessed
|
|
84
|
+
|
|
85
|
+
# 3) heuristics from kind (optional but nice)
|
|
86
|
+
if not mime and a.kind:
|
|
87
|
+
k = a.kind.lower()
|
|
88
|
+
if any(x in k for x in ["log", "text", "stdout", "stderr"]):
|
|
89
|
+
mime = "text/plain"
|
|
90
|
+
elif "json" in k:
|
|
91
|
+
mime = "application/json"
|
|
92
|
+
elif "csv" in k:
|
|
93
|
+
mime = "text/csv"
|
|
94
|
+
elif "markdown" in k or "md" in k:
|
|
95
|
+
mime = "text/markdown"
|
|
96
|
+
|
|
97
|
+
# 4) fallback
|
|
98
|
+
return mime or "application/octet-stream"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _artifact_to_meta(a: Artifact) -> ArtifactMeta:
|
|
102
|
+
"""
|
|
103
|
+
Convert Artifact to ArtifactMeta schema.
|
|
104
|
+
"""
|
|
105
|
+
labels = a.labels or {}
|
|
106
|
+
|
|
107
|
+
out = ArtifactMeta(
|
|
108
|
+
artifact_id=a.artifact_id,
|
|
109
|
+
kind=a.kind,
|
|
110
|
+
mime_type=_guess_mime(a),
|
|
111
|
+
size=a.bytes,
|
|
112
|
+
scope_id=_extract_scope_id(a) or "unknown_scope",
|
|
113
|
+
tags=_extract_tags(labels),
|
|
114
|
+
created_at=a.created_at, # pydantic will parse ISO str -> datetime
|
|
115
|
+
uri=a.uri,
|
|
116
|
+
pinned=a.pinned,
|
|
117
|
+
preview_uri=a.preview_uri,
|
|
118
|
+
run_id=a.run_id,
|
|
119
|
+
graph_id=a.graph_id,
|
|
120
|
+
node_id=a.node_id if getattr(a, "node_id", None) else None,
|
|
121
|
+
session_id=a.session_id if getattr(a, "session_id", None) else None,
|
|
122
|
+
filename=labels.get("filename"),
|
|
123
|
+
)
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# -------- API Endpoints -------- #
|
|
128
|
+
@router.get("/artifacts", response_model=ArtifactListResponse)
|
|
129
|
+
async def list_artifacts(
|
|
130
|
+
scope_id: Annotated[str | None, Query()] = None,
|
|
131
|
+
kind: Annotated[str | None, Query()] = None,
|
|
132
|
+
tags: Annotated[str | None, Query()] = None,
|
|
133
|
+
cursor: Annotated[str | None, Query()] = None,
|
|
134
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
135
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
136
|
+
) -> ArtifactListResponse:
|
|
137
|
+
container = current_services()
|
|
138
|
+
index = getattr(container, "artifact_index", None)
|
|
139
|
+
if index is None:
|
|
140
|
+
return ArtifactListResponse(artifacts=[], next_cursor=None)
|
|
141
|
+
|
|
142
|
+
offset = decode_cursor(cursor.strip() if cursor else None)
|
|
143
|
+
|
|
144
|
+
label_filters: dict[str, Any] = {}
|
|
145
|
+
|
|
146
|
+
if scope_id and scope_id.strip():
|
|
147
|
+
label_filters["scope_id"] = scope_id.strip()
|
|
148
|
+
|
|
149
|
+
if tags and tags.strip():
|
|
150
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
151
|
+
if tag_list:
|
|
152
|
+
label_filters["tags"] = tag_list
|
|
153
|
+
|
|
154
|
+
# 🔹 Tenant scoping: org_id + user_id
|
|
155
|
+
label_filters.update(_tenant_label_filters(identity))
|
|
156
|
+
|
|
157
|
+
artifacts = await index.search(
|
|
158
|
+
kind=kind.strip() if kind and kind.strip() else None,
|
|
159
|
+
labels=label_filters or None,
|
|
160
|
+
metric=None,
|
|
161
|
+
mode=None,
|
|
162
|
+
limit=limit,
|
|
163
|
+
offset=offset,
|
|
164
|
+
)
|
|
165
|
+
metas = [_artifact_to_meta(a) for a in artifacts]
|
|
166
|
+
next_cursor = encode_cursor(offset + limit) if len(artifacts) == limit else None
|
|
167
|
+
return ArtifactListResponse(artifacts=metas, next_cursor=next_cursor)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.get("/artifacts/{artifact_id}", response_model=ArtifactMeta)
|
|
171
|
+
async def get_artifact(
|
|
172
|
+
artifact_id: str,
|
|
173
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
174
|
+
) -> ArtifactMeta:
|
|
175
|
+
"""
|
|
176
|
+
Get single artifact metadata.
|
|
177
|
+
"""
|
|
178
|
+
container = current_services()
|
|
179
|
+
index = getattr(container, "artifact_index", None)
|
|
180
|
+
rm = getattr(container, "run_manager", None)
|
|
181
|
+
if index is None or (identity.mode == "demo" and rm is None):
|
|
182
|
+
raise HTTPException(status_code=503, detail="Artifact index not configured")
|
|
183
|
+
|
|
184
|
+
artifact = await index.get(artifact_id)
|
|
185
|
+
if artifact is None:
|
|
186
|
+
raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
|
|
187
|
+
|
|
188
|
+
meta = _artifact_to_meta(artifact)
|
|
189
|
+
return meta
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@router.get("/artifacts/{artifact_id}/content")
|
|
193
|
+
async def get_artifact_content(
|
|
194
|
+
artifact_id: str,
|
|
195
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
196
|
+
) -> Response:
|
|
197
|
+
container = current_services()
|
|
198
|
+
index = getattr(container, "artifact_index", None)
|
|
199
|
+
store = getattr(container, "artifacts", None)
|
|
200
|
+
rm = getattr(container, "run_manager", None)
|
|
201
|
+
if index is None or store is None or (identity.client_id and rm is None):
|
|
202
|
+
raise HTTPException(status_code=503, detail="Artifact services not configured")
|
|
203
|
+
|
|
204
|
+
artifact = await index.get(artifact_id)
|
|
205
|
+
if artifact is None:
|
|
206
|
+
raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
|
|
207
|
+
|
|
208
|
+
# If user provided a fully qualified preview URI (e.g. S3 signed URL)
|
|
209
|
+
if artifact.preview_uri and str(artifact.preview_uri).startswith(("http://", "https://")):
|
|
210
|
+
return RedirectResponse(artifact.preview_uri)
|
|
211
|
+
|
|
212
|
+
# Otherwise, stream raw bytes from the artifact store.
|
|
213
|
+
data = await store.load_artifact_bytes(artifact.uri)
|
|
214
|
+
|
|
215
|
+
# Derive a filename that's at least somewhat meaningful
|
|
216
|
+
labels = artifact.labels or {}
|
|
217
|
+
filename = (
|
|
218
|
+
labels.get("filename")
|
|
219
|
+
or (os.path.basename(artifact.uri) if artifact.uri else None)
|
|
220
|
+
or artifact.artifact_id
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
media_type = artifact.mime or "application/octet-stream"
|
|
224
|
+
|
|
225
|
+
return Response(
|
|
226
|
+
content=data,
|
|
227
|
+
media_type=media_type,
|
|
228
|
+
headers={
|
|
229
|
+
"Content-Length": str(len(data)),
|
|
230
|
+
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
231
|
+
"X-AetherGraph-Artifact-Id": artifact.artifact_id,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@router.post("/artifacts/{artifact_id}/pin")
|
|
237
|
+
async def pin_artifact(
|
|
238
|
+
artifact_id: str,
|
|
239
|
+
pinned: Annotated[bool, Body()] = True,
|
|
240
|
+
identity: Annotated[RequestIdentity, Depends(get_identity)] = None,
|
|
241
|
+
) -> dict:
|
|
242
|
+
"""
|
|
243
|
+
Mark/unmark an artifact as pinned in the index.
|
|
244
|
+
|
|
245
|
+
Pinned artifacts can be treated as "keep" in GC policies or highlighted in UIs.
|
|
246
|
+
"""
|
|
247
|
+
container = current_services()
|
|
248
|
+
rm = getattr(container, "run_manager", None)
|
|
249
|
+
index = getattr(container, "artifact_index", None)
|
|
250
|
+
if index is None:
|
|
251
|
+
raise HTTPException(status_code=503, detail="Artifact index not configured")
|
|
252
|
+
|
|
253
|
+
if identity.client_id and rm is None:
|
|
254
|
+
# Can't enforce client scoping without RunManager
|
|
255
|
+
raise HTTPException(status_code=503, detail="Run manager not configured")
|
|
256
|
+
|
|
257
|
+
artifact = await index.get(artifact_id)
|
|
258
|
+
if artifact is None:
|
|
259
|
+
raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
|
|
260
|
+
|
|
261
|
+
await index.pin(artifact_id, pinned=pinned)
|
|
262
|
+
return {"artifact_id": artifact_id, "pinned": pinned}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@router.get("/runs/{run_id}/artifacts", response_model=ArtifactListResponse)
|
|
266
|
+
async def list_run_artifacts(
|
|
267
|
+
run_id: str,
|
|
268
|
+
cursor: Annotated[str | None, Query()] = None,
|
|
269
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
|
270
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
271
|
+
) -> ArtifactListResponse:
|
|
272
|
+
container = current_services()
|
|
273
|
+
index = getattr(container, "artifact_index", None)
|
|
274
|
+
if index is None:
|
|
275
|
+
raise HTTPException(status_code=503, detail="Artifact index not configured")
|
|
276
|
+
|
|
277
|
+
offset = decode_cursor(cursor.strip() if cursor else None)
|
|
278
|
+
|
|
279
|
+
label_filters: dict[str, Any] = {"run_id": run_id}
|
|
280
|
+
label_filters.update(_tenant_label_filters(identity))
|
|
281
|
+
|
|
282
|
+
artifacts = await index.search(
|
|
283
|
+
labels=label_filters,
|
|
284
|
+
limit=limit,
|
|
285
|
+
offset=offset,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
metas = [_artifact_to_meta(a) for a in artifacts]
|
|
289
|
+
next_cursor = encode_cursor(offset + limit) if len(artifacts) == limit else None
|
|
290
|
+
return ArtifactListResponse(artifacts=metas, next_cursor=next_cursor)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@router.get("/sessions/{session_id}/artifacts", response_model=ArtifactListResponse)
|
|
294
|
+
async def list_session_artifacts(
|
|
295
|
+
session_id: str,
|
|
296
|
+
cursor: Annotated[str | None, Query()] = None, # noqa: B008
|
|
297
|
+
limit: Annotated[int, Query(ge=1, le=200)] = 50, # noqa: B008
|
|
298
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
299
|
+
) -> ArtifactListResponse:
|
|
300
|
+
container = current_services()
|
|
301
|
+
index = getattr(container, "artifact_index", None)
|
|
302
|
+
if index is None:
|
|
303
|
+
raise HTTPException(status_code=503, detail="Artifact index not configured")
|
|
304
|
+
|
|
305
|
+
offset = decode_cursor(cursor.strip() if cursor else None)
|
|
306
|
+
|
|
307
|
+
label_filters: dict[str, Any] = {"session_id": session_id}
|
|
308
|
+
label_filters.update(_tenant_label_filters(identity))
|
|
309
|
+
|
|
310
|
+
artifacts = await index.search(
|
|
311
|
+
labels=label_filters,
|
|
312
|
+
limit=limit,
|
|
313
|
+
offset=offset,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
metas = [_artifact_to_meta(a) for a in artifacts]
|
|
317
|
+
next_cursor = encode_cursor(offset + limit) if len(artifacts) == limit else None
|
|
318
|
+
return ArtifactListResponse(artifacts=metas, next_cursor=next_cursor)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@router.post("/artifacts/search", response_model=ArtifactSearchResponse)
|
|
322
|
+
async def search_artifacts(
|
|
323
|
+
req: ArtifactSearchRequest,
|
|
324
|
+
identity: Annotated[RequestIdentity, Depends(get_identity)],
|
|
325
|
+
) -> ArtifactSearchResponse:
|
|
326
|
+
"""
|
|
327
|
+
Structured search over artifacts via the artifact index.
|
|
328
|
+
|
|
329
|
+
We interpret fields on ArtifactSearchRequest in a flexible way:
|
|
330
|
+
- kind: optional artifact kind filter
|
|
331
|
+
- scope_id: maps to labels["scope_id"]
|
|
332
|
+
- tags: optional list[str] or comma-separated string -> labels["tags"]
|
|
333
|
+
- labels: optional extra label filters
|
|
334
|
+
- metric + mode: if provided, used for ranking (and required for best-only)
|
|
335
|
+
- limit: max results
|
|
336
|
+
- best_only: if True, use index.best(...) and return a single hit
|
|
337
|
+
|
|
338
|
+
Tenant scoping is enforced via org_id/user_id/client_id/app_id from RequestIdentity.
|
|
339
|
+
"""
|
|
340
|
+
container = current_services()
|
|
341
|
+
index = getattr(container, "artifact_index", None)
|
|
342
|
+
if index is None:
|
|
343
|
+
return ArtifactSearchResponse(results=[])
|
|
344
|
+
|
|
345
|
+
kind = getattr(req, "kind", None)
|
|
346
|
+
scope_id = getattr(req, "scope_id", None)
|
|
347
|
+
tags = getattr(req, "tags", None)
|
|
348
|
+
extra_labels = getattr(req, "labels", None)
|
|
349
|
+
metric = getattr(req, "metric", None)
|
|
350
|
+
mode = getattr(req, "mode", None)
|
|
351
|
+
limit = getattr(req, "limit", 50)
|
|
352
|
+
best_only = getattr(req, "best_only", False)
|
|
353
|
+
|
|
354
|
+
label_filter: dict[str, Any] = {}
|
|
355
|
+
|
|
356
|
+
if scope_id:
|
|
357
|
+
label_filter["scope_id"] = scope_id
|
|
358
|
+
|
|
359
|
+
# Handle tags, may be list or comma-separated str
|
|
360
|
+
if tags:
|
|
361
|
+
if isinstance(tags, str):
|
|
362
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
363
|
+
elif isinstance(tags, list):
|
|
364
|
+
tag_list = [str(t) for t in tags]
|
|
365
|
+
else:
|
|
366
|
+
tag_list = []
|
|
367
|
+
if tag_list:
|
|
368
|
+
label_filter["tags"] = tag_list
|
|
369
|
+
|
|
370
|
+
if extra_labels:
|
|
371
|
+
label_filter.update(extra_labels)
|
|
372
|
+
|
|
373
|
+
# 🔹 Tenant scoping
|
|
374
|
+
tenant_filters = _tenant_label_filters(identity)
|
|
375
|
+
label_filter.update(tenant_filters)
|
|
376
|
+
|
|
377
|
+
hits: list[ArtifactSearchHit] = []
|
|
378
|
+
|
|
379
|
+
if best_only and metric and mode:
|
|
380
|
+
best = await index.best(
|
|
381
|
+
kind=kind or "",
|
|
382
|
+
metric=metric,
|
|
383
|
+
mode=mode,
|
|
384
|
+
filters=label_filter or None,
|
|
385
|
+
)
|
|
386
|
+
if best is not None:
|
|
387
|
+
score = float(best.metrics.get(metric, 0.0)) if best.metrics else 0.0
|
|
388
|
+
hits.append(
|
|
389
|
+
ArtifactSearchHit(
|
|
390
|
+
artifact=_artifact_to_meta(best),
|
|
391
|
+
score=score,
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
return ArtifactSearchResponse(results=hits)
|
|
395
|
+
|
|
396
|
+
artifacts = await index.search(
|
|
397
|
+
kind=kind,
|
|
398
|
+
labels=label_filter or None,
|
|
399
|
+
metric=metric,
|
|
400
|
+
mode=mode,
|
|
401
|
+
limit=limit,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
for a in artifacts:
|
|
405
|
+
score = 1.0
|
|
406
|
+
if metric and a.metrics:
|
|
407
|
+
score = float(a.metrics.get(metric, 0.0))
|
|
408
|
+
hits.append(
|
|
409
|
+
ArtifactSearchHit(
|
|
410
|
+
artifact=_artifact_to_meta(a),
|
|
411
|
+
score=score,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
return ArtifactSearchResponse(results=hits)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# stub, to move the server.channels module here later
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
|
7
|
+
|
|
8
|
+
from .deps import RequestIdentity, get_identity
|
|
9
|
+
from .schemas import (
|
|
10
|
+
ChannelEvent,
|
|
11
|
+
ChannelEventListResponse,
|
|
12
|
+
ChannelIngressRequest,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
router = APIRouter(tags=["channels"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.post("/channels/{channel_id}/ingress")
|
|
19
|
+
async def channel_ingress(
|
|
20
|
+
channel_id: str,
|
|
21
|
+
req: ChannelIngressRequest,
|
|
22
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
23
|
+
) -> dict:
|
|
24
|
+
"""
|
|
25
|
+
Ingest a message into a channel (HTTP).
|
|
26
|
+
|
|
27
|
+
TODO:
|
|
28
|
+
- Forward to Channel service / Correlator.
|
|
29
|
+
- Likely emit a memory event + trigger continuations.
|
|
30
|
+
"""
|
|
31
|
+
# Stub: just echo
|
|
32
|
+
return {
|
|
33
|
+
"channel_id": channel_id,
|
|
34
|
+
"kind": req.kind,
|
|
35
|
+
"text": req.text,
|
|
36
|
+
"metadata": req.metadata,
|
|
37
|
+
"user_id": identity.user_id,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/channels/{channel_id}/events", response_model=ChannelEventListResponse)
|
|
42
|
+
async def list_channel_events(
|
|
43
|
+
channel_id: str,
|
|
44
|
+
cursor: str | None = Query(None), # noqa: B008
|
|
45
|
+
limit: int = Query(50, ge=1, le=200), # noqa: B008
|
|
46
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
47
|
+
) -> ChannelEventListResponse:
|
|
48
|
+
"""
|
|
49
|
+
Polling-based channel event retrieval.
|
|
50
|
+
|
|
51
|
+
TODO:
|
|
52
|
+
- Integrate with channel/event store using cursor pagination.
|
|
53
|
+
"""
|
|
54
|
+
now = datetime.utcnow()
|
|
55
|
+
dummy = ChannelEvent(
|
|
56
|
+
event_id="ch-evt-1",
|
|
57
|
+
channel_id=channel_id,
|
|
58
|
+
kind="chat_assistant",
|
|
59
|
+
created_at=now - timedelta(seconds=30),
|
|
60
|
+
data={"text": "Stub channel event"},
|
|
61
|
+
)
|
|
62
|
+
return ChannelEventListResponse(events=[dummy], next_cursor=None)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ----- WebSocket for real-time -----
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.websocket("/ws/channels/{channel_id}")
|
|
69
|
+
async def channel_websocket(
|
|
70
|
+
websocket: WebSocket,
|
|
71
|
+
channel_id: str,
|
|
72
|
+
):
|
|
73
|
+
"""
|
|
74
|
+
WebSocket endpoint for real-time channel events.
|
|
75
|
+
|
|
76
|
+
TODO:
|
|
77
|
+
- Authenticate if needed (e.g., via query param token or headers).
|
|
78
|
+
- Subscribe this socket to channel event stream.
|
|
79
|
+
- Push events as they arrive; accept client messages as ingress.
|
|
80
|
+
"""
|
|
81
|
+
await websocket.accept()
|
|
82
|
+
try:
|
|
83
|
+
# Very basic echo loop as a stub
|
|
84
|
+
while True:
|
|
85
|
+
data = await websocket.receive_text()
|
|
86
|
+
await websocket.send_text(f"[stub] Channel {channel_id} received: {data}")
|
|
87
|
+
except WebSocketDisconnect:
|
|
88
|
+
# TODO: clean up subscriptions if add them.
|
|
89
|
+
pass
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from fastapi import Depends, Header, HTTPException, Request, status
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from aethergraph.core.runtime.runtime_services import current_services
|
|
7
|
+
from aethergraph.services.auth.authz import AuthZService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RequestIdentity(BaseModel):
|
|
11
|
+
user_id: str | None = None
|
|
12
|
+
org_id: str | None = None
|
|
13
|
+
roles: list[str] = Field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
# Demo-only/browser identity
|
|
16
|
+
client_id: str | None = None
|
|
17
|
+
|
|
18
|
+
# How this request is “authenticated”
|
|
19
|
+
mode: Literal["cloud", "demo", "local"] = "local"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_cloud(self) -> bool:
|
|
23
|
+
return self.mode == "cloud"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def is_demo(self) -> bool:
|
|
27
|
+
return self.mode == "demo"
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_local(self) -> bool:
|
|
31
|
+
return self.mode == "local"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def tenant_key(self) -> tuple[str | None, str | None]:
|
|
35
|
+
"""Convenience key for tenant scoping."""
|
|
36
|
+
return (self.org_id, self.user_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_identity(
|
|
40
|
+
request: Request,
|
|
41
|
+
x_user_id: str | None = Header(None, alias="X-User-ID"),
|
|
42
|
+
x_org_id: str | None = Header(None, alias="X-Org-ID"),
|
|
43
|
+
x_roles: str | None = Header(None, alias="X-Roles"),
|
|
44
|
+
x_client_id: str | None = Header(None, alias="X-Client-ID"),
|
|
45
|
+
) -> RequestIdentity:
|
|
46
|
+
"""
|
|
47
|
+
Identity extraction hook.
|
|
48
|
+
|
|
49
|
+
Modes:
|
|
50
|
+
- CLOUD: auth gateway injects X-User-ID / X-Org-ID (optionally X-Client-ID).
|
|
51
|
+
- DEMO: no user/org, but a client_id is provided (header or query param).
|
|
52
|
+
- LOCAL: no headers; fall back to a single 'local' user/org.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
roles = x_roles.split(",") if x_roles else []
|
|
56
|
+
|
|
57
|
+
# Allow demo frontend to keep sending ?client_id=... for now
|
|
58
|
+
query_client_id = request.query_params.get("client_id")
|
|
59
|
+
client_id = x_client_id or query_client_id
|
|
60
|
+
|
|
61
|
+
# --- Cloud mode: real auth in front of us ---
|
|
62
|
+
if x_user_id or x_org_id:
|
|
63
|
+
return RequestIdentity(
|
|
64
|
+
user_id=x_user_id,
|
|
65
|
+
org_id=x_org_id,
|
|
66
|
+
roles=roles,
|
|
67
|
+
client_id=client_id, # optional; may be unused in cloud
|
|
68
|
+
mode="cloud",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# --- Demo mode: no auth, but we have a client_id ---
|
|
72
|
+
if client_id:
|
|
73
|
+
# Treat client_id as the actual user_id for demo
|
|
74
|
+
demo_user_id = f"demo:{client_id}"
|
|
75
|
+
return RequestIdentity(
|
|
76
|
+
user_id=demo_user_id,
|
|
77
|
+
org_id="demo",
|
|
78
|
+
roles=["demo"],
|
|
79
|
+
client_id=client_id,
|
|
80
|
+
mode="demo",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# --- Local mode: dev / sidecar ---
|
|
84
|
+
return RequestIdentity(
|
|
85
|
+
user_id="local",
|
|
86
|
+
org_id="local",
|
|
87
|
+
roles=["dev"],
|
|
88
|
+
client_id=None,
|
|
89
|
+
mode="local",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _rate_key(identity: RequestIdentity) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Compute a stable key for rate limiting.
|
|
96
|
+
|
|
97
|
+
- CLOUD: prefer org_id, then user_id
|
|
98
|
+
- DEMO: use client_id if present, else "demo"
|
|
99
|
+
- LOCAL: just "local"
|
|
100
|
+
"""
|
|
101
|
+
if identity.mode == "cloud":
|
|
102
|
+
return identity.org_id or identity.user_id or "anonymous"
|
|
103
|
+
|
|
104
|
+
if identity.mode == "demo":
|
|
105
|
+
# Each browser/client gets its own key if possible
|
|
106
|
+
return identity.client_id or "demo"
|
|
107
|
+
|
|
108
|
+
# local / dev
|
|
109
|
+
return "local"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_authz() -> AuthZService:
|
|
113
|
+
container = current_services()
|
|
114
|
+
return container.authz # type: ignore[return-value]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def require_runs_execute(
|
|
118
|
+
identity: RequestIdentity = Depends(get_identity), # noqa B008
|
|
119
|
+
) -> RequestIdentity:
|
|
120
|
+
container = current_services()
|
|
121
|
+
if container.authz:
|
|
122
|
+
await container.authz.authorize(identity=identity, scope="runs", action="execute")
|
|
123
|
+
return identity
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def enforce_run_rate_limits(
|
|
127
|
+
request: Request,
|
|
128
|
+
identity: RequestIdentity = Depends(get_identity), # noqa B008
|
|
129
|
+
) -> None:
|
|
130
|
+
container = current_services()
|
|
131
|
+
settings = getattr(container, "settings", None)
|
|
132
|
+
if not settings or not settings.rate_limit.enabled:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
# In local/dev mode, don't annoy with limits
|
|
136
|
+
if identity.mode == "local":
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
rl_cfg = settings.rate_limit
|
|
140
|
+
|
|
141
|
+
# ---------- 1) Long-window per-identity cap via metering ----------
|
|
142
|
+
meter = getattr(container, "metering", None)
|
|
143
|
+
if meter is not None:
|
|
144
|
+
# For demo mode this will be user_id="demo", org_id="demo",
|
|
145
|
+
# so all demo clients share the hourly cap. That's fine for now.
|
|
146
|
+
overview = await meter.get_overview(
|
|
147
|
+
user_id=identity.user_id,
|
|
148
|
+
org_id=identity.org_id,
|
|
149
|
+
window=rl_cfg.runs_window,
|
|
150
|
+
)
|
|
151
|
+
if overview.get("runs", 0) >= rl_cfg.max_runs_per_window:
|
|
152
|
+
raise HTTPException(
|
|
153
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
154
|
+
detail=(
|
|
155
|
+
f"Run limit exceeded: at most "
|
|
156
|
+
f"{rl_cfg.max_runs_per_window} runs per {rl_cfg.runs_window}."
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# ---------- 2) Short-burst limiter (in-memory) ----------
|
|
161
|
+
limiter = getattr(container, "run_burst_limiter", None)
|
|
162
|
+
if limiter is not None:
|
|
163
|
+
key = _rate_key(identity)
|
|
164
|
+
if not limiter.allow(key):
|
|
165
|
+
raise HTTPException(
|
|
166
|
+
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
167
|
+
detail="Too many runs started in a short period. Please wait a moment.",
|
|
168
|
+
)
|