openvox-core 0.1.0__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.
Files changed (127) hide show
  1. openvox/__init__.py +3 -0
  2. openvox/api/__init__.py +3 -0
  3. openvox/api/app.py +210 -0
  4. openvox/api/routes/__init__.py +0 -0
  5. openvox/api/routes/admin.py +133 -0
  6. openvox/api/routes/agents.py +340 -0
  7. openvox/api/routes/auth.py +105 -0
  8. openvox/api/routes/documents.py +133 -0
  9. openvox/api/routes/evals.py +331 -0
  10. openvox/api/routes/health.py +19 -0
  11. openvox/api/routes/jobs.py +260 -0
  12. openvox/api/routes/mcp.py +81 -0
  13. openvox/api/routes/playground.py +420 -0
  14. openvox/api/routes/pricing.py +195 -0
  15. openvox/api/routes/providers.py +55 -0
  16. openvox/api/routes/rtc.py +28 -0
  17. openvox/api/routes/sessions.py +86 -0
  18. openvox/api/routes/skills.py +31 -0
  19. openvox/api/routes/storage.py +19 -0
  20. openvox/api/routes/telephony.py +971 -0
  21. openvox/api/routes/templates.py +1318 -0
  22. openvox/api/ws/__init__.py +0 -0
  23. openvox/api/ws/twilio_stream.py +336 -0
  24. openvox/api/ws/voice.py +378 -0
  25. openvox/cli/__init__.py +60 -0
  26. openvox/cli/__main__.py +12 -0
  27. openvox/cli/commands/__init__.py +0 -0
  28. openvox/cli/commands/info.py +94 -0
  29. openvox/cli/commands/logs.py +89 -0
  30. openvox/cli/commands/restart.py +18 -0
  31. openvox/cli/commands/run.py +137 -0
  32. openvox/cli/commands/start.py +61 -0
  33. openvox/cli/commands/status.py +22 -0
  34. openvox/cli/commands/stop.py +20 -0
  35. openvox/cli/commands/version.py +25 -0
  36. openvox/cli/daemon/__init__.py +47 -0
  37. openvox/cli/daemon/base.py +80 -0
  38. openvox/cli/daemon/launchd.py +174 -0
  39. openvox/cli/daemon/systemd.py +162 -0
  40. openvox/cli/daemon/windows_service.py +155 -0
  41. openvox/cli/main.py +51 -0
  42. openvox/config.py +174 -0
  43. openvox/db/__init__.py +5 -0
  44. openvox/db/models.py +437 -0
  45. openvox/db/session.py +99 -0
  46. openvox/eval/__init__.py +15 -0
  47. openvox/eval/judge.py +136 -0
  48. openvox/eval/personas.py +84 -0
  49. openvox/eval/runner.py +208 -0
  50. openvox/mcp/__init__.py +17 -0
  51. openvox/mcp/bridge.py +184 -0
  52. openvox/mcp/catalogue.json +106 -0
  53. openvox/pipeline/__init__.py +3 -0
  54. openvox/pipeline/orchestrator.py +649 -0
  55. openvox/pricing/__init__.py +10 -0
  56. openvox/pricing/rates.py +345 -0
  57. openvox/providers/__init__.py +42 -0
  58. openvox/providers/base.py +238 -0
  59. openvox/providers/bootstrap.py +53 -0
  60. openvox/providers/byteplus/__init__.py +8 -0
  61. openvox/providers/byteplus/llm.py +169 -0
  62. openvox/providers/byteplus/rtc.py +128 -0
  63. openvox/providers/byteplus/stt.py +423 -0
  64. openvox/providers/byteplus/tts.py +185 -0
  65. openvox/providers/byteplus/voices.py +109 -0
  66. openvox/providers/openai_compat/__init__.py +30 -0
  67. openvox/providers/openai_compat/_openai_base.py +121 -0
  68. openvox/providers/openai_compat/anthropic.py +101 -0
  69. openvox/providers/openai_compat/assemblyai_stt.py +87 -0
  70. openvox/providers/openai_compat/cartesia_tts.py +94 -0
  71. openvox/providers/openai_compat/deepgram_stt.py +94 -0
  72. openvox/providers/openai_compat/deepseek.py +19 -0
  73. openvox/providers/openai_compat/elevenlabs_tts.py +71 -0
  74. openvox/providers/openai_compat/gemini.py +21 -0
  75. openvox/providers/openai_compat/openai_llm.py +19 -0
  76. openvox/providers/openai_compat/openai_tts.py +78 -0
  77. openvox/providers/openai_compat/whisper_stt.py +99 -0
  78. openvox/providers/registry.py +111 -0
  79. openvox/providers/vad/__init__.py +14 -0
  80. openvox/providers/vad/base.py +56 -0
  81. openvox/providers/vad/silero.py +168 -0
  82. openvox/rag/__init__.py +22 -0
  83. openvox/rag/bm25.py +79 -0
  84. openvox/rag/byteplus_cloud.py +208 -0
  85. openvox/rag/embeddings.py +56 -0
  86. openvox/rag/extract.py +135 -0
  87. openvox/rag/store.py +194 -0
  88. openvox/scheduler/__init__.py +15 -0
  89. openvox/scheduler/engine.py +143 -0
  90. openvox/scheduler/runner.py +298 -0
  91. openvox/secrets.py +306 -0
  92. openvox/skills/__init__.py +32 -0
  93. openvox/skills/base.py +109 -0
  94. openvox/skills/builtin/__init__.py +20 -0
  95. openvox/skills/builtin/documents.py +162 -0
  96. openvox/skills/builtin/ecommerce.py +90 -0
  97. openvox/skills/builtin/education.py +74 -0
  98. openvox/skills/builtin/general.py +67 -0
  99. openvox/skills/builtin/language.py +154 -0
  100. openvox/skills/builtin/reception.py +299 -0
  101. openvox/skills/builtin/sales.py +266 -0
  102. openvox/skills/builtin/setup.py +677 -0
  103. openvox/skills/builtin/stock.py +91 -0
  104. openvox/skills/builtin/voice_analysis.py +145 -0
  105. openvox/skills/registry.py +159 -0
  106. openvox/skills/runner.py +80 -0
  107. openvox/skills/watcher.py +107 -0
  108. openvox/storage/__init__.py +6 -0
  109. openvox/storage/base.py +24 -0
  110. openvox/storage/byteplus_tos.py +58 -0
  111. openvox/storage/factory.py +35 -0
  112. openvox/storage/local.py +45 -0
  113. openvox/storage/s3.py +70 -0
  114. openvox/telephony/__init__.py +9 -0
  115. openvox/telephony/lark.py +93 -0
  116. openvox/telephony/telegram.py +212 -0
  117. openvox/telephony/telegram_polling.py +232 -0
  118. openvox/telephony/twilio.py +88 -0
  119. openvox/telephony/wechat_work.py +133 -0
  120. openvox/telephony/whatsapp_personal.py +213 -0
  121. openvox/utils/__init__.py +0 -0
  122. openvox/utils/http.py +89 -0
  123. openvox/utils/text.py +476 -0
  124. openvox_core-0.1.0.dist-info/METADATA +82 -0
  125. openvox_core-0.1.0.dist-info/RECORD +127 -0
  126. openvox_core-0.1.0.dist-info/WHEEL +4 -0
  127. openvox_core-0.1.0.dist-info/entry_points.txt +2 -0
