openvox-core 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.
Files changed (130) hide show
  1. openvox_core-0.1.0/.gitignore +52 -0
  2. openvox_core-0.1.0/PKG-INFO +82 -0
  3. openvox_core-0.1.0/README.md +6 -0
  4. openvox_core-0.1.0/openvox/__init__.py +3 -0
  5. openvox_core-0.1.0/openvox/api/__init__.py +3 -0
  6. openvox_core-0.1.0/openvox/api/app.py +210 -0
  7. openvox_core-0.1.0/openvox/api/routes/__init__.py +0 -0
  8. openvox_core-0.1.0/openvox/api/routes/admin.py +133 -0
  9. openvox_core-0.1.0/openvox/api/routes/agents.py +340 -0
  10. openvox_core-0.1.0/openvox/api/routes/auth.py +105 -0
  11. openvox_core-0.1.0/openvox/api/routes/documents.py +133 -0
  12. openvox_core-0.1.0/openvox/api/routes/evals.py +331 -0
  13. openvox_core-0.1.0/openvox/api/routes/health.py +19 -0
  14. openvox_core-0.1.0/openvox/api/routes/jobs.py +260 -0
  15. openvox_core-0.1.0/openvox/api/routes/mcp.py +81 -0
  16. openvox_core-0.1.0/openvox/api/routes/playground.py +420 -0
  17. openvox_core-0.1.0/openvox/api/routes/pricing.py +195 -0
  18. openvox_core-0.1.0/openvox/api/routes/providers.py +55 -0
  19. openvox_core-0.1.0/openvox/api/routes/rtc.py +28 -0
  20. openvox_core-0.1.0/openvox/api/routes/sessions.py +86 -0
  21. openvox_core-0.1.0/openvox/api/routes/skills.py +31 -0
  22. openvox_core-0.1.0/openvox/api/routes/storage.py +19 -0
  23. openvox_core-0.1.0/openvox/api/routes/telephony.py +971 -0
  24. openvox_core-0.1.0/openvox/api/routes/templates.py +1318 -0
  25. openvox_core-0.1.0/openvox/api/ws/__init__.py +0 -0
  26. openvox_core-0.1.0/openvox/api/ws/twilio_stream.py +336 -0
  27. openvox_core-0.1.0/openvox/api/ws/voice.py +378 -0
  28. openvox_core-0.1.0/openvox/cli/__init__.py +60 -0
  29. openvox_core-0.1.0/openvox/cli/__main__.py +12 -0
  30. openvox_core-0.1.0/openvox/cli/commands/__init__.py +0 -0
  31. openvox_core-0.1.0/openvox/cli/commands/info.py +94 -0
  32. openvox_core-0.1.0/openvox/cli/commands/logs.py +89 -0
  33. openvox_core-0.1.0/openvox/cli/commands/restart.py +18 -0
  34. openvox_core-0.1.0/openvox/cli/commands/run.py +137 -0
  35. openvox_core-0.1.0/openvox/cli/commands/start.py +61 -0
  36. openvox_core-0.1.0/openvox/cli/commands/status.py +22 -0
  37. openvox_core-0.1.0/openvox/cli/commands/stop.py +20 -0
  38. openvox_core-0.1.0/openvox/cli/commands/version.py +25 -0
  39. openvox_core-0.1.0/openvox/cli/daemon/__init__.py +47 -0
  40. openvox_core-0.1.0/openvox/cli/daemon/base.py +80 -0
  41. openvox_core-0.1.0/openvox/cli/daemon/launchd.py +174 -0
  42. openvox_core-0.1.0/openvox/cli/daemon/systemd.py +162 -0
  43. openvox_core-0.1.0/openvox/cli/daemon/windows_service.py +155 -0
  44. openvox_core-0.1.0/openvox/cli/main.py +51 -0
  45. openvox_core-0.1.0/openvox/config.py +174 -0
  46. openvox_core-0.1.0/openvox/db/__init__.py +5 -0
  47. openvox_core-0.1.0/openvox/db/models.py +437 -0
  48. openvox_core-0.1.0/openvox/db/session.py +99 -0
  49. openvox_core-0.1.0/openvox/eval/__init__.py +15 -0
  50. openvox_core-0.1.0/openvox/eval/judge.py +136 -0
  51. openvox_core-0.1.0/openvox/eval/personas.py +84 -0
  52. openvox_core-0.1.0/openvox/eval/runner.py +208 -0
  53. openvox_core-0.1.0/openvox/mcp/__init__.py +17 -0
  54. openvox_core-0.1.0/openvox/mcp/bridge.py +184 -0
  55. openvox_core-0.1.0/openvox/mcp/catalogue.json +106 -0
  56. openvox_core-0.1.0/openvox/pipeline/__init__.py +3 -0
  57. openvox_core-0.1.0/openvox/pipeline/orchestrator.py +649 -0
  58. openvox_core-0.1.0/openvox/pricing/__init__.py +10 -0
  59. openvox_core-0.1.0/openvox/pricing/rates.py +345 -0
  60. openvox_core-0.1.0/openvox/providers/__init__.py +42 -0
  61. openvox_core-0.1.0/openvox/providers/base.py +238 -0
  62. openvox_core-0.1.0/openvox/providers/bootstrap.py +53 -0
  63. openvox_core-0.1.0/openvox/providers/byteplus/__init__.py +8 -0
  64. openvox_core-0.1.0/openvox/providers/byteplus/llm.py +169 -0
  65. openvox_core-0.1.0/openvox/providers/byteplus/rtc.py +128 -0
  66. openvox_core-0.1.0/openvox/providers/byteplus/stt.py +423 -0
  67. openvox_core-0.1.0/openvox/providers/byteplus/tts.py +185 -0
  68. openvox_core-0.1.0/openvox/providers/byteplus/voices.py +109 -0
  69. openvox_core-0.1.0/openvox/providers/openai_compat/__init__.py +30 -0
  70. openvox_core-0.1.0/openvox/providers/openai_compat/_openai_base.py +121 -0
  71. openvox_core-0.1.0/openvox/providers/openai_compat/anthropic.py +101 -0
  72. openvox_core-0.1.0/openvox/providers/openai_compat/assemblyai_stt.py +87 -0
  73. openvox_core-0.1.0/openvox/providers/openai_compat/cartesia_tts.py +94 -0
  74. openvox_core-0.1.0/openvox/providers/openai_compat/deepgram_stt.py +94 -0
  75. openvox_core-0.1.0/openvox/providers/openai_compat/deepseek.py +19 -0
  76. openvox_core-0.1.0/openvox/providers/openai_compat/elevenlabs_tts.py +71 -0
  77. openvox_core-0.1.0/openvox/providers/openai_compat/gemini.py +21 -0
  78. openvox_core-0.1.0/openvox/providers/openai_compat/openai_llm.py +19 -0
  79. openvox_core-0.1.0/openvox/providers/openai_compat/openai_tts.py +78 -0
  80. openvox_core-0.1.0/openvox/providers/openai_compat/whisper_stt.py +99 -0
  81. openvox_core-0.1.0/openvox/providers/registry.py +111 -0
  82. openvox_core-0.1.0/openvox/providers/vad/__init__.py +14 -0
  83. openvox_core-0.1.0/openvox/providers/vad/base.py +56 -0
  84. openvox_core-0.1.0/openvox/providers/vad/silero.py +168 -0
  85. openvox_core-0.1.0/openvox/rag/__init__.py +22 -0
  86. openvox_core-0.1.0/openvox/rag/bm25.py +79 -0
  87. openvox_core-0.1.0/openvox/rag/byteplus_cloud.py +208 -0
  88. openvox_core-0.1.0/openvox/rag/embeddings.py +56 -0
  89. openvox_core-0.1.0/openvox/rag/extract.py +135 -0
  90. openvox_core-0.1.0/openvox/rag/store.py +194 -0
  91. openvox_core-0.1.0/openvox/scheduler/__init__.py +15 -0
  92. openvox_core-0.1.0/openvox/scheduler/engine.py +143 -0
  93. openvox_core-0.1.0/openvox/scheduler/runner.py +298 -0
  94. openvox_core-0.1.0/openvox/secrets.py +306 -0
  95. openvox_core-0.1.0/openvox/skills/__init__.py +32 -0
  96. openvox_core-0.1.0/openvox/skills/base.py +109 -0
  97. openvox_core-0.1.0/openvox/skills/builtin/__init__.py +20 -0
  98. openvox_core-0.1.0/openvox/skills/builtin/documents.py +162 -0
  99. openvox_core-0.1.0/openvox/skills/builtin/ecommerce.py +90 -0
  100. openvox_core-0.1.0/openvox/skills/builtin/education.py +74 -0
  101. openvox_core-0.1.0/openvox/skills/builtin/general.py +67 -0
  102. openvox_core-0.1.0/openvox/skills/builtin/language.py +154 -0
  103. openvox_core-0.1.0/openvox/skills/builtin/reception.py +299 -0
  104. openvox_core-0.1.0/openvox/skills/builtin/sales.py +266 -0
  105. openvox_core-0.1.0/openvox/skills/builtin/setup.py +677 -0
  106. openvox_core-0.1.0/openvox/skills/builtin/stock.py +91 -0
  107. openvox_core-0.1.0/openvox/skills/builtin/voice_analysis.py +145 -0
  108. openvox_core-0.1.0/openvox/skills/registry.py +159 -0
  109. openvox_core-0.1.0/openvox/skills/runner.py +80 -0
  110. openvox_core-0.1.0/openvox/skills/watcher.py +107 -0
  111. openvox_core-0.1.0/openvox/storage/__init__.py +6 -0
  112. openvox_core-0.1.0/openvox/storage/base.py +24 -0
  113. openvox_core-0.1.0/openvox/storage/byteplus_tos.py +58 -0
  114. openvox_core-0.1.0/openvox/storage/factory.py +35 -0
  115. openvox_core-0.1.0/openvox/storage/local.py +45 -0
  116. openvox_core-0.1.0/openvox/storage/s3.py +70 -0
  117. openvox_core-0.1.0/openvox/telephony/__init__.py +9 -0
  118. openvox_core-0.1.0/openvox/telephony/lark.py +93 -0
  119. openvox_core-0.1.0/openvox/telephony/telegram.py +212 -0
  120. openvox_core-0.1.0/openvox/telephony/telegram_polling.py +232 -0
  121. openvox_core-0.1.0/openvox/telephony/twilio.py +88 -0
  122. openvox_core-0.1.0/openvox/telephony/wechat_work.py +133 -0
  123. openvox_core-0.1.0/openvox/telephony/whatsapp_personal.py +213 -0
  124. openvox_core-0.1.0/openvox/utils/__init__.py +0 -0
  125. openvox_core-0.1.0/openvox/utils/http.py +89 -0
  126. openvox_core-0.1.0/openvox/utils/text.py +476 -0
  127. openvox_core-0.1.0/pyproject.toml +181 -0
  128. openvox_core-0.1.0/tests/__init__.py +0 -0
  129. openvox_core-0.1.0/tests/test_daemon_backends.py +194 -0
  130. openvox_core-0.1.0/tests/test_storage_sqlite_parity.py +200 -0
