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.
- openvox/__init__.py +3 -0
- openvox/api/__init__.py +3 -0
- openvox/api/app.py +210 -0
- openvox/api/routes/__init__.py +0 -0
- openvox/api/routes/admin.py +133 -0
- openvox/api/routes/agents.py +340 -0
- openvox/api/routes/auth.py +105 -0
- openvox/api/routes/documents.py +133 -0
- openvox/api/routes/evals.py +331 -0
- openvox/api/routes/health.py +19 -0
- openvox/api/routes/jobs.py +260 -0
- openvox/api/routes/mcp.py +81 -0
- openvox/api/routes/playground.py +420 -0
- openvox/api/routes/pricing.py +195 -0
- openvox/api/routes/providers.py +55 -0
- openvox/api/routes/rtc.py +28 -0
- openvox/api/routes/sessions.py +86 -0
- openvox/api/routes/skills.py +31 -0
- openvox/api/routes/storage.py +19 -0
- openvox/api/routes/telephony.py +971 -0
- openvox/api/routes/templates.py +1318 -0
- openvox/api/ws/__init__.py +0 -0
- openvox/api/ws/twilio_stream.py +336 -0
- openvox/api/ws/voice.py +378 -0
- openvox/cli/__init__.py +60 -0
- openvox/cli/__main__.py +12 -0
- openvox/cli/commands/__init__.py +0 -0
- openvox/cli/commands/info.py +94 -0
- openvox/cli/commands/logs.py +89 -0
- openvox/cli/commands/restart.py +18 -0
- openvox/cli/commands/run.py +137 -0
- openvox/cli/commands/start.py +61 -0
- openvox/cli/commands/status.py +22 -0
- openvox/cli/commands/stop.py +20 -0
- openvox/cli/commands/version.py +25 -0
- openvox/cli/daemon/__init__.py +47 -0
- openvox/cli/daemon/base.py +80 -0
- openvox/cli/daemon/launchd.py +174 -0
- openvox/cli/daemon/systemd.py +162 -0
- openvox/cli/daemon/windows_service.py +155 -0
- openvox/cli/main.py +51 -0
- openvox/config.py +174 -0
- openvox/db/__init__.py +5 -0
- openvox/db/models.py +437 -0
- openvox/db/session.py +99 -0
- openvox/eval/__init__.py +15 -0
- openvox/eval/judge.py +136 -0
- openvox/eval/personas.py +84 -0
- openvox/eval/runner.py +208 -0
- openvox/mcp/__init__.py +17 -0
- openvox/mcp/bridge.py +184 -0
- openvox/mcp/catalogue.json +106 -0
- openvox/pipeline/__init__.py +3 -0
- openvox/pipeline/orchestrator.py +649 -0
- openvox/pricing/__init__.py +10 -0
- openvox/pricing/rates.py +345 -0
- openvox/providers/__init__.py +42 -0
- openvox/providers/base.py +238 -0
- openvox/providers/bootstrap.py +53 -0
- openvox/providers/byteplus/__init__.py +8 -0
- openvox/providers/byteplus/llm.py +169 -0
- openvox/providers/byteplus/rtc.py +128 -0
- openvox/providers/byteplus/stt.py +423 -0
- openvox/providers/byteplus/tts.py +185 -0
- openvox/providers/byteplus/voices.py +109 -0
- openvox/providers/openai_compat/__init__.py +30 -0
- openvox/providers/openai_compat/_openai_base.py +121 -0
- openvox/providers/openai_compat/anthropic.py +101 -0
- openvox/providers/openai_compat/assemblyai_stt.py +87 -0
- openvox/providers/openai_compat/cartesia_tts.py +94 -0
- openvox/providers/openai_compat/deepgram_stt.py +94 -0
- openvox/providers/openai_compat/deepseek.py +19 -0
- openvox/providers/openai_compat/elevenlabs_tts.py +71 -0
- openvox/providers/openai_compat/gemini.py +21 -0
- openvox/providers/openai_compat/openai_llm.py +19 -0
- openvox/providers/openai_compat/openai_tts.py +78 -0
- openvox/providers/openai_compat/whisper_stt.py +99 -0
- openvox/providers/registry.py +111 -0
- openvox/providers/vad/__init__.py +14 -0
- openvox/providers/vad/base.py +56 -0
- openvox/providers/vad/silero.py +168 -0
- openvox/rag/__init__.py +22 -0
- openvox/rag/bm25.py +79 -0
- openvox/rag/byteplus_cloud.py +208 -0
- openvox/rag/embeddings.py +56 -0
- openvox/rag/extract.py +135 -0
- openvox/rag/store.py +194 -0
- openvox/scheduler/__init__.py +15 -0
- openvox/scheduler/engine.py +143 -0
- openvox/scheduler/runner.py +298 -0
- openvox/secrets.py +306 -0
- openvox/skills/__init__.py +32 -0
- openvox/skills/base.py +109 -0
- openvox/skills/builtin/__init__.py +20 -0
- openvox/skills/builtin/documents.py +162 -0
- openvox/skills/builtin/ecommerce.py +90 -0
- openvox/skills/builtin/education.py +74 -0
- openvox/skills/builtin/general.py +67 -0
- openvox/skills/builtin/language.py +154 -0
- openvox/skills/builtin/reception.py +299 -0
- openvox/skills/builtin/sales.py +266 -0
- openvox/skills/builtin/setup.py +677 -0
- openvox/skills/builtin/stock.py +91 -0
- openvox/skills/builtin/voice_analysis.py +145 -0
- openvox/skills/registry.py +159 -0
- openvox/skills/runner.py +80 -0
- openvox/skills/watcher.py +107 -0
- openvox/storage/__init__.py +6 -0
- openvox/storage/base.py +24 -0
- openvox/storage/byteplus_tos.py +58 -0
- openvox/storage/factory.py +35 -0
- openvox/storage/local.py +45 -0
- openvox/storage/s3.py +70 -0
- openvox/telephony/__init__.py +9 -0
- openvox/telephony/lark.py +93 -0
- openvox/telephony/telegram.py +212 -0
- openvox/telephony/telegram_polling.py +232 -0
- openvox/telephony/twilio.py +88 -0
- openvox/telephony/wechat_work.py +133 -0
- openvox/telephony/whatsapp_personal.py +213 -0
- openvox/utils/__init__.py +0 -0
- openvox/utils/http.py +89 -0
- openvox/utils/text.py +476 -0
- openvox_core-0.1.0.dist-info/METADATA +82 -0
- openvox_core-0.1.0.dist-info/RECORD +127 -0
- openvox_core-0.1.0.dist-info/WHEEL +4 -0
- openvox_core-0.1.0.dist-info/entry_points.txt +2 -0
openvox/__init__.py
ADDED
openvox/api/__init__.py
ADDED
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}
|