openvox/__init__.py ADDED
@@ -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"]
openvox/api/app.py ADDED
@@ -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()}
@@ -0,0 +1,340 @@
1
+ """Agent CRUD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, HTTPException
8
+ from pydantic import BaseModel, Field
9
+ from sqlalchemy import delete, select
10
+
11
+ from openvox.config import get_settings
12
+ from openvox.db import db_session
13
+ from openvox.db.models import (
14
+ Agent,
15
+ AgentStatus,
16
+ Document,
17
+ DocumentChunk,
18
+ EvalRun,
19
+ JobRun,
20
+ Recording,
21
+ ScheduledJob,
22
+ Session as DBSession,
23
+ Transcript,
24
+ )
25
+
26
+ router = APIRouter()
27
+
28
+
29
+ class AgentIn(BaseModel):
30
+ name: str
31
+ description: str = ""
32
+ template_id: str | None = None
33
+ stt_provider: str = "byteplus"
34
+ tts_provider: str = "byteplus"
35
+ llm_provider: str = "byteplus"
36
+ # Empty → server fills from settings.byteplus_llm_model on create.
37
+ llm_model: str = ""
38
+ voice_id: str = ""
39
+ voice_speed: float = 1.0
40
+ voice_language: str = "en-US"
41
+ system_prompt: str = "You are a helpful voice assistant."
42
+ greeting: str = "Hi, how can I help you?"
43
+ temperature: float = 0.7
44
+ max_tokens: int = 2048
45
+ skills: list[str] = Field(default_factory=list)
46
+ channels: dict[str, Any] = Field(default_factory=dict)
47
+ mcp_servers: list[dict[str, Any]] = Field(default_factory=list)
48
+ voice_map: dict[str, str] = Field(default_factory=dict)
49
+ # "silero" (default) | "none" — controls server-side VAD interrupt path.
50
+ vad_provider: str = "silero"
51
+
52
+
53
+ class AgentOut(AgentIn):
54
+ id: str
55
+ status: str
56
+ created_at: str
57
+ updated_at: str
58
+
59
+
60
+ def _to_out(a: Agent) -> dict[str, Any]:
61
+ return {
62
+ "id": a.id,
63
+ "name": a.name,
64
+ "description": a.description,
65
+ "template_id": a.template_id,
66
+ "stt_provider": a.stt_provider,
67
+ "tts_provider": a.tts_provider,
68
+ "llm_provider": a.llm_provider,
69
+ "llm_model": a.llm_model,
70
+ "voice_id": a.voice_id,
71
+ "voice_speed": a.voice_speed,
72
+ "voice_language": a.voice_language,
73
+ "system_prompt": a.system_prompt,
74
+ "greeting": a.greeting,
75
+ "temperature": a.temperature,
76
+ "max_tokens": a.max_tokens,
77
+ "skills": a.skills or [],
78
+ "channels": a.channels or {},
79
+ "mcp_servers": a.mcp_servers or [],
80
+ "voice_map": a.voice_map or {},
81
+ "vad_provider": getattr(a, "vad_provider", "silero") or "silero",
82
+ "status": a.status,
83
+ "created_at": a.created_at.isoformat() if a.created_at else "",
84
+ "updated_at": a.updated_at.isoformat() if a.updated_at else "",
85
+ }
86
+
87
+
88
+ @router.get("")
89
+ async def list_agents() -> list[dict[str, Any]]:
90
+ async with db_session() as s:
91
+ rows = (await s.execute(select(Agent).order_by(Agent.updated_at.desc()))).scalars().all()
92
+ return [_to_out(a) for a in rows]
93
+
94
+
95
+ @router.post("", status_code=201)
96
+ async def create_agent(body: AgentIn) -> dict[str, Any]:
97
+ settings = get_settings()
98
+ fields = body.model_dump()
99
+ # Fill in env-driven defaults for fields the caller left blank.
100
+ if not fields.get("llm_model"):
101
+ fields["llm_model"] = settings.byteplus_llm_model
102
+ if not fields.get("voice_id"):
103
+ fields["voice_id"] = settings.byteplus_tts_default_voice
104
+ async with db_session() as s:
105
+ a = Agent(**fields)
106
+ s.add(a)
107
+ await s.flush()
108
+ return _to_out(a)
109
+
110
+
111
+ @router.get("/{agent_id}")
112
+ async def get_agent(agent_id: str) -> dict[str, Any]:
113
+ async with db_session() as s:
114
+ a = await s.get(Agent, agent_id)
115
+ if a is None:
116
+ raise HTTPException(404, "agent not found")
117
+ return _to_out(a)
118
+
119
+
120
+ @router.put("/{agent_id}")
121
+ async def update_agent(agent_id: str, body: AgentIn) -> dict[str, Any]:
122
+ async with db_session() as s:
123
+ a = await s.get(Agent, agent_id)
124
+ if a is None:
125
+ raise HTTPException(404, "agent not found")
126
+ for k, v in body.model_dump().items():
127
+ setattr(a, k, v)
128
+ await s.flush()
129
+ return _to_out(a)
130
+
131
+
132
+ @router.post("/{agent_id}/publish")
133
+ async def publish_agent(agent_id: str) -> dict[str, Any]:
134
+ async with db_session() as s:
135
+ a = await s.get(Agent, agent_id)
136
+ if a is None:
137
+ raise HTTPException(404, "agent not found")
138
+ a.status = AgentStatus.PUBLISHED.value
139
+ await s.flush()
140
+ return _to_out(a)
141
+
142
+
143
+ @router.delete("/{agent_id}", status_code=204)
144
+ async def delete_agent(agent_id: str) -> None:
145
+ """Delete an agent + every row that references it.
146
+
147
+ The agent table has two hard FK dependents (Session, Document) and
148
+ several soft string-keyed dependents added across later sessions
149
+ (DocumentChunk, ScheduledJob, JobRun, Recording, EvalRun). The
150
+ hard FKs cause a `ForeignKeyViolationError` if children are not
151
+ deleted first; the soft ones don't block delete but leave orphan
152
+ rows that clutter the eval framework, RAG store, and scheduler.
153
+
154
+ Pattern: cascade in dependency order, then `s.delete(a)`. Plain
155
+ `s.delete(a)` with `relationship(cascade="all, delete-orphan")`
156
+ has historically been unreliable in async-mode SQLAlchemy when
157
+ the relationship isn't pre-loaded — see bugs #29, #30 in
158
+ CLAUDE.md §8. In-route cascades are slower but bulletproof.
159
+ """
160
+ async with db_session() as s:
161
+ a = await s.get(Agent, agent_id)
162
+ if a is None:
163
+ raise HTTPException(404, "agent not found")
164
+
165
+ # 1. Eval framework — runs reference the agent directly,
166
+ # recordings via source_agent_id.
167
+ await s.execute(delete(EvalRun).where(EvalRun.agent_id == agent_id))
168
+ await s.execute(delete(Recording).where(Recording.source_agent_id == agent_id))
169
+
170
+ # 2. Scheduler — kill job_runs first (FK to scheduled_jobs),
171
+ # then the jobs themselves. Same pattern as the
172
+ # /api/v1/jobs/{id} route uses (CLAUDE.md §8 #29).
173
+ job_ids = (
174
+ await s.execute(select(ScheduledJob.id).where(ScheduledJob.agent_id == agent_id))
175
+ ).scalars().all()
176
+ if job_ids:
177
+ await s.execute(delete(JobRun).where(JobRun.job_id.in_(job_ids)))
178
+ await s.execute(delete(ScheduledJob).where(ScheduledJob.agent_id == agent_id))
179
+
180
+ # 3. Voice / text sessions — transcripts FK to sessions, so
181
+ # clear those first. Hard FK constraint.
182
+ session_ids = (
183
+ await s.execute(select(DBSession.id).where(DBSession.agent_id == agent_id))
184
+ ).scalars().all()
185
+ if session_ids:
186
+ await s.execute(delete(Transcript).where(Transcript.session_id.in_(session_ids)))
187
+ await s.execute(delete(DBSession).where(DBSession.agent_id == agent_id))
188
+
189
+ # 4. Documents + RAG chunks. Hard FK on Document, soft on chunks.
190
+ await s.execute(delete(DocumentChunk).where(DocumentChunk.agent_id == agent_id))
191
+ await s.execute(delete(Document).where(Document.agent_id == agent_id))
192
+
193
+ # 5. Finally the agent itself.
194
+ await s.delete(a)
195
+
196
+
197
+ # ── Session 10: text-mode turn for the Setup Assistant ───────────────
198
+ # The Setup Assistant ships as a voice agent but the user-facing
199
+ # component supports both voice AND typed input. Voice goes through the
200
+ # existing /ws/voice WS; text comes in here. Both invoke the same agent
201
+ # + skills, both write to the same Agent.channels.setup_state, so the
202
+ # "draft" state stays consistent when the user switches mid-flow.
203
+ #
204
+ # Out of scope for v1: streaming. The skill-loop nature of an LLM round
205
+ # (LLM → maybe-tool-call → result → re-invoke LLM) is awkward to stream
206
+ # over a single HTTP response; the SetupAssistant doesn't need it
207
+ # because typed input is naturally turn-based. Reply payload includes
208
+ # every event the orchestrator would have emitted, in order.
209
+
210
+
211
+ class TurnRequest(BaseModel):
212
+ user_text: str
213
+ # Optional — lets the caller carry a conversational history across
214
+ # turns. Each item is `{"role": "user"|"assistant", "content": "..."}`.
215
+ # If omitted we treat this as a fresh turn (still uses the agent's
216
+ # configured system_prompt + greeting).
217
+ history: list[dict[str, Any]] = Field(default_factory=list)
218
+
219
+
220
+ @router.post("/{agent_id}/turn")
221
+ async def agent_text_turn(agent_id: str, body: TurnRequest) -> dict[str, Any]:
222
+ """Run a single LLM turn (with skill calls) against `agent_id`.
223
+
224
+ Returns the assistant text plus an array of every event the
225
+ orchestrator emitted (skill_call / skill_result / errors) so the
226
+ SetupAssistant UI can render a faithful transcript even when the
227
+ LLM took a multi-step tool path.
228
+ """
229
+ import json
230
+
231
+ from openvox.providers import ProviderType, get_registry
232
+ from openvox.providers.base import LLMConfig, LLMMessage, LLMProvider
233
+ from openvox.skills import SkillContext
234
+ from openvox.skills.runner import SkillRunner
235
+
236
+ user_text = (body.user_text or "").strip()
237
+ if not user_text:
238
+ raise HTTPException(400, "user_text is required")
239
+
240
+ async with db_session() as s:
241
+ a = await s.get(Agent, agent_id)
242
+ if a is None:
243
+ raise HTTPException(404, "agent not found")
244
+ system_prompt = a.system_prompt
245
+ skill_ids = list(a.skills or [])
246
+ llm_id = a.llm_provider
247
+ llm_model = a.llm_model
248
+ temperature = a.temperature
249
+ max_tokens = a.max_tokens
250
+
251
+ llm = get_registry().get(ProviderType.LLM, llm_id)
252
+ if not isinstance(llm, LLMProvider) or not llm.is_available():
253
+ raise HTTPException(400, f"LLM provider '{llm_id}' unavailable")
254
+
255
+ runner = SkillRunner(
256
+ skill_ids=skill_ids,
257
+ ctx=SkillContext(agent_id=agent_id, metadata={"source": "agent_text_turn"}),
258
+ )
259
+
260
+ # Build a fresh message list — system prompt + caller-supplied
261
+ # history + the new user turn. We're not persisting history here;
262
+ # the SetupAssistant client carries it. Keeps this endpoint stateless
263
+ # at the HTTP layer while still letting the LLM see the full thread.
264
+ messages: list[LLMMessage] = [LLMMessage(role="system", content=system_prompt)]
265
+ for h in body.history:
266
+ role = h.get("role")
267
+ content = h.get("content") or ""
268
+ if role in {"user", "assistant"} and content:
269
+ messages.append(LLMMessage(role=role, content=content))
270
+ messages.append(LLMMessage(role="user", content=user_text))
271
+
272
+ cfg = LLMConfig(
273
+ model=llm_model,
274
+ temperature=temperature,
275
+ max_tokens=max_tokens,
276
+ stream=False,
277
+ tools=runner.tool_specs() or None,
278
+ )
279
+
280
+ # ── Skill loop ───────────────────────────────────────────────────
281
+ # Mirrors `orchestrator._llm_turn`'s shape but in non-streaming mode
282
+ # because text replies don't need sub-sentence chunking. Same cap
283
+ # on tool iterations (default 6) for the same reason —
284
+ # see CLAUDE.md §8 #46.
285
+ events: list[dict[str, Any]] = []
286
+ full_text = ""
287
+ max_iters = 6
288
+
289
+ for iteration in range(max_iters):
290
+ # Non-streaming call returns one chunk with the full delta
291
+ # plus possibly tool_calls.
292
+ last_chunk = None
293
+ async for chunk in llm.chat_stream(messages, cfg):
294
+ last_chunk = chunk
295
+ if last_chunk is None:
296
+ break
297
+ delta = last_chunk.delta or ""
298
+ full_text += delta
299
+ if delta:
300
+ events.append({"type": "assistant_token", "text": delta})
301
+
302
+ tool_calls = last_chunk.tool_calls or []
303
+ if not tool_calls:
304
+ # LLM is done.
305
+ break
306
+
307
+ # Echo the assistant message that issued the tool_calls so the
308
+ # next LLM call's history is well-formed (OpenAI / Ark contract).
309
+ messages.append(
310
+ LLMMessage(role="assistant", content=delta, tool_calls=tool_calls)
311
+ )
312
+ for tc in tool_calls:
313
+ fn = tc.get("function") or {}
314
+ name = fn.get("name") or ""
315
+ raw_args = fn.get("arguments") or "{}"
316
+ try:
317
+ parsed_args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
318
+ except json.JSONDecodeError:
319
+ parsed_args = {"_raw": raw_args}
320
+ if not isinstance(parsed_args, dict):
321
+ parsed_args = {"_value": parsed_args}
322
+ events.append({"type": "skill_call", "name": name, "args": parsed_args})
323
+ result = await runner.invoke(name, parsed_args)
324
+ events.append({"type": "skill_result", "name": name, "output": result})
325
+ messages.append(
326
+ LLMMessage(
327
+ role="tool",
328
+ tool_call_id=tc.get("id") or "",
329
+ name=name,
330
+ content=json.dumps(result, ensure_ascii=False),
331
+ )
332
+ )
333
+ else:
334
+ events.append({
335
+ "type": "error",
336
+ "text": f"tool-call loop exceeded {max_iters} iterations",
337
+ })
338
+
339
+ events.append({"type": "assistant_done", "text": full_text})
340
+ return {"text": full_text, "events": events}