mash-api 0.1.0__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.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: mash-api
3
+ Version: 0.1.0
4
+ Summary: OpenAPI service package for Mash applications
5
+ Author: imsid
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: mashpy>=0.1.2
10
+ Requires-Dist: fastapi>=0.115.0
11
+ Requires-Dist: uvicorn>=0.30.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
14
+ Requires-Dist: httpx>=0.27.0; extra == "dev"
15
+
16
+ # mash-api
17
+
18
+ OpenAPI service package for self-hosted Mash applications.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install mash-api
24
+ # or
25
+ pip install "mashpy[api]"
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ from mash_api import create_app
32
+ from my_app import definition
33
+
34
+ app = create_app(definition)
35
+ ```
36
+
37
+ Run with Uvicorn:
38
+
39
+ ```bash
40
+ uvicorn my_module:app --host 127.0.0.1 --port 8000
41
+ ```
42
+
43
+ Or use the bundled CLI:
44
+
45
+ ```bash
46
+ mash-api --app my_app:build_definition
47
+ ```
@@ -0,0 +1,32 @@
1
+ # mash-api
2
+
3
+ OpenAPI service package for self-hosted Mash applications.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install mash-api
9
+ # or
10
+ pip install "mashpy[api]"
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from mash_api import create_app
17
+ from my_app import definition
18
+
19
+ app = create_app(definition)
20
+ ```
21
+
22
+ Run with Uvicorn:
23
+
24
+ ```bash
25
+ uvicorn my_module:app --host 127.0.0.1 --port 8000
26
+ ```
27
+
28
+ Or use the bundled CLI:
29
+
30
+ ```bash
31
+ mash-api --app my_app:build_definition
32
+ ```
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "mash-api"
3
+ version = "0.1.0"
4
+ description = "OpenAPI service package for Mash applications"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = {text = "Proprietary"}
8
+ authors = [{ name = "imsid" }]
9
+ dependencies = [
10
+ "mashpy>=0.1.2",
11
+ "fastapi>=0.115.0",
12
+ "uvicorn>=0.30.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.3.0",
18
+ "httpx>=0.27.0",
19
+ ]
20
+
21
+ [project.scripts]
22
+ mash-api = "mash_api.main:main"
23
+
24
+ [build-system]
25
+ requires = ["setuptools>=68", "wheel"]
26
+ build-backend = "setuptools.build_meta"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+ include = ["mash_api*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Public exports for mash-api package."""
2
+
3
+ from .app import create_app, run_app
4
+ from .config import MashAPIConfig
5
+ from .types import MashAPIAppSpec, SubagentRegistration
6
+
7
+ __all__ = ["create_app", "run_app", "MashAPIConfig", "MashAPIAppSpec", "SubagentRegistration"]
@@ -0,0 +1,662 @@
1
+ """FastAPI composition for mash-api runtime and telemetry endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Any, AsyncIterator, Iterator, Optional, Sequence
12
+
13
+ import uvicorn
14
+ from fastapi import APIRouter, Depends, FastAPI, Query, Request
15
+ from fastapi.exceptions import RequestValidationError
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import JSONResponse, StreamingResponse
18
+ from pydantic import BaseModel, Field
19
+
20
+ from mash.logging.logger import EventLogger
21
+ from mash.memory.search.service import MemorySearchService
22
+ from mash.memory.search.types import FusionWeights, RetrievalConfig
23
+ from mash.memory.store import SQLiteStore
24
+ from mash.runtime import MashAgentClient, MashAgentClientError, MashAgentHost, MashRuntimeDefinition
25
+
26
+ from .config import MashAPIConfig
27
+ from .types import SubagentRegistration
28
+
29
+
30
+ class APIError(RuntimeError):
31
+ """Structured API error for envelope serialization."""
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ code: str,
37
+ message: str,
38
+ status_code: int,
39
+ details: Optional[dict[str, Any]] = None,
40
+ ) -> None:
41
+ super().__init__(message)
42
+ self.code = code
43
+ self.message = message
44
+ self.status_code = status_code
45
+ self.details = details or {}
46
+
47
+
48
+ @dataclass
49
+ class _AppRuntimeState:
50
+ host: MashAgentHost
51
+ primary_agent_id: str
52
+ primary_client: MashAgentClient
53
+ api_key: Optional[str]
54
+ observability_enabled: bool
55
+ observability_log_path: Optional[Path]
56
+ observability_memory_db_path: Optional[Path]
57
+ search_service: Optional[MemorySearchService]
58
+ default_events_limit: int
59
+ default_search_limit: int
60
+
61
+
62
+ class InvokeRequest(BaseModel):
63
+ message: str = Field(min_length=1)
64
+ session_id: Optional[str] = None
65
+ turn_metadata: dict[str, Any] = Field(default_factory=dict)
66
+ timeout_ms: Optional[int] = None
67
+
68
+
69
+ class SubmitRequest(BaseModel):
70
+ message: str = Field(min_length=1)
71
+ session_id: Optional[str] = None
72
+ turn_metadata: dict[str, Any] = Field(default_factory=dict)
73
+
74
+
75
+ class PreferencesUpdateRequest(BaseModel):
76
+ preferences: dict[str, Any]
77
+
78
+
79
+ class AppDataSetRequest(BaseModel):
80
+ value: Any
81
+
82
+
83
+ class CompactSessionRequest(BaseModel):
84
+ reason: str = "manual"
85
+ session_total_tokens_reset: int = 0
86
+
87
+
88
+ def _success(data: Any) -> dict[str, Any]:
89
+ return {"data": data}
90
+
91
+
92
+ def _error_payload(code: str, message: str, details: Optional[dict[str, Any]] = None) -> dict[str, Any]:
93
+ return {
94
+ "error": {
95
+ "code": code,
96
+ "message": message,
97
+ "details": details or {},
98
+ }
99
+ }
100
+
101
+
102
+ def _state_from_request(request: Request) -> _AppRuntimeState:
103
+ state = getattr(request.app.state, "runtime_state", None)
104
+ if state is None:
105
+ raise APIError(
106
+ code="RUNTIME_NOT_READY",
107
+ message="runtime is not initialized",
108
+ status_code=503,
109
+ )
110
+ return state
111
+
112
+
113
+ def _normalize_optional_text(value: Optional[str]) -> Optional[str]:
114
+ if value is None:
115
+ return None
116
+ text = value.strip()
117
+ return text or None
118
+
119
+
120
+ def _require_message(value: str) -> str:
121
+ text = str(value or "").strip()
122
+ if not text:
123
+ raise APIError(code="INVALID_REQUEST", message="message is required", status_code=400)
124
+ return text
125
+
126
+
127
+ def _parse_limit(raw: Optional[int], *, default: int, max_value: int) -> int:
128
+ if raw is None:
129
+ return default
130
+ return max(1, min(int(raw), max_value))
131
+
132
+
133
+ def _build_runtime_event_sse_payload(event_name: str, payload: Any) -> str:
134
+ data = json.dumps(payload, ensure_ascii=True)
135
+ return f"event: {event_name}\ndata: {data}\n\n"
136
+
137
+
138
+ def _build_observability_sse_payload(payload: str) -> str:
139
+ return f"data: {payload}\n\n"
140
+
141
+
142
+ def create_app(
143
+ definition: MashRuntimeDefinition,
144
+ *,
145
+ subagents: Sequence[SubagentRegistration] | None = None,
146
+ config: MashAPIConfig | None = None,
147
+ ) -> FastAPI:
148
+ """Build a FastAPI app that composes Mash runtime + observability APIs."""
149
+
150
+ resolved_config = config or MashAPIConfig()
151
+
152
+ @asynccontextmanager
153
+ async def _lifespan(application: FastAPI):
154
+ host = MashAgentHost(bind_host=resolved_config.runtime_bind_host)
155
+ primary_agent_id = host.register_primary(definition)
156
+ for registration in subagents or ():
157
+ host.register_subagent(
158
+ registration.definition,
159
+ metadata=registration.metadata,
160
+ agent_id=registration.agent_id,
161
+ )
162
+ host.start()
163
+ primary_client = host.get_client(primary_agent_id)
164
+
165
+ default_log_path = definition.get_log_destination().expanduser().resolve()
166
+ observability_log_path = resolved_config.resolved_log_path() or default_log_path
167
+ observability_memory_db_path = resolved_config.resolved_memory_db_path()
168
+
169
+ search_service: Optional[MemorySearchService] = None
170
+ if resolved_config.enable_observability and observability_memory_db_path is not None:
171
+ event_logger = EventLogger(observability_log_path)
172
+ search_service = MemorySearchService(
173
+ SQLiteStore(observability_memory_db_path),
174
+ event_logger=event_logger,
175
+ retrieval_config=RetrievalConfig(enable_keyword=True, enable_semantic=False),
176
+ fusion_weights=FusionWeights(keyword_weight=1.0, semantic_weight=0.0),
177
+ )
178
+
179
+ application.state.runtime_state = _AppRuntimeState(
180
+ host=host,
181
+ primary_agent_id=primary_agent_id,
182
+ primary_client=primary_client,
183
+ api_key=resolved_config.resolved_api_key(),
184
+ observability_enabled=resolved_config.enable_observability,
185
+ observability_log_path=observability_log_path,
186
+ observability_memory_db_path=observability_memory_db_path,
187
+ search_service=search_service,
188
+ default_events_limit=max(1, int(resolved_config.default_events_limit)),
189
+ default_search_limit=max(1, int(resolved_config.default_search_limit)),
190
+ )
191
+ try:
192
+ yield
193
+ finally:
194
+ state = getattr(application.state, "runtime_state", None)
195
+ if state is not None:
196
+ state.host.close()
197
+ application.state.runtime_state = None
198
+
199
+ app = FastAPI(title="Mash API", version="1.0.0", lifespan=_lifespan)
200
+
201
+ cors_origins = resolved_config.resolved_cors_origins()
202
+ if cors_origins:
203
+ app.add_middleware(
204
+ CORSMiddleware,
205
+ allow_origins=cors_origins,
206
+ allow_methods=["*"],
207
+ allow_headers=["*"],
208
+ allow_credentials=False,
209
+ )
210
+
211
+ @app.exception_handler(APIError)
212
+ async def _api_error_handler(_: Request, exc: APIError) -> JSONResponse:
213
+ return JSONResponse(
214
+ status_code=exc.status_code,
215
+ content=_error_payload(exc.code, exc.message, exc.details),
216
+ )
217
+
218
+ @app.exception_handler(RequestValidationError)
219
+ async def _validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
220
+ return JSONResponse(
221
+ status_code=422,
222
+ content=_error_payload(
223
+ "VALIDATION_ERROR",
224
+ "request validation failed",
225
+ {"errors": exc.errors()},
226
+ ),
227
+ )
228
+
229
+ @app.exception_handler(MashAgentClientError)
230
+ async def _client_error_handler(_: Request, exc: MashAgentClientError) -> JSONResponse:
231
+ return JSONResponse(
232
+ status_code=502,
233
+ content=_error_payload("RUNTIME_CLIENT_ERROR", str(exc)),
234
+ )
235
+
236
+ async def _authorize(request: Request) -> None:
237
+ state = _state_from_request(request)
238
+ expected_key = state.api_key
239
+ if expected_key is None:
240
+ return
241
+
242
+ header_token: Optional[str] = None
243
+ auth_header = request.headers.get("authorization")
244
+ if isinstance(auth_header, str) and auth_header.lower().startswith("bearer "):
245
+ header_token = auth_header[7:].strip() or None
246
+
247
+ x_api_key = _normalize_optional_text(request.headers.get("x-api-key"))
248
+ provided = header_token or x_api_key
249
+ if provided != expected_key:
250
+ raise APIError(
251
+ code="UNAUTHORIZED",
252
+ message="valid API key is required",
253
+ status_code=401,
254
+ )
255
+
256
+ api = APIRouter(prefix=resolved_config.api_prefix, dependencies=[Depends(_authorize)])
257
+
258
+ @api.get("/health")
259
+ def health(request: Request) -> dict[str, Any]:
260
+ state = _state_from_request(request)
261
+ session_info = state.primary_client.get_session_info()
262
+ log_path = state.observability_log_path
263
+ return _success(
264
+ {
265
+ "status": "ok",
266
+ "api_version": "v1",
267
+ "runtime": {
268
+ "primary_agent_id": state.primary_agent_id,
269
+ "app_id": session_info.get("app_id"),
270
+ "session_id": session_info.get("session_id"),
271
+ "subagent_ids": session_info.get("subagent_ids", []),
272
+ "model": session_info.get("model"),
273
+ "max_steps": session_info.get("max_steps"),
274
+ },
275
+ "observability": {
276
+ "enabled": state.observability_enabled,
277
+ "events": {
278
+ "configured": log_path is not None,
279
+ "path": str(log_path) if log_path is not None else None,
280
+ "exists": bool(log_path.exists()) if log_path is not None else False,
281
+ "default_limit": state.default_events_limit,
282
+ },
283
+ "memory": {
284
+ "configured": state.observability_memory_db_path is not None,
285
+ "search_available": state.search_service is not None,
286
+ "path": (
287
+ str(state.observability_memory_db_path)
288
+ if state.observability_memory_db_path is not None
289
+ else None
290
+ ),
291
+ "default_limit": state.default_search_limit,
292
+ },
293
+ },
294
+ }
295
+ )
296
+
297
+ @api.post("/interactions/invoke")
298
+ def invoke(request: Request, body: InvokeRequest) -> dict[str, Any]:
299
+ state = _state_from_request(request)
300
+ message = _require_message(body.message)
301
+ session_id = _normalize_optional_text(body.session_id)
302
+ timeout_ms = body.timeout_ms if body.timeout_ms is None else int(body.timeout_ms)
303
+ if timeout_ms is not None and timeout_ms <= 0:
304
+ timeout_ms = None
305
+
306
+ try:
307
+ result = state.primary_client.invoke(
308
+ message,
309
+ session_id=session_id,
310
+ turn_metadata=dict(body.turn_metadata or {}),
311
+ timeout_ms=timeout_ms,
312
+ )
313
+ except TimeoutError as exc:
314
+ raise APIError(
315
+ code="REQUEST_TIMEOUT",
316
+ message=str(exc),
317
+ status_code=504,
318
+ ) from exc
319
+ return _success(result)
320
+
321
+ @api.post("/interactions/requests")
322
+ def submit_request(request: Request, body: SubmitRequest) -> dict[str, Any]:
323
+ state = _state_from_request(request)
324
+ request_id = state.primary_client.post_request(
325
+ _require_message(body.message),
326
+ session_id=_normalize_optional_text(body.session_id),
327
+ turn_metadata=dict(body.turn_metadata or {}),
328
+ )
329
+ return _success({"request_id": request_id})
330
+
331
+ @api.get("/interactions/requests/{request_id}/events")
332
+ def stream_request_events(request: Request, request_id: str) -> StreamingResponse:
333
+ state = _state_from_request(request)
334
+ normalized_request_id = request_id.strip()
335
+ if not normalized_request_id:
336
+ raise APIError(
337
+ code="INVALID_REQUEST",
338
+ message="request_id is required",
339
+ status_code=400,
340
+ )
341
+
342
+ def _generate() -> Iterator[str]:
343
+ try:
344
+ for event in state.primary_client.stream(normalized_request_id):
345
+ event_name = str(event.get("event") or "message")
346
+ payload = event.get("data")
347
+ yield _build_runtime_event_sse_payload(event_name, payload)
348
+ if event_name in {"request.completed", "request.error"}:
349
+ break
350
+ except MashAgentClientError as exc:
351
+ yield _build_runtime_event_sse_payload(
352
+ "request.error",
353
+ {
354
+ "request_id": normalized_request_id,
355
+ "status": "error",
356
+ "error": str(exc),
357
+ },
358
+ )
359
+
360
+ return StreamingResponse(
361
+ _generate(),
362
+ media_type="text/event-stream",
363
+ headers={
364
+ "Cache-Control": "no-cache",
365
+ "Connection": "keep-alive",
366
+ },
367
+ )
368
+
369
+ @api.get("/runtime/session")
370
+ def get_runtime_session(request: Request, session_id: Optional[str] = None) -> dict[str, Any]:
371
+ state = _state_from_request(request)
372
+ info = state.primary_client.get_session_info(_normalize_optional_text(session_id))
373
+ return _success(info)
374
+
375
+ @api.get("/runtime/subagents")
376
+ def get_subagents(request: Request) -> dict[str, Any]:
377
+ state = _state_from_request(request)
378
+ return _success({"subagent_ids": state.primary_client.get_subagent_ids()})
379
+
380
+ @api.get("/runtime/sessions/{session_id}/preferences")
381
+ def get_preferences(request: Request, session_id: str) -> dict[str, Any]:
382
+ state = _state_from_request(request)
383
+ if not session_id.strip():
384
+ raise APIError(code="INVALID_REQUEST", message="session_id is required", status_code=400)
385
+ return _success({"preferences": state.primary_client.get_preferences(session_id)})
386
+
387
+ @api.put("/runtime/sessions/{session_id}/preferences")
388
+ def set_preferences(request: Request, session_id: str, body: PreferencesUpdateRequest) -> dict[str, Any]:
389
+ state = _state_from_request(request)
390
+ if not session_id.strip():
391
+ raise APIError(code="INVALID_REQUEST", message="session_id is required", status_code=400)
392
+ state.primary_client.set_preferences(session_id, body.preferences)
393
+ return _success({"ok": True})
394
+
395
+ @api.get("/runtime/sessions/{session_id}/app-data")
396
+ def list_app_data(request: Request, session_id: str) -> dict[str, Any]:
397
+ state = _state_from_request(request)
398
+ if not session_id.strip():
399
+ raise APIError(code="INVALID_REQUEST", message="session_id is required", status_code=400)
400
+ return _success({"items": state.primary_client.list_app_data(session_id)})
401
+
402
+ @api.get("/runtime/sessions/{session_id}/app-data/{key}")
403
+ def get_app_data(request: Request, session_id: str, key: str) -> dict[str, Any]:
404
+ state = _state_from_request(request)
405
+ if not session_id.strip() or not key.strip():
406
+ raise APIError(code="INVALID_REQUEST", message="session_id and key are required", status_code=400)
407
+ return _success({"value": state.primary_client.get_app_data(session_id, key)})
408
+
409
+ @api.put("/runtime/sessions/{session_id}/app-data/{key}")
410
+ def set_app_data(request: Request, session_id: str, key: str, body: AppDataSetRequest) -> dict[str, Any]:
411
+ state = _state_from_request(request)
412
+ if not session_id.strip() or not key.strip():
413
+ raise APIError(code="INVALID_REQUEST", message="session_id and key are required", status_code=400)
414
+ state.primary_client.set_app_data(session_id, key, body.value)
415
+ return _success({"ok": True})
416
+
417
+ @api.delete("/runtime/sessions/{session_id}/app-data/{key}")
418
+ def delete_app_data(request: Request, session_id: str, key: str) -> dict[str, Any]:
419
+ state = _state_from_request(request)
420
+ if not session_id.strip() or not key.strip():
421
+ raise APIError(code="INVALID_REQUEST", message="session_id and key are required", status_code=400)
422
+ deleted = state.primary_client.delete_app_data(session_id, key)
423
+ return _success({"deleted": bool(deleted)})
424
+
425
+ @api.get("/runtime/sessions/{session_id}/history")
426
+ def get_history(
427
+ request: Request,
428
+ session_id: str,
429
+ limit: Optional[int] = Query(default=None),
430
+ ) -> dict[str, Any]:
431
+ state = _state_from_request(request)
432
+ if not session_id.strip():
433
+ raise APIError(code="INVALID_REQUEST", message="session_id is required", status_code=400)
434
+ turns = state.primary_client.get_history_turns(session_id, limit=limit)
435
+ return _success({"turns": turns})
436
+
437
+ @api.post("/runtime/sessions/{session_id}/compact")
438
+ def compact_session(
439
+ request: Request,
440
+ session_id: str,
441
+ body: CompactSessionRequest,
442
+ ) -> dict[str, Any]:
443
+ state = _state_from_request(request)
444
+ if not session_id.strip():
445
+ raise APIError(code="INVALID_REQUEST", message="session_id is required", status_code=400)
446
+ summary_text, turn_id = state.primary_client.compact_session(
447
+ session_id=session_id,
448
+ reason=body.reason,
449
+ session_total_tokens_reset=body.session_total_tokens_reset,
450
+ )
451
+ return _success({"summary_text": summary_text, "turn_id": turn_id})
452
+
453
+ @api.get("/telemetry/events")
454
+ def get_observability_events(
455
+ request: Request,
456
+ limit: Optional[int] = Query(default=None),
457
+ ) -> dict[str, Any]:
458
+ state = _state_from_request(request)
459
+ if not state.observability_enabled:
460
+ raise APIError(
461
+ code="OBSERVABILITY_DISABLED",
462
+ message="telemetry endpoints are disabled",
463
+ status_code=503,
464
+ )
465
+
466
+ log_path = state.observability_log_path
467
+ if log_path is None:
468
+ raise APIError(
469
+ code="OBSERVABILITY_EVENTS_UNCONFIGURED",
470
+ message="telemetry log path is not configured",
471
+ status_code=503,
472
+ )
473
+ if not log_path.exists():
474
+ raise APIError(
475
+ code="LOG_FILE_NOT_FOUND",
476
+ message="log file not found",
477
+ status_code=404,
478
+ details={"path": str(log_path)},
479
+ )
480
+
481
+ resolved_limit = _parse_limit(limit, default=state.default_events_limit, max_value=20000)
482
+ events: list[dict[str, Any]] = []
483
+ with log_path.open("r", encoding="utf-8") as handle:
484
+ lines = handle.readlines()
485
+
486
+ for raw in lines[-resolved_limit:]:
487
+ line = raw.strip()
488
+ if not line:
489
+ continue
490
+ try:
491
+ payload = json.loads(line)
492
+ except json.JSONDecodeError:
493
+ continue
494
+ if isinstance(payload, dict):
495
+ events.append(payload)
496
+
497
+ return _success({
498
+ "events": events,
499
+ "path": str(log_path),
500
+ "limit": resolved_limit,
501
+ })
502
+
503
+ @api.get("/telemetry/events/stream")
504
+ async def stream_observability_events(request: Request) -> StreamingResponse:
505
+ state = _state_from_request(request)
506
+ if not state.observability_enabled:
507
+ raise APIError(
508
+ code="OBSERVABILITY_DISABLED",
509
+ message="telemetry endpoints are disabled",
510
+ status_code=503,
511
+ )
512
+
513
+ log_path = state.observability_log_path
514
+ if log_path is None:
515
+ raise APIError(
516
+ code="OBSERVABILITY_EVENTS_UNCONFIGURED",
517
+ message="telemetry log path is not configured",
518
+ status_code=503,
519
+ )
520
+ if not log_path.exists():
521
+ raise APIError(
522
+ code="LOG_FILE_NOT_FOUND",
523
+ message="log file not found",
524
+ status_code=404,
525
+ details={"path": str(log_path)},
526
+ )
527
+
528
+ async def _generate() -> AsyncIterator[str]:
529
+ with log_path.open("r", encoding="utf-8") as handle:
530
+ handle.seek(0, os.SEEK_END)
531
+ while True:
532
+ if await request.is_disconnected():
533
+ break
534
+ line = handle.readline()
535
+ if not line:
536
+ yield ": keep-alive\n\n"
537
+ await asyncio.sleep(0.25)
538
+ continue
539
+ payload = line.strip()
540
+ if not payload:
541
+ continue
542
+ try:
543
+ json.loads(payload)
544
+ except json.JSONDecodeError:
545
+ continue
546
+ yield _build_observability_sse_payload(payload)
547
+
548
+ return StreamingResponse(
549
+ _generate(),
550
+ media_type="text/event-stream",
551
+ headers={
552
+ "Cache-Control": "no-cache",
553
+ "Connection": "keep-alive",
554
+ },
555
+ )
556
+
557
+ @api.get("/telemetry/memory/search")
558
+ def search_memory(
559
+ request: Request,
560
+ q: str,
561
+ app_id: str,
562
+ session_id: Optional[str] = None,
563
+ limit: Optional[int] = Query(default=None),
564
+ ) -> dict[str, Any]:
565
+ state = _state_from_request(request)
566
+ if not state.observability_enabled:
567
+ raise APIError(
568
+ code="OBSERVABILITY_DISABLED",
569
+ message="telemetry endpoints are disabled",
570
+ status_code=503,
571
+ )
572
+
573
+ query_text = q.strip()
574
+ if not query_text:
575
+ raise APIError(
576
+ code="MISSING_QUERY",
577
+ message="q is required",
578
+ status_code=400,
579
+ details={"param": "q"},
580
+ )
581
+
582
+ app_id_value = app_id.strip()
583
+ if not app_id_value:
584
+ raise APIError(
585
+ code="MISSING_APP_ID",
586
+ message="app_id is required",
587
+ status_code=400,
588
+ details={"param": "app_id"},
589
+ )
590
+
591
+ if state.search_service is None:
592
+ raise APIError(
593
+ code="MEMORY_SEARCH_UNAVAILABLE",
594
+ message="memory search unavailable (configure memory db path)",
595
+ status_code=503,
596
+ )
597
+
598
+ resolved_limit = _parse_limit(limit, default=state.default_search_limit, max_value=50)
599
+ normalized_session_id = _normalize_optional_text(session_id)
600
+ try:
601
+ results = state.search_service.search(
602
+ query_text,
603
+ limit=resolved_limit,
604
+ session_id=normalized_session_id,
605
+ app_id=app_id_value,
606
+ )
607
+ except ValueError as exc:
608
+ raise APIError(
609
+ code="SEARCH_VALIDATION_ERROR",
610
+ message=str(exc),
611
+ status_code=400,
612
+ ) from exc
613
+ except (NotImplementedError, RuntimeError) as exc:
614
+ raise APIError(
615
+ code="SEARCH_UNAVAILABLE",
616
+ message=str(exc),
617
+ status_code=503,
618
+ ) from exc
619
+ except Exception as exc: # pragma: no cover
620
+ raise APIError(
621
+ code="SEARCH_FAILED",
622
+ message=f"search failed: {exc}",
623
+ status_code=500,
624
+ ) from exc
625
+
626
+ return _success(
627
+ {
628
+ "results": [asdict(result) for result in results],
629
+ "app_id": app_id_value,
630
+ "session_id": normalized_session_id,
631
+ "query": query_text,
632
+ "limit": resolved_limit,
633
+ }
634
+ )
635
+
636
+ app.include_router(api)
637
+
638
+ @app.get("/")
639
+ def root() -> dict[str, Any]:
640
+ return {
641
+ "service": "mash-api",
642
+ "api": {
643
+ "version": "v1",
644
+ "base": resolved_config.api_prefix,
645
+ "openapi": "/openapi.json",
646
+ "docs": "/docs",
647
+ },
648
+ }
649
+
650
+ return app
651
+
652
+
653
+ def run_app(
654
+ definition: MashRuntimeDefinition,
655
+ *,
656
+ subagents: Sequence[SubagentRegistration] | None = None,
657
+ config: MashAPIConfig | None = None,
658
+ ) -> None:
659
+ """Run mash-api service with uvicorn."""
660
+ resolved_config = config or MashAPIConfig()
661
+ app = create_app(definition, subagents=subagents, config=resolved_config)
662
+ uvicorn.run(app, host=resolved_config.bind_host, port=resolved_config.bind_port)
@@ -0,0 +1,53 @@
1
+ """Configuration types for mash-api."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional, Sequence
8
+
9
+ _DEFAULT_CORS_ORIGINS = (
10
+ "http://127.0.0.1:3000",
11
+ "http://localhost:3000",
12
+ "http://127.0.0.1:5173",
13
+ "http://localhost:5173",
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class MashAPIConfig:
19
+ """Runtime configuration for mash-api server composition."""
20
+
21
+ api_prefix: str = "/api/v1"
22
+ bind_host: str = "127.0.0.1"
23
+ bind_port: int = 8000
24
+ runtime_bind_host: str = "127.0.0.1"
25
+ api_key: Optional[str] = None
26
+ cors_allow_origins: Sequence[str] = field(default_factory=lambda: _DEFAULT_CORS_ORIGINS)
27
+ enable_observability: bool = True
28
+ observability_log_path: Optional[Path] = None
29
+ observability_memory_db_path: Optional[Path] = None
30
+ default_events_limit: int = 2000
31
+ default_search_limit: int = 10
32
+
33
+ def resolved_api_key(self) -> Optional[str]:
34
+ value = (self.api_key or "").strip()
35
+ return value or None
36
+
37
+ def resolved_cors_origins(self) -> list[str]:
38
+ values: list[str] = []
39
+ for origin in self.cors_allow_origins:
40
+ text = str(origin).strip()
41
+ if text:
42
+ values.append(text)
43
+ return values
44
+
45
+ def resolved_log_path(self) -> Optional[Path]:
46
+ if self.observability_log_path is None:
47
+ return None
48
+ return Path(self.observability_log_path).expanduser().resolve()
49
+
50
+ def resolved_memory_db_path(self) -> Optional[Path]:
51
+ if self.observability_memory_db_path is None:
52
+ return None
53
+ return Path(self.observability_memory_db_path).expanduser().resolve()
@@ -0,0 +1,114 @@
1
+ """CLI entrypoint for mash-api package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import os
8
+ from typing import Any, Sequence
9
+
10
+ import uvicorn
11
+
12
+ from mash.runtime import MashRuntimeDefinition
13
+
14
+ from .app import create_app
15
+ from .config import MashAPIConfig
16
+ from .types import MashAPIAppSpec
17
+
18
+
19
+ def _load_target(app_ref: str) -> Any:
20
+ module_name, sep, attr_name = app_ref.partition(":")
21
+ if not sep or not module_name.strip() or not attr_name.strip():
22
+ raise ValueError("--app must be in 'module:attribute' format")
23
+
24
+ module = importlib.import_module(module_name)
25
+ if not hasattr(module, attr_name):
26
+ raise ValueError(f"module '{module_name}' has no attribute '{attr_name}'")
27
+ return getattr(module, attr_name)
28
+
29
+
30
+ def _resolve_spec(app_ref: str) -> MashAPIAppSpec:
31
+ target = _load_target(app_ref)
32
+ resolved = target() if callable(target) else target
33
+
34
+ if isinstance(resolved, MashAPIAppSpec):
35
+ return resolved
36
+ if isinstance(resolved, MashRuntimeDefinition):
37
+ return MashAPIAppSpec(definition=resolved)
38
+
39
+ raise ValueError(
40
+ "app target must resolve to MashRuntimeDefinition or MashAPIAppSpec"
41
+ )
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(description="Run mash-api server")
46
+ parser.add_argument(
47
+ "--app",
48
+ required=True,
49
+ help="Application reference in module:attribute format.",
50
+ )
51
+ parser.add_argument("--host", default="127.0.0.1", help="API bind host")
52
+ parser.add_argument("--port", type=int, default=8000, help="API bind port")
53
+ parser.add_argument(
54
+ "--runtime-bind-host",
55
+ default="127.0.0.1",
56
+ help="Internal mash runtime bind host",
57
+ )
58
+ parser.add_argument(
59
+ "--api-key",
60
+ default=None,
61
+ help="Optional API key (or set MASH_API_KEY)",
62
+ )
63
+ parser.add_argument(
64
+ "--cors-origin",
65
+ action="append",
66
+ default=None,
67
+ help="Allowed CORS origin; repeat for multiple values.",
68
+ )
69
+ parser.add_argument("--log-path", default=None, help="Observability log path")
70
+ parser.add_argument(
71
+ "--memory-db",
72
+ default=None,
73
+ help="Optional SQLite path for /telemetry/memory/search",
74
+ )
75
+ parser.add_argument(
76
+ "--disable-observability",
77
+ action="store_true",
78
+ help="Disable telemetry endpoints.",
79
+ )
80
+ return parser
81
+
82
+
83
+ def main(argv: Sequence[str] | None = None) -> int:
84
+ parser = build_parser()
85
+ args = parser.parse_args(argv)
86
+
87
+ spec = _resolve_spec(args.app)
88
+
89
+ api_key = args.api_key
90
+ if api_key is None:
91
+ api_key = os.environ.get("MASH_API_KEY")
92
+
93
+ config = MashAPIConfig(
94
+ bind_host=args.host,
95
+ bind_port=args.port,
96
+ runtime_bind_host=args.runtime_bind_host,
97
+ api_key=api_key,
98
+ cors_allow_origins=(
99
+ args.cors_origin
100
+ if args.cors_origin is not None
101
+ else MashAPIConfig().cors_allow_origins
102
+ ),
103
+ enable_observability=not args.disable_observability,
104
+ observability_log_path=args.log_path,
105
+ observability_memory_db_path=args.memory_db,
106
+ )
107
+
108
+ app = create_app(spec.definition, subagents=spec.subagents, config=config)
109
+ uvicorn.run(app, host=config.bind_host, port=config.bind_port)
110
+ return 0
111
+
112
+
113
+ if __name__ == "__main__":
114
+ raise SystemExit(main())
@@ -0,0 +1,25 @@
1
+ """Public types for mash-api app composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Sequence
7
+
8
+ from mash.runtime import MashRuntimeDefinition, SubAgentMetadata
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SubagentRegistration:
13
+ """Subagent registration payload for host-backed API composition."""
14
+
15
+ definition: MashRuntimeDefinition
16
+ metadata: SubAgentMetadata
17
+ agent_id: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class MashAPIAppSpec:
22
+ """Application spec used by mash-api CLI loading."""
23
+
24
+ definition: MashRuntimeDefinition
25
+ subagents: Sequence[SubagentRegistration] = ()
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: mash-api
3
+ Version: 0.1.0
4
+ Summary: OpenAPI service package for Mash applications
5
+ Author: imsid
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: mashpy>=0.1.2
10
+ Requires-Dist: fastapi>=0.115.0
11
+ Requires-Dist: uvicorn>=0.30.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.3.0; extra == "dev"
14
+ Requires-Dist: httpx>=0.27.0; extra == "dev"
15
+
16
+ # mash-api
17
+
18
+ OpenAPI service package for self-hosted Mash applications.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install mash-api
24
+ # or
25
+ pip install "mashpy[api]"
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```python
31
+ from mash_api import create_app
32
+ from my_app import definition
33
+
34
+ app = create_app(definition)
35
+ ```
36
+
37
+ Run with Uvicorn:
38
+
39
+ ```bash
40
+ uvicorn my_module:app --host 127.0.0.1 --port 8000
41
+ ```
42
+
43
+ Or use the bundled CLI:
44
+
45
+ ```bash
46
+ mash-api --app my_app:build_definition
47
+ ```
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/mash_api/__init__.py
4
+ src/mash_api/app.py
5
+ src/mash_api/config.py
6
+ src/mash_api/main.py
7
+ src/mash_api/types.py
8
+ src/mash_api.egg-info/PKG-INFO
9
+ src/mash_api.egg-info/SOURCES.txt
10
+ src/mash_api.egg-info/dependency_links.txt
11
+ src/mash_api.egg-info/entry_points.txt
12
+ src/mash_api.egg-info/requires.txt
13
+ src/mash_api.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mash-api = mash_api.main:main
@@ -0,0 +1,7 @@
1
+ mashpy>=0.1.2
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.30.0
4
+
5
+ [dev]
6
+ pytest>=8.3.0
7
+ httpx>=0.27.0
@@ -0,0 +1 @@
1
+ mash_api