@@ -0,0 +1,52 @@
1
+ # Node
2
+ node_modules/
3
+ .pnpm-store/
4
+ *.tsbuildinfo
5
+ .next/
6
+ out/
7
+ dist/
8
+ .turbo/
9
+
10
+ # Python
11
+ __pycache__/
12
+ *.py[cod]
13
+ *$py.class
14
+ *.egg-info/
15
+ .eggs/
16
+ build/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .venv/
21
+ venv/
22
+ env/
23
+
24
+ # Env
25
+ .env
26
+ .env.local
27
+ .env.*.local
28
+ !.env.example
29
+
30
+ # Editor
31
+ .vscode/
32
+ .idea/
33
+ *.swp
34
+ .DS_Store
35
+
36
+ # Local data (local-first)
37
+ data/
38
+ *.db
39
+ *.sqlite
40
+ *.sqlite3
41
+
42
+ # Logs
43
+ logs/
44
+ *.log
45
+ npm-debug.log*
46
+ pnpm-debug.log*
47
+
48
+ # Docker volumes (when bind-mounted)
49
+ .docker-data/
50
+
51
+ # OpenVox local state directory
52
+ .openvox/
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: openvox-core
3
+ Version: 0.1.0
4
+ Summary: Local-first, open-source voice agent platform — install with pip, run as a daemon, no Docker required.
5
+ Project-URL: Homepage, https://github.com/amznsri/openvox
6
+ Project-URL: Documentation, https://github.com/amznsri/openvox/tree/main/docs
7
+ Project-URL: Repository, https://github.com/amznsri/openvox
8
+ Project-URL: Issues, https://github.com/amznsri/openvox/issues
9
+ Project-URL: Changelog, https://github.com/amznsri/openvox/blob/main/docs/SESSION_LOG.md
10
+ Author: OpenVox Contributors
11
+ License: Apache-2.0
12
+ Keywords: agent,ai,rtc,stt,telegram,tts,twilio,voice,whatsapp
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: Apache Software License
17
+ Classifier: Operating System :: MacOS :: MacOS X
18
+ Classifier: Operating System :: Microsoft :: Windows
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Communications :: Telephony
25
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
26
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
27
+ Requires-Python: >=3.11
28
+ Requires-Dist: aiofiles>=24.1.0
29
+ Requires-Dist: aiosqlite>=0.20.0
30
+ Requires-Dist: alembic>=1.13.0
31
+ Requires-Dist: apscheduler>=3.10.4
32
+ Requires-Dist: asyncpg>=0.29.0
33
+ Requires-Dist: authlib>=1.3.0
34
+ Requires-Dist: boto3>=1.35.0
35
+ Requires-Dist: certifi>=2024.8.30
36
+ Requires-Dist: fastapi>=0.115.0
37
+ Requires-Dist: google-cloud-storage>=2.18.0
38
+ Requires-Dist: httpx>=0.27.0
39
+ Requires-Dist: mcp>=1.2.0
40
+ Requires-Dist: numpy>=1.26.0
41
+ Requires-Dist: opentelemetry-api>=1.27.0
42
+ Requires-Dist: opentelemetry-instrumentation-fastapi>=0.48b0
43
+ Requires-Dist: opentelemetry-sdk>=1.27.0
44
+ Requires-Dist: orjson>=3.10.0
45
+ Requires-Dist: oss2>=2.18.0
46
+ Requires-Dist: passlib[bcrypt]>=1.7.4
47
+ Requires-Dist: pydantic-settings>=2.5.0
48
+ Requires-Dist: pydantic>=2.9.0
49
+ Requires-Dist: pydub>=0.25.1
50
+ Requires-Dist: pypdf>=4.3.0
51
+ Requires-Dist: python-jose[cryptography]>=3.3.0
52
+ Requires-Dist: python-multipart>=0.0.12
53
+ Requires-Dist: redis[hiredis]>=5.1.0
54
+ Requires-Dist: scipy>=1.13.0
55
+ Requires-Dist: silero-vad>=5.1.0
56
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.35
57
+ Requires-Dist: structlog>=24.4.0
58
+ Requires-Dist: tenacity>=9.0.0
59
+ Requires-Dist: tos>=2.7.0
60
+ Requires-Dist: twilio>=9.3.0
61
+ Requires-Dist: typer>=0.12.0
62
+ Requires-Dist: uvicorn[standard]>=0.32.0
63
+ Requires-Dist: watchfiles>=0.24.0
64
+ Requires-Dist: websockets>=13.0
65
+ Provides-Extra: dev
66
+ Requires-Dist: mypy>=1.11.0; extra == 'dev'
67
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
68
+ Requires-Dist: pytest-cov>=5.0.0; extra == 'dev'
69
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
70
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
71
+ Provides-Extra: local-stt
72
+ Requires-Dist: openai-whisper>=20240930; extra == 'local-stt'
73
+ Provides-Extra: postgres
74
+ Provides-Extra: whatsapp
75
+ Description-Content-Type: text/markdown
76
+
77
+ # openvox-core
78
+
79
+ The OpenVox voice pipeline. FastAPI + asyncio orchestrator that ties STT,
80
+ LLM, TTS, RTC, telephony, and pluggable skills together.
81
+
82
+ See the top-level [README](../../README.md).
@@ -0,0 +1,6 @@
1
+ # openvox-core
2
+
3
+ The OpenVox voice pipeline. FastAPI + asyncio orchestrator that ties STT,
4
+ LLM, TTS, RTC, telephony, and pluggable skills together.
5
+
6
+ See the top-level [README](../../README.md).
@@ -0,0 +1,3 @@
1
+ """OpenVox — local-first voice agent platform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from openvox.api.app import create_app
2
+
3
+ __all__ = ["create_app"]
@@ -0,0 +1,210 @@
1
+ """FastAPI app factory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+
11
+ from openvox.api.routes import (
12
+ admin as admin_routes,
13
+ agents,
14
+ auth as auth_routes,
15
+ documents as documents_routes,
16
+ evals as evals_routes,
17
+ health,
18
+ jobs as jobs_routes,
19
+ mcp as mcp_routes,
20
+ playground,
21
+ pricing as pricing_routes,
22
+ providers as providers_routes,
23
+ rtc,
24
+ sessions,
25
+ skills as skills_routes,
26
+ storage as storage_routes,
27
+ telephony,
28
+ templates as templates_routes,
29
+ )
30
+ from openvox.api.ws import twilio_stream as twilio_ws
31
+ from openvox.api.ws import voice as voice_ws
32
+ from openvox.config import get_settings
33
+ from openvox.db import init_db
34
+ from openvox.providers.bootstrap import register_builtins
35
+ from openvox.scheduler import start_scheduler, stop_scheduler
36
+ from openvox.skills.registry import get_skill_registry
37
+ from openvox.skills.watcher import start_watcher, stop_watcher
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ async def _seed_builtin_personas() -> None:
43
+ """Upsert the built-in synthetic personas on startup.
44
+
45
+ We upsert by id so edits to the prompt in eval/personas.py take
46
+ effect without manual intervention, but user-edited personas
47
+ (different id) are left alone.
48
+ """
49
+ from openvox.db import db_session
50
+ from openvox.db.models import Persona
51
+ from openvox.eval.personas import BUILTIN_PERSONAS
52
+
53
+ async with db_session() as s:
54
+ for entry in BUILTIN_PERSONAS:
55
+ existing = await s.get(Persona, entry["id"])
56
+ if existing is None:
57
+ p = Persona(
58
+ id=entry["id"],
59
+ name=entry["name"],
60
+ description=entry.get("description", ""),
61
+ system_prompt=entry["system_prompt"],
62
+ tags=entry.get("tags", []),
63
+ builtin=True,
64
+ )
65
+ s.add(p)
66
+ else:
67
+ # Refresh prompt + tags on every boot so prompt iterations stick.
68
+ existing.name = entry["name"]
69
+ existing.description = entry.get("description", "")
70
+ existing.system_prompt = entry["system_prompt"]
71
+ existing.tags = entry.get("tags", [])
72
+ existing.builtin = True
73
+
74
+
75
+ @asynccontextmanager
76
+ async def _lifespan(app: FastAPI):
77
+ settings = get_settings()
78
+ settings.data_dir.mkdir(parents=True, exist_ok=True)
79
+ register_builtins()
80
+ get_skill_registry() # discover
81
+ await init_db()
82
+ await _seed_builtin_personas()
83
+ await start_scheduler()
84
+ await start_watcher() # hot-reload skills dropped in ~/.openvox/skills/
85
+ # Phase 2: start telegram polling tasks for any agent connected
86
+ # in polling mode. Webhook-mode agents bootstrap themselves on the
87
+ # next incoming Telegram POST — no startup action needed.
88
+ from openvox.telephony.telegram_polling import start_all_pollers, stop_all_pollers
89
+ await start_all_pollers()
90
+ # WhatsApp Personal: reconnect bridge sessions for any agent whose
91
+ # channels.whatsapp_personal.enabled == true. No-op if the bridge
92
+ # container isn't running (opt-in via --profile whatsapp).
93
+ from openvox.telephony.whatsapp_personal import (
94
+ start_all_sessions as wpp_start_all,
95
+ stop_all_sessions as wpp_stop_all,
96
+ )
97
+ await wpp_start_all()
98
+ logger.info("OpenVox core started — auth=%s storage=%s", settings.openvox_auth, settings.storage_backend)
99
+ yield
100
+ await wpp_stop_all()
101
+ await stop_all_pollers()
102
+ await stop_watcher()
103
+ await stop_scheduler()
104
+ logger.info("OpenVox core shutting down")
105
+
106
+
107
+ def create_app() -> FastAPI:
108
+ settings = get_settings()
109
+ logging.basicConfig(level=settings.log_level.upper())
110
+
111
+ app = FastAPI(
112
+ title="OpenVox Core",
113
+ description="Voice agent pipeline (STT + LLM + TTS + RTC + telephony).",
114
+ version="0.1.0",
115
+ lifespan=_lifespan,
116
+ )
117
+ app.add_middleware(
118
+ CORSMiddleware,
119
+ allow_origins=["*"], # local-first — accept dashboard from any local origin
120
+ allow_credentials=True,
121
+ allow_methods=["*"],
122
+ allow_headers=["*"],
123
+ )
124
+
125
+ app.include_router(health.router)
126
+ app.include_router(auth_routes.router, prefix="/api/v1/auth", tags=["auth"])
127
+ app.include_router(admin_routes.router, prefix="/api/v1/admin", tags=["admin"])
128
+ app.include_router(agents.router, prefix="/api/v1/agents", tags=["agents"])
129
+ app.include_router(documents_routes.router, prefix="/api/v1/agents", tags=["documents"])
130
+ app.include_router(sessions.router, prefix="/api/v1/sessions", tags=["sessions"])
131
+ app.include_router(providers_routes.router, prefix="/api/v1/providers", tags=["providers"])
132
+ app.include_router(skills_routes.router, prefix="/api/v1/skills", tags=["skills"])
133
+ app.include_router(templates_routes.router, prefix="/api/v1/templates", tags=["templates"])
134
+ app.include_router(rtc.router, prefix="/api/v1/rtc", tags=["rtc"])
135
+ app.include_router(jobs_routes.router, prefix="/api/v1/jobs", tags=["jobs"])
136
+ app.include_router(mcp_routes.router, prefix="/api/v1/mcp", tags=["mcp"])
137
+ app.include_router(telephony.router, prefix="/api/v1/telephony", tags=["telephony"])
138
+ app.include_router(pricing_routes.router, prefix="/api/v1/pricing", tags=["pricing"])
139
+ app.include_router(evals_routes.router, prefix="/api/v1/evals", tags=["evals"])
140
+ app.include_router(playground.router, prefix="/api/v1/playground", tags=["playground"])
141
+ app.include_router(storage_routes.router, prefix="/storage", tags=["storage"])
142
+ app.include_router(voice_ws.router)
143
+ app.include_router(twilio_ws.router)
144
+
145
+ # ── Optional static-dashboard serving ───────────────────────────
146
+ # Phase 1 PR-3 scaffolding: when the dashboard has been built with
147
+ # BUILD_OUTPUT=export (Next.js static export → `out/` directory),
148
+ # FastAPI serves it at /dashboard/* on the same port as the API.
149
+ # This is what enables the single-process CLI experience
150
+ # (`openvox run` → one process, browser opens to localhost:8000/dashboard).
151
+ #
152
+ # Discovery order — first existing path wins:
153
+ # 1. OPENVOX_DASHBOARD_PATH env var (explicit override)
154
+ # 2. /app/dashboard_static/ (Docker/CLI install — bundled at build)
155
+ # 3. ../apps/dashboard/out/ (repo-relative dev workflow)
156
+ #
157
+ # If none of the above exist, the mount is silently skipped —
158
+ # browsers hitting /dashboard get 404, which is correct for Docker
159
+ # mode (where the separate `openvox-dashboard` container handles it).
160
+ #
161
+ # Today this is no-op: the dashboard's static-export build pipeline
162
+ # ships in a follow-up commit (the agents/[id] route needs to be
163
+ # refactored to query params first — Next.js static export can't
164
+ # handle dynamic path params for runtime-created IDs). The mount
165
+ # itself is committed now so we don't have to revisit api/app.py
166
+ # in that follow-up — just produce the out/ directory and it
167
+ # gets served automatically.
168
+ _maybe_mount_dashboard_static(app)
169
+
170
+ return app
171
+
172
+
173
+ def _maybe_mount_dashboard_static(app: FastAPI) -> None:
174
+ """Mount the built dashboard static files under /dashboard/* if available.
175
+
176
+ See the comment block in create_app() above for the discovery
177
+ rules + why this is a no-op today. Pulled into its own function so
178
+ it can be unit-tested + so the mount logic doesn't clutter the
179
+ main app factory.
180
+ """
181
+ import os
182
+ from pathlib import Path
183
+
184
+ from fastapi.staticfiles import StaticFiles
185
+
186
+ explicit = os.environ.get("OPENVOX_DASHBOARD_PATH")
187
+ candidates: list[Path] = []
188
+ if explicit:
189
+ candidates.append(Path(explicit))
190
+ candidates.append(Path("/app/dashboard_static"))
191
+ # Repo-relative — works in `openvox run` invoked from the repo root.
192
+ candidates.append(Path(__file__).resolve().parent.parent.parent.parent.parent
193
+ / "apps" / "dashboard" / "out")
194
+
195
+ for path in candidates:
196
+ if path.is_dir() and (path / "index.html").exists():
197
+ app.mount(
198
+ "/dashboard",
199
+ StaticFiles(directory=str(path), html=True),
200
+ name="dashboard",
201
+ )
202
+ logger.info("dashboard static files mounted at /dashboard from %s", path)
203
+ return
204
+
205
+ # Not found — that's fine in Docker mode where the separate
206
+ # `openvox-dashboard` container serves the dashboard on its own port.
207
+ logger.debug(
208
+ "no dashboard static files found — /dashboard will 404. "
209
+ "Either set OPENVOX_DASHBOARD_PATH or use Docker dashboard service."
210
+ )
File without changes
@@ -0,0 +1,133 @@
1
+ """Admin endpoints — backing API for the dashboard's first-run wizard.
2
+
3
+ Phase 3 of `docs/PLANNING_SESSION15.md`. Three endpoints:
4
+
5
+ - ``GET /api/v1/admin/setup/status`` — what's configured + is setup complete?
6
+ - ``POST /api/v1/admin/setup/keys`` — save provider keys (encrypted-at-rest)
7
+ - ``DELETE /api/v1/admin/setup/keys`` — remove a stored key
8
+
9
+ The dashboard's wizard pages (``apps/dashboard/src/app/dashboard/setup/*``)
10
+ poll status on mount, present the form when ``complete == false``, and
11
+ POST the user's pasted keys here. The encrypted-store layer
12
+ (``openvox/secrets.py``) handles persistence; this module is just the
13
+ HTTP shell.
14
+
15
+ Security notes
16
+ ==============
17
+ - Endpoints don't require auth today because OpenVox runs local-first;
18
+ the operator and the admin are the same person. When
19
+ ``OPENVOX_AUTH=enabled`` (multi-tenant cloud), these must be gated
20
+ behind admin-only authorization — see the TODO at the top of
21
+ ``check_admin()`` below.
22
+ - POSTed values flow into Fernet-encrypted storage. Never logged or
23
+ returned in any response.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from typing import Any
29
+
30
+ from fastapi import APIRouter, HTTPException
31
+ from pydantic import BaseModel, Field
32
+
33
+ from openvox import secrets as secret_store
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ router = APIRouter()
38
+
39
+
40
+ def check_admin() -> None:
41
+ """Authorization placeholder for the multi-tenant future.
42
+
43
+ Today: no-op. OpenVox runs local-first; operator == admin.
44
+
45
+ When ``OPENVOX_AUTH=enabled`` ships (cloud mode), this should
46
+ inspect the request's bearer token and 403 if the caller isn't
47
+ an admin. Keep all admin endpoints calling this so the eventual
48
+ gate has a single edit point.
49
+ """
50
+ # TODO: when OPENVOX_AUTH lands, verify admin role from JWT.
51
+ return None
52
+
53
+
54
+ class SetupKeysRequest(BaseModel):
55
+ """Save one or more provider keys at once.
56
+
57
+ Example body:
58
+ {
59
+ "provider": "byteplus",
60
+ "keys": {
61
+ "llm_api_key": "01a2b3c4...",
62
+ "voice_api_key": "01a2b3c4..."
63
+ }
64
+ }
65
+
66
+ An empty string for a key value deletes the stored key (caller
67
+ can use this to "forget" a key without a separate DELETE call).
68
+ """
69
+
70
+ provider: str = Field(..., min_length=1, max_length=64)
71
+ keys: dict[str, str] = Field(default_factory=dict)
72
+
73
+
74
+ @router.get("/setup/status")
75
+ async def setup_status() -> dict[str, Any]:
76
+ """Tell the dashboard whether the first-run wizard should run.
77
+
78
+ Used by ``apps/dashboard/src/app/dashboard/layout.tsx`` (or
79
+ equivalent) on mount: if ``complete == false`` and the user
80
+ isn't already on /dashboard/setup, redirect them there.
81
+ """
82
+ check_admin()
83
+ return await secret_store.setup_complete()
84
+
85
+
86
+ @router.post("/setup/keys")
87
+ async def setup_keys(req: SetupKeysRequest) -> dict[str, Any]:
88
+ """Persist a batch of provider keys.
89
+
90
+ Returns the updated setup-status so the wizard can refresh its
91
+ "what's configured" panel without a second round-trip.
92
+ """
93
+ check_admin()
94
+
95
+ provider = req.provider.strip().lower()
96
+ if not provider:
97
+ raise HTTPException(400, "provider is required")
98
+ if not req.keys:
99
+ raise HTTPException(400, "keys map must be non-empty")
100
+
101
+ saved: list[str] = []
102
+ deleted: list[str] = []
103
+ for key_name, value in req.keys.items():
104
+ try:
105
+ await secret_store.set_provider_key(provider, key_name, value)
106
+ except ValueError as e:
107
+ raise HTTPException(400, f"invalid key '{key_name}': {e}") from e
108
+ (deleted if value == "" else saved).append(key_name)
109
+
110
+ logger.info(
111
+ "admin/setup/keys: provider=%s saved=%s deleted=%s",
112
+ provider, saved, deleted,
113
+ )
114
+
115
+ return {
116
+ "ok": True,
117
+ "saved": saved,
118
+ "deleted": deleted,
119
+ "status": await secret_store.setup_complete(),
120
+ }
121
+
122
+
123
+ class DeleteKeyRequest(BaseModel):
124
+ provider: str = Field(..., min_length=1, max_length=64)
125
+ key_name: str = Field(..., min_length=1, max_length=64)
126
+
127
+
128
+ @router.delete("/setup/keys")
129
+ async def delete_key(req: DeleteKeyRequest) -> dict[str, Any]:
130
+ """Remove a single stored key — env-var fallback resumes."""
131
+ check_admin()
132
+ await secret_store.delete_provider_key(req.provider, req.key_name)
133
+ return {"ok": True, "status": await secret_store.setup_complete()}