| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844 |
- """Yaoyao plugin backend routes for files, settings, voice, and group chat.
- Auto-mounted at /api/plugins/yaoyao/ by the dashboard plugin loader
- (web_server.py:17277). HTTP routes run behind the dashboard's session-token
- auth middleware just like core /api routes - no explicit auth dependency is
- needed (see the kanban plugin_api.py docstring for the authoritative note).
- Response field naming is camelCase to match iOS FileLibraryModels.swift
- CodingKeys (itemId / messageId / displayName / contentType / ...). The
- ``message-files/query`` payload shape matches the existing ArchivedMessageFile
- contract so iOS keeps working with only a path-string change.
- This file is loaded by path as a standalone module (web_server.py:17256
- spec_from_file_location), so it puts its own directory on sys.path and imports
- its companions (store, poller) as top-level modules.
- """
- from __future__ import annotations
- import hashlib
- import importlib.util
- import logging
- import os
- import sys
- import tempfile
- import json
- from pathlib import Path
- from typing import Any, Optional
- from fastapi import APIRouter, HTTPException, Query
- from fastapi.responses import FileResponse, StreamingResponse
- from pydantic import BaseModel, Field
- from urllib.parse import quote
- log = logging.getLogger("yaoyao.plugin_api")
- # Make sibling modules importable when this file is loaded by path.
- _THIS_DIR = str(Path(__file__).resolve().parent)
- if _THIS_DIR not in sys.path:
- sys.path.insert(0, _THIS_DIR)
- import store # noqa: E402
- import poller # noqa: E402
- import voice_store # noqa: E402
- def _profile_home(profile: Optional[str]) -> Path:
- """Resolve the Hermes profile home without consulting YaoYao state."""
- profile_name = str(profile or "default").strip() or "default"
- try:
- from hermes_constants import get_default_hermes_root # type: ignore
- root = Path(get_default_hermes_root())
- except Exception:
- configured = os.environ.get("HERMES_HOME", "").strip()
- root = Path(configured) if configured else Path.home() / ".hermes"
- if profile_name == "default":
- return root
- try:
- from hermes_cli.profiles import get_profile_dir # type: ignore
- return Path(get_profile_dir(profile_name))
- except Exception:
- return root / "profiles" / profile_name
- def _hermes_bot_name(profile: Optional[str], profile_home: Optional[Path] = None) -> str:
- """Read the Bot Mode title, then the core Hermes display name.
- Agent identity no longer falls back to YaoYao's legacy agent_settings.json.
- """
- try:
- import yaml # type: ignore
- path = (profile_home or _profile_home(profile)) / "profile.yaml"
- raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else {}
- if not isinstance(raw, dict):
- return ""
- ui_meta = raw.get("ui_meta")
- bot_meta = ui_meta.get("hermes-bots") if isinstance(ui_meta, dict) else None
- candidates = [
- bot_meta.get("title") if isinstance(bot_meta, dict) else None,
- raw.get("display_name"),
- ]
- for candidate in candidates:
- if not isinstance(candidate, str):
- continue
- name = " ".join(candidate.split())
- if (
- name
- and len(name) <= store.MAX_AGENT_NAME_LENGTH
- and not any(ord(character) < 32 or ord(character) == 127 for character in name)
- and not store.is_reserved_mention_alias(name)
- ):
- return name
- except (OSError, TypeError, ValueError):
- pass
- except Exception as error:
- log.warning("Hermes Bot name load failed for %s: %s", profile, error)
- return ""
- def _hermes_bot_avatar(profile: Optional[str], profile_home: Optional[Path] = None) -> str:
- """Read the avatar stored with the Hermes-owned Bot metadata."""
- try:
- import yaml # type: ignore
- path = (profile_home or _profile_home(profile)) / "profile.yaml"
- raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else {}
- if not isinstance(raw, dict):
- return ""
- ui_meta = raw.get("ui_meta")
- bot_meta = ui_meta.get("hermes-bots") if isinstance(ui_meta, dict) else None
- avatar = bot_meta.get("avatar") if isinstance(bot_meta, dict) else None
- if not isinstance(avatar, str):
- return ""
- avatar = avatar.strip()
- if len(avatar) > 512_000 or not avatar.startswith("data:image/"):
- return ""
- return avatar
- except (OSError, TypeError, ValueError):
- pass
- except Exception as error:
- log.warning("Hermes Bot avatar load failed for %s: %s", profile, error)
- return ""
- def _load_group_plugin_api():
- module_path = Path(_THIS_DIR) / "group_plugin_api.py"
- module_name = (
- "_hermes_yaoyao_group_api_"
- + hashlib.sha256(str(module_path).encode("utf-8")).hexdigest()[:24]
- )
- loaded = sys.modules.get(module_name)
- if loaded is not None:
- loaded_path = getattr(loaded, "__file__", None)
- if loaded_path is None or Path(loaded_path).resolve() != module_path.resolve():
- raise ImportError("Yaoyao group API module identity is invalid")
- return loaded
- spec = importlib.util.spec_from_file_location(module_name, module_path)
- if spec is None or spec.loader is None:
- raise ImportError("Unable to load Yaoyao group API")
- module = importlib.util.module_from_spec(spec)
- sys.modules[module_name] = module
- try:
- spec.loader.exec_module(module)
- except BaseException:
- sys.modules.pop(module_name, None)
- raise
- return module
- group_plugin_api = _load_group_plugin_api()
- group_plugin_api.set_agent_name_resolver(
- lambda profile: _hermes_bot_name(profile)
- )
- def _store_for(profile: Optional[str]):
- """Resolve the Store for ``profile`` (default if None). Raises 404 if the
- profile is unknown. Validates against the live profile set so a typo or a
- not-yet-created profile returns 404 instead of silently creating an empty
- data dir."""
- if not profile or profile == "default":
- return store.get_store(store.DEFAULT_DATA_ROOT)
- # Validate: the profile must exist on disk. Use profiles_to_serve to avoid
- # importing hermes_cli.profiles when not needed, but fall back to a path
- # check.
- try:
- from hermes_cli.profiles import profile_exists # type: ignore
- if not profile_exists(profile):
- raise HTTPException(status_code=404, detail=f"unknown profile: {profile}")
- except HTTPException:
- raise
- except Exception:
- # hermes_cli not importable (standalone runtime): best-effort path check.
- data_root = store.data_root_for_profile(profile)
- if not data_root.parent.parent.parent.is_dir(): # <home>/plugins/yaoyao
- raise HTTPException(status_code=404, detail=f"unknown profile: {profile}")
- return store.get_store(store.data_root_for_profile(profile))
- router = APIRouter()
- router.include_router(group_plugin_api.router)
- # Start the background archiver as soon as the module loads (the dashboard
- # loader imports this file once at server startup). Idempotent.
- try:
- poller.start()
- except Exception:
- log.exception("yaoyao: poller failed to start (routes still serve existing data)")
- # ---------------------------------------------------------------------------
- # Request / response models
- # ---------------------------------------------------------------------------
- class MessageFileQueryRequest(BaseModel):
- # Field names mirror iOS FileLibraryModels.swift MessageFileArchiveQueryRequest
- # CodingKeys: contextType / contextId / messageIds (camelCase).
- context_type: str = Field(default="session", alias="contextType")
- context_id: Optional[str] = Field(default=None, alias="contextId")
- message_ids: list[int] = Field(default_factory=list, alias="messageIds")
- model_config = {"populate_by_name": True}
- class UploadBody(BaseModel):
- # Reserved for the future upload endpoint; kept here for schema stability.
- display_name: Optional[str] = None
- class SessionContextSnapshotBody(BaseModel):
- used_tokens: int = Field(alias="usedTokens", gt=0)
- limit_tokens: Optional[int] = Field(default=None, alias="limitTokens", gt=0)
- percent: Optional[float] = Field(default=None, ge=0, le=100)
- compressions: Optional[int] = Field(default=None, ge=0)
- model: Optional[str] = Field(default=None, max_length=4096)
- provider: Optional[str] = Field(default=None, max_length=4096)
- observed_at: float = Field(alias="observedAt", gt=0)
- model_config = {"populate_by_name": True}
- # ---------------------------------------------------------------------------
- # Endpoints
- # ---------------------------------------------------------------------------
- def _kind_of(item: dict) -> str:
- return store.infer_kind(item["displayName"], item["contentType"])
- def _to_file_library_item(it: dict) -> dict:
- """Render an attachment row as an iOS FileLibraryItem-compatible payload.
- Field names match FileLibraryItem.CodingKeys (FileLibraryModels.swift):
- id / fileId / path / name / extension / mimeType / size / modifiedAt /
- exists / firstSeenAt / lastSeenAt / messageTimestamp / origins. The origin
- block matches FileLibraryOrigin.CodingKeys.
- """
- name = it["displayName"]
- ext = os.path.splitext(name)[1] # includes the leading "."
- ts = it["discoveredAt"]
- sender = it.get("sender") or "agent"
- author_kind = "user" if sender == "user" else "agent"
- source_kind = "agent_generated" if sender == "agent" else "upload"
- event_kind = "generated" if sender == "agent" else "uploaded"
- session_id = it.get("sessionId") or ""
- origin = {
- "id": it["id"],
- "sourceKind": source_kind,
- "eventKind": event_kind,
- "userId": None,
- "username": "",
- "profile": "",
- "agent": "",
- "agentProfile": None,
- "agentDisplayName": None,
- "authorKind": author_kind,
- "authorId": None,
- "authorName": None,
- "contextType": "session",
- "sessionId": session_id,
- "sessionTitle": "",
- "roomId": "",
- "roomName": "",
- "messageId": str(it["sourceMessageId"])
- if it.get("sourceMessageId") is not None
- else "",
- "runId": "",
- "workspace": "",
- "originalPath": "",
- "observedAt": ts,
- "messageTimestamp": ts,
- "messageSequence": None,
- "messageOrdinal": None,
- }
- return {
- "id": it["id"],
- "fileId": None,
- "path": f"yaoyao-file-library://archive/{it['id']}?name={quote(name, safe='')}",
- "name": name,
- "extension": ext,
- "mimeType": it.get("contentType") or "application/octet-stream",
- "size": it.get("byteCount", 0),
- "modifiedAt": ts,
- "exists": True,
- "firstSeenAt": ts,
- "lastSeenAt": ts,
- "messageTimestamp": ts,
- "origins": [origin],
- }
- @router.get("/profiles")
- def list_profiles():
- """Profiles with an existing state.db (i.e. ones the poller watches).
- Returns ``[{name, label, botName, avatar, isDefault}]``. ``botName`` comes from
- Hermes Bot Mode (then the core profile display name), never YaoYao's
- legacy Agent-name setting. ``default`` is always first when present.
- """
- pairs = poller._discover_profiles()
- out = []
- for name, home in pairs:
- bot_name = _hermes_bot_name(name, Path(home))
- avatar = _hermes_bot_avatar(name, Path(home))
- out.append(
- {
- "name": name,
- "label": bot_name or ("默认" if name == "default" else name),
- "botName": bot_name,
- # Compatibility alias for older YaoYao clients. Its value is
- # still Hermes-owned Bot metadata, not agent_settings.json.
- "agentName": bot_name,
- "avatar": avatar,
- "isDefault": name == "default",
- }
- )
- return {"profiles": out}
- @router.get("/files")
- def list_files(
- sender: Optional[str] = Query(None, description="agent | user"),
- kind: Optional[str] = Query(None, description="image | video | text | file"),
- session_id: Optional[str] = Query(None),
- search: Optional[str] = Query(None),
- profile: Optional[str] = Query(
- None, description="which agent's library to read (default if omitted)"
- ),
- limit: int = Query(50, ge=1, le=200),
- cursor: Optional[int] = Query(
- None, description="last item id seen, for pagination"
- ),
- ):
- """Page over the archive, newest first. Powers both iOS list and the dashboard tab.
- Each item is shaped as an iOS FileLibraryItem so the iOS list service can
- decode the response directly. ``profile`` selects which agent's library to
- read; omitted -> the default profile.
- """
- st = _store_for(profile)
- items, next_cursor, total = st.query_attachments(
- sender=sender,
- kind=kind,
- session_id=session_id,
- search=search,
- limit=limit,
- cursor=cursor,
- )
- return {
- "items": [_to_file_library_item(it) for it in items],
- "nextCursor": str(next_cursor) if next_cursor is not None else None,
- "total": total,
- "profile": profile or "default",
- }
- @router.get("/stats")
- def get_stats(
- profile: Optional[str] = Query(
- None, description="which agent's library to read (default if omitted)"
- ),
- ):
- """Archive summary: counts and total bytes for one profile."""
- return _store_for(profile).stats()
- @router.get("/session-context/{session_id}")
- def get_session_context(
- session_id: str,
- profile: Optional[str] = Query(default=None),
- ):
- """Return the last confirmed context-window reading for one chat."""
- snapshot = _store_for(profile).get_session_context(session_id)
- return {"snapshot": snapshot}
- @router.put("/session-context/{session_id}")
- def put_session_context(
- session_id: str,
- body: SessionContextSnapshotBody,
- profile: Optional[str] = Query(default=None),
- ):
- """Persist a monotonic, profile-scoped context-window reading."""
- snapshot = _store_for(profile).upsert_session_context(
- session_id,
- context_used=body.used_tokens,
- context_limit=body.limit_tokens,
- context_percent=body.percent,
- compressions=body.compressions,
- model=body.model,
- provider=body.provider,
- observed_at=body.observed_at,
- )
- return {"snapshot": snapshot}
- @router.get("/{item_id}")
- def get_item(
- item_id: int,
- profile: Optional[str] = Query(
- None, description="which agent's library holds this item"
- ),
- ):
- att = _store_for(profile).get_attachment(item_id)
- if att is None:
- raise HTTPException(status_code=404, detail="attachment not found")
- return {**att, "kind": _kind_of(att)}
- @router.get("/{item_id}/download")
- def download_item(
- item_id: int,
- profile: Optional[str] = Query(
- None, description="which agent's library holds this item"
- ),
- ):
- """Stream the archived file body. Arbitrary extension; behind dashboard auth.
- The static /dashboard-plugins/<name>/<file> route rejects non-browser
- suffixes (.pdf/.zip/...) and is unauthenticated, so arbitrary file
- downloads must live here as a FileResponse under /api/plugins/yaoyao/.
- """
- st = _store_for(profile)
- att = st.get_attachment(item_id)
- if att is None:
- raise HTTPException(status_code=404, detail="attachment not found")
- storage_name = st.get_object_storage_name(att["objectSha256"])
- if storage_name is None:
- raise HTTPException(status_code=404, detail="object body missing")
- path = st.objects_dir / storage_name
- if not path.is_file():
- raise HTTPException(status_code=404, detail="object body not on disk")
- return FileResponse(
- path=str(path),
- filename=att["displayName"],
- media_type=att["contentType"] or "application/octet-stream",
- )
- @router.post("/message-files/query")
- def query_message_files(
- req: MessageFileQueryRequest,
- profile: Optional[str] = Query(
- None, description="which agent's library to read (default if omitted)"
- ),
- ):
- """Batch-resolve archived files for a set of message ids.
- Mirrors the legacy /api/hermes/file-library/message-files/query contract:
- request {contextType, contextId, messageIds}, response
- {messages: {<messageId>: [file, ...]}} where each file's fields match
- iOS ArchivedMessageFile (camelCase) so a path-string change is the only
- iOS delta needed.
- ``profile`` selects which agent's library to read; omitted -> the default
- profile. Read from the query string so iOS callers that route through the
- shared HermesRESTClient (which sends profile via the X-Hermes-Profile
- header) can pass it explicitly as a query item.
- """
- st = _store_for(profile)
- grouped = st.query_message_files(req.message_ids)
- out: dict[str, list[dict]] = {}
- for mid, files in grouped.items():
- out[str(mid)] = [
- {
- "itemId": f["id"],
- "messageId": str(f["sourceMessageId"])
- if f["sourceMessageId"] is not None
- else "",
- "ordinal": idx,
- "originalPath": "",
- "referencePath": f"/api/plugins/yaoyao/{f['id']}/download",
- "name": f["displayName"],
- "size": f["byteCount"],
- "mimeType": f["contentType"],
- "archiveStatus": "ready",
- "archivedAt": f["discoveredAt"],
- "availability": "archived",
- }
- for idx, f in enumerate(files)
- ]
- return {"messages": out}
- @router.post("/rescan")
- def rescan():
- """Force one immediate poll cycle (handy after a big agent run)."""
- try:
- poller.poll_now()
- except Exception:
- log.debug("rescan: poll_now failed", exc_info=True)
- return store.stats()
- # ===========================================================================
- # Legacy Agent display-name settings
- #
- # The profile id stays the stable routing key. ``agentName`` is an optional,
- # per-profile presentation value retained for older clients. Group Agent
- # identity and current clients use Hermes Bot metadata instead.
- #
- # GET /agent/settings?profile=<profile>
- # PUT /agent/settings?profile=<profile> <- {"agentName": "..."}
- # ===========================================================================
- @router.get("/agent/settings")
- def get_agent_settings(
- profile: Optional[str] = Query(
- None, description="Hermes profile (default if omitted)"
- ),
- ):
- """Read one profile's configured Agent display name."""
- _store_for(profile)
- return store.load_agent_settings(profile)
- @router.put("/agent/settings")
- def put_agent_settings(
- body: dict,
- profile: Optional[str] = Query(
- None, description="Hermes profile (default if omitted)"
- ),
- ):
- """Set or clear one profile's Agent display name."""
- _store_for(profile)
- try:
- return store.save_agent_settings(profile, body)
- except ValueError as error:
- raise HTTPException(status_code=400, detail=str(error)) from error
- # ===========================================================================
- # iOS duplex voice settings
- #
- # Mirrors the yaoyao-webui ``ios-duplex-voice`` contract: one shared API key,
- # a voice list (id + name), and the currently selected voice. The iOS app
- # connects directly to the TTS provider (e.g. Volcano/doubao) for low-latency
- # duplex voice; this plugin stores the configuration it downloads.
- #
- # Endpoints (matching yaoyao-webui's /api/app/duplex-voice/* shape):
- # GET /voice/settings -> {hasApiKey, voices, currentVoiceId, updatedAt}
- # PUT /voice/settings <- {apiKey?, voices, currentVoiceId}
- # GET /voice/runtime -> {apiKey, voices, currentVoiceId, updatedAt}
- # PUT /voice/current-voice <- {currentVoiceId}
- # ===========================================================================
- @router.get("/voice/settings")
- def get_duplex_settings():
- """Public settings for the dashboard UI (apiKey masked as hasApiKey)."""
- return store.public_duplex_voice()
- @router.put("/voice/settings")
- def put_duplex_settings(body: dict):
- """Update duplex voice settings. Partial: omit apiKey to keep existing.
- Returns public settings (apiKey masked as hasApiKey), matching
- yaoyao-webui's saveSettings controller."""
- try:
- store.save_duplex_voice(body)
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- return store.public_duplex_voice()
- @router.get("/voice/runtime")
- def get_duplex_runtime():
- """Runtime settings for the iOS app (includes the real apiKey)."""
- return store.runtime_duplex_voice()
- @router.put("/voice/current-voice")
- def put_duplex_current_voice(body: dict):
- """Quick-switch the current voice without re-sending the full list.
- Returns {voices, currentVoiceId, updatedAt} (no apiKey fields), matching
- yaoyao-webui's saveCurrentVoice controller."""
- try:
- store.save_duplex_voice({"currentVoiceId": body.get("currentVoiceId")})
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- s = store.load_duplex_voice()
- return {
- "voices": s["voices"],
- "currentVoiceId": s["currentVoiceId"],
- "updatedAt": s["updatedAt"],
- }
- # ===========================================================================
- # TTS provider settings (mirrors yaoyao-webui's /api/hermes/tts/settings/*)
- # ===========================================================================
- @router.get("/tts/settings")
- def tts_list_settings():
- """List all configured TTS providers + the active one."""
- return voice_store.tts_list_settings()
- @router.put("/tts/settings/active")
- def tts_set_active(body: dict):
- try:
- return {"activeProvider": voice_store.tts_set_active(body.get("provider", ""))}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- @router.put("/tts/settings/{provider}")
- def tts_save_provider(provider: str, body: dict):
- try:
- row = voice_store.tts_save_provider(
- provider, body.get("settings"), body.get("secrets")
- )
- # Optionally set active
- if "activeProvider" in body:
- voice_store.tts_set_active(body["activeProvider"])
- return {"setting": row, "activeProvider": voice_store.tts_get_active()}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- @router.delete("/tts/settings/{provider}")
- def tts_delete_provider(provider: str):
- try:
- deleted = voice_store.tts_delete_provider(provider)
- return {
- "success": True,
- "deleted": deleted,
- "activeProvider": voice_store.tts_get_active(),
- }
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- @router.delete("/tts/settings/{provider}/base-url-preset")
- def tts_delete_base_url_preset(provider: str, url: str = Query(...)):
- row = voice_store.tts_delete_base_url_preset(provider, url)
- if row is None:
- raise HTTPException(status_code=404, detail="provider not found")
- return {"success": True, "setting": row}
- @router.delete("/tts/settings/{provider}/secret/{secret_name}")
- def tts_clear_secret(provider: str, secret_name: str):
- try:
- row = voice_store.tts_clear_secret(provider, secret_name)
- if row is None:
- raise HTTPException(status_code=404, detail="provider not found")
- return {"success": True, "setting": row}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- # ===========================================================================
- # STT provider settings (mirrors yaoyao-webui's /api/hermes/stt/settings/*)
- # ===========================================================================
- @router.get("/stt/settings")
- def stt_list_settings():
- """List all configured STT providers + the active one."""
- return voice_store.stt_list_settings()
- @router.put("/stt/settings/active")
- def stt_set_active(body: dict):
- try:
- return {"activeProvider": voice_store.stt_set_active(body.get("provider", ""))}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- @router.put("/stt/settings/{provider}")
- def stt_save_provider(provider: str, body: dict):
- try:
- row = voice_store.stt_save_provider(
- provider, body.get("settings"), body.get("secrets")
- )
- if "activeProvider" in body:
- voice_store.stt_set_active(body["activeProvider"])
- return {"setting": row, "activeProvider": voice_store.stt_get_active()}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- @router.delete("/stt/settings/{provider}")
- def stt_delete_provider(provider: str):
- deleted = voice_store.stt_delete_provider(provider)
- return {
- "success": True,
- "deleted": deleted,
- "activeProvider": voice_store.stt_get_active(),
- }
- @router.delete("/stt/settings/{provider}/base-url-preset")
- def stt_delete_base_url_preset(provider: str, url: str = Query(...)):
- row = voice_store.stt_delete_base_url_preset(provider, url)
- if row is None:
- raise HTTPException(status_code=404, detail="provider not found")
- return {"success": True, "setting": row}
- @router.delete("/stt/settings/{provider}/secret/{secret_name}")
- def stt_clear_secret(provider: str, secret_name: str):
- try:
- row = voice_store.stt_clear_secret(provider, secret_name)
- if row is None:
- raise HTTPException(status_code=404, detail="provider not found")
- return {"success": True, "setting": row}
- except ValueError as e:
- raise HTTPException(status_code=400, detail=str(e))
- # ===========================================================================
- # Provider info + probe
- # ===========================================================================
- @router.get("/voice/providers-info")
- def get_providers_info():
- """Static provider metadata for the frontend picker."""
- return voice_store.provider_info()
- class ProbeRequest(BaseModel):
- kind: str # 'tts' | 'stt'
- provider: Optional[str] = None
- compatibility: Optional[str] = None # 'openai-compatible' | 'manual'
- baseUrl: Optional[str] = None
- apiKey: Optional[str] = None
- @router.post("/voice/probe")
- def probe_provider(req: ProbeRequest):
- """Probe a TTS/STT provider's /models endpoint to validate connectivity.
- Mirrors yaoyao-webui's /api/voice/providers/probe. For 'manual' mode,
- just validates the baseUrl. For 'openai-compatible', fetches <baseUrl>/models.
- """
- import urllib.request
- import urllib.error
- compatibility = req.compatibility or "openai-compatible"
- base_url = (req.baseUrl or "").strip()
- if not base_url:
- raise HTTPException(status_code=400, detail="baseUrl is required")
- if not (base_url.startswith("http://") or base_url.startswith("https://")):
- raise HTTPException(status_code=400, detail="baseUrl must be http(s)")
- if compatibility == "manual":
- return {
- "ok": True,
- "models": [],
- "recommendedModel": "",
- "errorSummary": "",
- "manualModelAllowed": True,
- "normalizedBaseUrl": base_url,
- }
- # openai-compatible: fetch /models
- api_key = req.apiKey or ""
- url = base_url.rstrip("/") + "/models"
- headers = {"Content-Type": "application/json"}
- if api_key:
- headers["Authorization"] = f"Bearer {api_key}"
- try:
- request = urllib.request.Request(url, headers=headers, method="GET")
- with urllib.request.urlopen(request, timeout=10) as resp:
- body = resp.read().decode("utf-8", errors="replace")
- data = json.loads(body)
- raw_models = data.get("data", []) if isinstance(data, dict) else []
- models = []
- for m in raw_models[:100]:
- mid = m.get("id", "") if isinstance(m, dict) else str(m)
- if not mid:
- continue
- mid_lower = mid.lower()
- # TTS vs STT preference
- if req.kind == "tts":
- pref = any(
- k in mid_lower
- for k in (
- "tts",
- "speech",
- "audio",
- "voice",
- "playai",
- "orpheus",
- )
- )
- else:
- pref = any(
- k in mid_lower
- for k in ("whisper", "transcrib", "stt", "speech-to-text")
- )
- models.append(
- {
- "id": mid,
- "label": mid,
- "capability": "preferred" if pref else "other",
- }
- )
- # Sort: preferred first
- models.sort(key=lambda m: 0 if m["capability"] == "preferred" else 1)
- recommended = models[0]["id"] if models else ""
- return {
- "ok": True,
- "models": models,
- "recommendedModel": recommended,
- "errorSummary": "",
- "manualModelAllowed": True,
- "normalizedBaseUrl": base_url,
- }
- except urllib.error.HTTPError as e:
- return {
- "ok": False,
- "models": [],
- "recommendedModel": "",
- "errorSummary": f"HTTP {e.code}",
- "errorDetails": e.read().decode("utf-8", errors="replace")[:500],
- "manualModelAllowed": True,
- "normalizedBaseUrl": base_url,
- }
- except Exception as e:
- return {
- "ok": False,
- "models": [],
- "recommendedModel": "",
- "errorSummary": str(e)[:200],
- "manualModelAllowed": True,
- "normalizedBaseUrl": base_url,
- }
|