davinci-resolve-mcp 2.65.0 → 2.66.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.66.0
6
+
7
+ Generalizes the HTTP transcription backend introduced in v2.65.0 into a
8
+ configuration-driven registry, so additional local, network, or cloud-backed
9
+ adapters no longer require MCP source changes. Contributed in PR #97 by
10
+ @double2tea.
11
+
12
+ ### Changed
13
+
14
+ - **Pluggable HTTP transcription providers** (PR #97, @double2tea) — the
15
+ MLX-specific router backend is replaced by an ordered registry of HTTP
16
+ transcription providers registered via
17
+ `DAVINCI_RESOLVE_MCP_TRANSCRIPTION_HTTP_PROVIDERS` (a JSON array). Each entry
18
+ requires `id` and `base_url`; optional adapter fields cover `label`, `model`,
19
+ `health_path`, `transcribe_path`, `health_field`, `health_value`, `headers`,
20
+ `request_body`, `field_map`, and `response_field`. Configured providers are
21
+ selected as stable `http:<id>` backend names and preferred in transcription
22
+ capability ordering. Auth headers are sent on health and transcription
23
+ requests but kept out of capability reports, and malformed configuration
24
+ fails fast. Response handling now accepts a transcript object, a JSON-encoded
25
+ transcript string, or plain text under the configured `response_field`.
26
+ Audiobox is documented as one adapter example rather than a core requirement.
27
+
28
+ ### Removed
29
+
30
+ - The `DAVINCI_RESOLVE_MCP_MLX_AUDIO_URL` / `DAVINCI_RESOLVE_MCP_MLX_AUDIO_MODEL`
31
+ environment variables added in v2.65.0 are superseded by the generic provider
32
+ registry above. To keep an Audiobox/MLX router, register it as a provider:
33
+ `[{"id":"audiobox-local","base_url":"http://127.0.0.1:8000","request_body":{"provider":"mlx"}}]`.
34
+
35
+ ### Validation
36
+
37
+ - `tests/test_media_analysis.py` and the analysis caps/runs/store suites pass
38
+ (153 on the merged tree); static checks and drift guards pass.
39
+ - No DaVinci Resolve scripting behavior changed: the change is confined to the
40
+ stdlib HTTP transcription path and is gated behind an env var. Live Resolve
41
+ validation not required.
42
+
5
43
  ## What's New in v2.65.0
6
44
 
7
45
  Bundles two community contributions from @double2tea: an optional HTTP
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # DaVinci Resolve MCP Server
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.65.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
3
+ [![Version](https://img.shields.io/badge/version-2.66.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
4
4
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
5
5
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
6
6
  [![Tools](https://img.shields.io/badge/MCP%20Tools-34%20(341%20full)-blue.svg)](#server-modes)
@@ -79,21 +79,50 @@ which ffmpeg && ffmpeg -version 2>&1 | head -1
79
79
  which whisper 2>/dev/null || which whisper-cpp 2>/dev/null || python3 -c "import whisper" 2>/dev/null
80
80
  ```
81
81
 
82
- An HTTP MLX Audio Router can be used without installing a transcription module
83
- into the Resolve MCP Python environment:
82
+ One or more HTTP transcription adapters can be registered without installing a
83
+ transcription module into the Resolve MCP Python environment. Providers are
84
+ selected as `http:<id>`, in configuration order:
84
85
 
85
86
  ```bash
86
- export DAVINCI_RESOLVE_MCP_MLX_AUDIO_URL=http://127.0.0.1:8000
87
- # Optional: omit this to use the router's own default model.
88
- export DAVINCI_RESOLVE_MCP_MLX_AUDIO_MODEL=mlx-community/Qwen3-ASR-1.7B-8bit
87
+ export DAVINCI_RESOLVE_MCP_TRANSCRIPTION_HTTP_PROVIDERS='[
88
+ {
89
+ "id": "audiobox-local",
90
+ "label": "Audiobox local",
91
+ "base_url": "http://127.0.0.1:8000",
92
+ "model": "mlx-community/Qwen3-ASR-1.7B-8bit",
93
+ "request_body": {"provider": "mlx"}
94
+ },
95
+ {
96
+ "id": "studio-asr",
97
+ "label": "Studio ASR",
98
+ "base_url": "https://asr.example.com",
99
+ "headers": {"Authorization": "Bearer replace-me"},
100
+ "health_path": "/ready",
101
+ "transcribe_path": "/v1/transcribe",
102
+ "field_map": {"audio": "input_path"},
103
+ "response_field": "result"
104
+ }
105
+ ]'
89
106
  ```
90
107
 
91
- The configured service must expose `GET /health`, returning JSON with
92
- `{"status":"ok"}`, and `POST /stt`. The transcription request uses the local
93
- source path and requests JSON output; the response must contain a `transcript`
94
- string whose value is a JSON object with `text` and `segments`. The
95
- [Audiobox MLX Audio Router](https://github.com/double2tea/Audiobox) implements
96
- this contract. Model download behavior follows the existing
108
+ Each provider requires `id` and `base_url`. Optional fields are `label`,
109
+ `model`, `health_path`, `transcribe_path`, `health_field`, `health_value`,
110
+ `headers`, `request_body`, `field_map`, and `response_field`. Authentication
111
+ headers are used for health and transcription requests but are omitted from
112
+ capability reports.
113
+
114
+ The default adapter contract uses `GET /health` with `{"status":"ok"}` and
115
+ `POST /stt`. Its JSON request includes `audio`, `output_path`, `format`,
116
+ `verbose`, `allow_download`, and optional `model` and `language` fields.
117
+ `request_body` adds adapter-specific values, while `field_map` renames standard
118
+ request fields. The response may return a transcript object, a JSON-encoded
119
+ transcript string, or plain text under `response_field` (default
120
+ `transcript`). The normalized transcript object uses `text` and `segments`.
121
+
122
+ [Audiobox MLX Audio Router](https://github.com/double2tea/Audiobox) is one
123
+ adapter implementation, not a built-in backend requirement. Other local,
124
+ network, or cloud-backed adapters can be added through configuration without
125
+ changing MCP source code. Model download behavior follows the existing
97
126
  `allow_model_download` transcription option.
98
127
 
99
128
  FFprobe is required. If missing:
package/install.py CHANGED
@@ -36,7 +36,7 @@ from src.utils.update_check import (
36
36
 
37
37
  # ─── Version ──────────────────────────────────────────────────────────────────
38
38
 
39
- VERSION = "2.65.0"
39
+ VERSION = "2.66.0"
40
40
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
41
41
  # Resolve's scripting bridge loads into newer interpreters on recent builds
42
42
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.65.0",
3
+ "version": "2.66.0",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -81,7 +81,7 @@ if not logging.getLogger().handlers:
81
81
  handlers=[logging.StreamHandler()],
82
82
  )
83
83
 
84
- VERSION = "2.65.0"
84
+ VERSION = "2.66.0"
85
85
  logger = logging.getLogger("davinci-resolve-mcp")
86
86
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
87
87
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 341-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.65.0"
14
+ VERSION = "2.66.0"
15
15
 
16
16
  import base64
17
17
  import os
@@ -23,6 +23,7 @@ import sys
23
23
  import threading
24
24
  import time
25
25
  import urllib.error
26
+ import urllib.parse
26
27
  import urllib.request
27
28
  from datetime import datetime, timezone
28
29
  from pathlib import Path
@@ -357,8 +358,8 @@ ANALYSIS_DIR_NAME = "davinci-resolve-mcp-analysis"
357
358
  HIDDEN_ANALYSIS_DIR_NAME = ".davinci-resolve-mcp-analysis"
358
359
  ANALYSIS_VERSION = "0.2"
359
360
 
360
- MLX_AUDIO_ROUTER_URL_ENV = "DAVINCI_RESOLVE_MCP_MLX_AUDIO_URL"
361
- MLX_AUDIO_ROUTER_MODEL_ENV = "DAVINCI_RESOLVE_MCP_MLX_AUDIO_MODEL"
361
+ HTTP_TRANSCRIPTION_PROVIDERS_ENV = "DAVINCI_RESOLVE_MCP_TRANSCRIPTION_HTTP_PROVIDERS"
362
+ HTTP_TRANSCRIPTION_BACKEND_PREFIX = "http:"
362
363
  ANALYSIS_INDEX_FILENAME = "index.sqlite"
363
364
  ANALYSIS_REGISTRY_FILENAME = "analysis_registry.json"
364
365
  ANALYSIS_INDEX_SCHEMA_VERSION = 1
@@ -1611,46 +1612,112 @@ def install_plan_for(tool_name: str, platform_id: Optional[str] = None) -> Dict[
1611
1612
  }
1612
1613
 
1613
1614
 
1614
- def _detect_mlx_audio_router(env: Dict[str, str]) -> Dict[str, Any]:
1615
- router_url = (env.get(MLX_AUDIO_ROUTER_URL_ENV) or "").strip().rstrip("/")
1616
- model = (env.get(MLX_AUDIO_ROUTER_MODEL_ENV) or "").strip() or None
1617
- if not router_url:
1618
- return {
1619
- "available": False,
1620
- "configured": False,
1621
- "url_env": MLX_AUDIO_ROUTER_URL_ENV,
1622
- "model_env": MLX_AUDIO_ROUTER_MODEL_ENV,
1623
- }
1624
- if not router_url.startswith(("http://", "https://")):
1625
- return {
1626
- "available": False,
1627
- "configured": True,
1628
- "url": router_url,
1629
- "model": model,
1630
- "error": f"{MLX_AUDIO_ROUTER_URL_ENV} must use http:// or https://",
1631
- }
1615
+ def _http_provider_endpoint(provider: Dict[str, Any], path_key: str) -> str:
1616
+ path = str(provider[path_key]).strip()
1617
+ if path.startswith(("http://", "https://")):
1618
+ return path
1619
+ return f"{provider['base_url']}/{path.lstrip('/')}"
1620
+
1632
1621
 
1622
+ def _load_http_transcription_providers(env: Dict[str, str]) -> Tuple[List[Dict[str, Any]], Optional[str]]:
1623
+ raw = (env.get(HTTP_TRANSCRIPTION_PROVIDERS_ENV) or "").strip()
1624
+ if not raw:
1625
+ return [], None
1633
1626
  try:
1634
- request = urllib.request.Request(f"{router_url}/health", method="GET")
1635
- with urllib.request.urlopen(request, timeout=1.0) as response:
1636
- payload = json.loads(response.read().decode("utf-8"))
1637
- if payload.get("status") != "ok":
1638
- raise ValueError("health response status is not 'ok'")
1639
- except (OSError, ValueError) as exc:
1627
+ entries = json.loads(raw)
1628
+ except json.JSONDecodeError as exc:
1629
+ return [], f"{HTTP_TRANSCRIPTION_PROVIDERS_ENV} is not valid JSON: {exc}"
1630
+ if not isinstance(entries, list):
1631
+ return [], f"{HTTP_TRANSCRIPTION_PROVIDERS_ENV} must be a JSON array"
1632
+
1633
+ providers: List[Dict[str, Any]] = []
1634
+ seen_ids = set()
1635
+ for index, entry in enumerate(entries):
1636
+ if not isinstance(entry, dict):
1637
+ return [], f"HTTP transcription provider at index {index} must be an object"
1638
+ provider_id = str(entry.get("id") or "").strip()
1639
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", provider_id):
1640
+ return [], f"HTTP transcription provider at index {index} has an invalid id"
1641
+ if provider_id in seen_ids:
1642
+ return [], f"Duplicate HTTP transcription provider id: {provider_id}"
1643
+ seen_ids.add(provider_id)
1644
+
1645
+ base_url = str(entry.get("base_url") or "").strip().rstrip("/")
1646
+ parsed_url = urllib.parse.urlparse(base_url)
1647
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
1648
+ return [], f"HTTP transcription provider '{provider_id}' base_url must use http:// or https://"
1649
+ headers = entry.get("headers") or {}
1650
+ request_body = entry.get("request_body") or {}
1651
+ field_map = entry.get("field_map") or {}
1652
+ if not isinstance(headers, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in headers.items()):
1653
+ return [], f"HTTP transcription provider '{provider_id}' headers must be a string map"
1654
+ if not isinstance(request_body, dict):
1655
+ return [], f"HTTP transcription provider '{provider_id}' request_body must be an object"
1656
+ if not isinstance(field_map, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in field_map.items()):
1657
+ return [], f"HTTP transcription provider '{provider_id}' field_map must be a string map"
1658
+
1659
+ label = str(entry.get("label") or provider_id).strip() or provider_id
1660
+ providers.append({
1661
+ "id": provider_id,
1662
+ "label": label,
1663
+ "base_url": base_url,
1664
+ "model": str(entry.get("model") or "").strip() or None,
1665
+ "health_path": str(entry.get("health_path") or "/health").strip(),
1666
+ "transcribe_path": str(entry.get("transcribe_path") or "/stt").strip(),
1667
+ "health_field": str(entry.get("health_field") or "status").strip(),
1668
+ "health_value": entry.get("health_value", "ok"),
1669
+ "response_field": str(entry.get("response_field", "transcript")).strip(),
1670
+ "headers": dict(headers),
1671
+ "request_body": dict(request_body),
1672
+ "field_map": dict(field_map),
1673
+ })
1674
+ return providers, None
1675
+
1676
+
1677
+ def _detect_http_transcription_providers(env: Dict[str, str]) -> Dict[str, Any]:
1678
+ providers, config_error = _load_http_transcription_providers(env)
1679
+ if config_error:
1640
1680
  return {
1641
1681
  "available": False,
1642
1682
  "configured": True,
1643
- "url": router_url,
1644
- "model": model,
1645
- "error": str(exc),
1683
+ "config_env": HTTP_TRANSCRIPTION_PROVIDERS_ENV,
1684
+ "error": config_error,
1685
+ "providers": [],
1646
1686
  }
1647
1687
 
1688
+ detected = []
1689
+ for provider in providers:
1690
+ public = {
1691
+ "id": provider["id"],
1692
+ "label": provider["label"],
1693
+ "url": provider["base_url"],
1694
+ "model": provider["model"],
1695
+ "backend": f"{HTTP_TRANSCRIPTION_BACKEND_PREFIX}{provider['id']}",
1696
+ }
1697
+ try:
1698
+ request = urllib.request.Request(
1699
+ _http_provider_endpoint(provider, "health_path"),
1700
+ headers=provider["headers"],
1701
+ method="GET",
1702
+ )
1703
+ with urllib.request.urlopen(request, timeout=1.0) as response:
1704
+ payload = json.loads(response.read().decode("utf-8"))
1705
+ if not isinstance(payload, dict):
1706
+ raise ValueError("health response must be a JSON object")
1707
+ if payload.get(provider["health_field"]) != provider["health_value"]:
1708
+ raise ValueError(
1709
+ f"health field '{provider['health_field']}' did not equal the configured ready value"
1710
+ )
1711
+ public.update({"available": True, "api_version": payload.get("api_version")})
1712
+ except (OSError, ValueError) as exc:
1713
+ public.update({"available": False, "error": str(exc)})
1714
+ detected.append(public)
1715
+
1648
1716
  return {
1649
- "available": True,
1650
- "configured": True,
1651
- "url": router_url,
1652
- "model": model,
1653
- "api_version": payload.get("api_version"),
1717
+ "available": any(provider["available"] for provider in detected),
1718
+ "configured": bool(providers),
1719
+ "config_env": HTTP_TRANSCRIPTION_PROVIDERS_ENV,
1720
+ "providers": detected,
1654
1721
  }
1655
1722
 
1656
1723
 
@@ -1666,7 +1733,7 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
1666
1733
  mlx_whisper = importlib.util.find_spec("mlx_whisper") is not None
1667
1734
  cv2 = importlib.util.find_spec("cv2") is not None
1668
1735
  provider = env.get("DAVINCI_RESOLVE_MCP_VISION_PROVIDER")
1669
- mlx_audio_router = _detect_mlx_audio_router(env)
1736
+ http_transcription = _detect_http_transcription_providers(env)
1670
1737
 
1671
1738
  sync_events = detect_sync_event_capabilities()
1672
1739
 
@@ -1702,7 +1769,7 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
1702
1769
  "whisper_cli": _tool_entry("whisper_cli", bool(whisper_cli), {"path": whisper_cli}),
1703
1770
  "whisper_cpp": _tool_entry("whisper_cpp", bool(whisper_cpp), {"path": whisper_cpp}),
1704
1771
  "mlx_whisper": _tool_entry("mlx_whisper", bool(mlx_whisper), {"python_module": "mlx_whisper"}),
1705
- "mlx_audio_router": mlx_audio_router,
1772
+ "http_transcription": http_transcription,
1706
1773
  "opencv": _tool_entry("opencv", bool(cv2), {"python_module": "cv2"}),
1707
1774
  "ollama_embeddings": _tool_entry(
1708
1775
  "ollama_embeddings",
@@ -1722,10 +1789,13 @@ def detect_capabilities(env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
1722
1789
  },
1723
1790
  "embeddings": embedding_caps,
1724
1791
  "transcription": {
1725
- "available": bool(mlx_audio_router["available"] or whisper_cli or whisper_cpp or mlx_whisper),
1792
+ "available": bool(http_transcription["available"] or whisper_cli or whisper_cpp or mlx_whisper),
1726
1793
  "backends": [
1794
+ provider["backend"]
1795
+ for provider in http_transcription["providers"]
1796
+ if provider["available"]
1797
+ ] + [
1727
1798
  name for name, available in (
1728
- ("mlx_audio_router", mlx_audio_router["available"]),
1729
1799
  ("whisper_cli", bool(whisper_cli)),
1730
1800
  ("whisper_cpp", bool(whisper_cpp)),
1731
1801
  ("mlx_whisper", bool(mlx_whisper)),
@@ -1781,7 +1851,7 @@ def install_guidance(capabilities: Optional[Dict[str, Any]] = None) -> Dict[str,
1781
1851
  missing["transcription"] = {
1782
1852
  "required_for": ["transcription analysis", "default Resolve media analysis"],
1783
1853
  "options": [
1784
- f"Configure an MLX Audio Router with {MLX_AUDIO_ROUTER_URL_ENV}",
1854
+ f"Configure one or more HTTP providers with {HTTP_TRANSCRIPTION_PROVIDERS_ENV}",
1785
1855
  "Install/configure whisper CLI",
1786
1856
  "Install/configure whisper-cpp",
1787
1857
  "Install mlx-whisper on supported Apple Silicon systems",
@@ -3986,33 +4056,17 @@ def _transcribe_with_mlx_whisper(path: str, artifacts: Dict[str, Any], transcrip
3986
4056
  return payload
3987
4057
 
3988
4058
 
3989
- def _transcribe_with_mlx_audio_router(
4059
+ def _transcribe_with_http_provider(
3990
4060
  path: str,
3991
4061
  artifacts: Dict[str, Any],
3992
4062
  transcription: Dict[str, Any],
4063
+ provider: Dict[str, Any],
3993
4064
  ) -> Dict[str, Any]:
3994
- router_url = str(
3995
- transcription.get("router_url")
3996
- or os.environ.get(MLX_AUDIO_ROUTER_URL_ENV)
3997
- or ""
3998
- ).strip().rstrip("/")
3999
- if not router_url:
4000
- return {
4001
- "success": False,
4002
- "status": "skipped",
4003
- "backend": "mlx_audio_router",
4004
- "reason": f"Set {MLX_AUDIO_ROUTER_URL_ENV} to the MLX Audio Router base URL.",
4005
- }
4006
-
4007
- model = str(
4008
- transcription.get("model")
4009
- or os.environ.get(MLX_AUDIO_ROUTER_MODEL_ENV)
4010
- or ""
4011
- ).strip()
4065
+ backend = f"{HTTP_TRANSCRIPTION_BACKEND_PREFIX}{provider['id']}"
4066
+ model = str(transcription.get("model") or provider.get("model") or "").strip()
4012
4067
  transcript_json = artifacts.get("transcript_json") or artifacts["analysis_json"]
4013
4068
  output_base = os.path.splitext(transcript_json)[0]
4014
- request_payload = {
4015
- "provider": "mlx",
4069
+ canonical_payload = {
4016
4070
  "audio": path,
4017
4071
  "output_path": output_base,
4018
4072
  "format": "json",
@@ -4020,11 +4074,19 @@ def _transcribe_with_mlx_audio_router(
4020
4074
  "allow_download": _coerce_bool(transcription.get("allow_model_download"), default=False),
4021
4075
  }
4022
4076
  if model:
4023
- request_payload["model"] = model
4077
+ canonical_payload["model"] = model
4078
+ if transcription.get("language"):
4079
+ canonical_payload["language"] = transcription["language"]
4080
+ request_payload = dict(provider.get("request_body") or {})
4081
+ field_map = provider.get("field_map") or {}
4082
+ for key, value in canonical_payload.items():
4083
+ request_payload[field_map.get(key, key)] = value
4084
+ headers = {"Content-Type": "application/json"}
4085
+ headers.update(provider.get("headers") or {})
4024
4086
  request = urllib.request.Request(
4025
- f"{router_url}/stt",
4087
+ _http_provider_endpoint(provider, "transcribe_path"),
4026
4088
  data=json.dumps(request_payload).encode("utf-8"),
4027
- headers={"Content-Type": "application/json"},
4089
+ headers=headers,
4028
4090
  method="POST",
4029
4091
  )
4030
4092
  try:
@@ -4034,41 +4096,46 @@ def _transcribe_with_mlx_audio_router(
4034
4096
  detail = exc.read().decode("utf-8", errors="replace").strip()
4035
4097
  return {
4036
4098
  "success": False,
4037
- "backend": "mlx_audio_router",
4038
- "error": detail or f"MLX Audio Router returned HTTP {exc.code}",
4099
+ "backend": backend,
4100
+ "provider": provider["label"],
4101
+ "error": detail or f"HTTP transcription provider returned HTTP {exc.code}",
4039
4102
  }
4040
4103
  except (OSError, ValueError) as exc:
4041
- return {"success": False, "backend": "mlx_audio_router", "error": str(exc)}
4104
+ return {"success": False, "backend": backend, "provider": provider["label"], "error": str(exc)}
4042
4105
 
4043
4106
  if not isinstance(response_payload, dict):
4044
4107
  return {
4045
4108
  "success": False,
4046
- "backend": "mlx_audio_router",
4047
- "error": "MLX Audio Router response must be a JSON object.",
4048
- }
4049
- raw_transcript = response_payload.get("transcript")
4050
- if not isinstance(raw_transcript, str) or not raw_transcript.strip():
4051
- return {
4052
- "success": False,
4053
- "backend": "mlx_audio_router",
4054
- "error": "MLX Audio Router response did not include a JSON transcript.",
4109
+ "backend": backend,
4110
+ "provider": provider["label"],
4111
+ "error": "HTTP transcription response must be a JSON object.",
4055
4112
  }
4056
- try:
4057
- raw = json.loads(raw_transcript)
4058
- except json.JSONDecodeError as exc:
4113
+ response_field = provider.get("response_field")
4114
+ if response_field and response_field not in response_payload:
4059
4115
  return {
4060
4116
  "success": False,
4061
- "backend": "mlx_audio_router",
4062
- "error": f"MLX Audio Router transcript is not valid JSON: {exc}",
4117
+ "backend": backend,
4118
+ "provider": provider["label"],
4119
+ "error": f"HTTP transcription response did not include '{response_field}'.",
4063
4120
  }
4121
+ raw_transcript = response_payload[response_field] if response_field else response_payload
4122
+ if isinstance(raw_transcript, str):
4123
+ try:
4124
+ raw = json.loads(raw_transcript)
4125
+ except json.JSONDecodeError:
4126
+ raw = {"text": raw_transcript, "segments": []}
4127
+ else:
4128
+ raw = raw_transcript
4064
4129
  if not isinstance(raw, dict):
4065
4130
  return {
4066
4131
  "success": False,
4067
- "backend": "mlx_audio_router",
4068
- "error": "MLX Audio Router transcript JSON must be an object.",
4132
+ "backend": backend,
4133
+ "provider": provider["label"],
4134
+ "error": "HTTP transcription payload must resolve to a JSON object or text string.",
4069
4135
  }
4070
4136
 
4071
- payload = _normalize_transcript_payload(raw, "mlx_audio_router", transcription.get("language"))
4137
+ payload = _normalize_transcript_payload(raw, backend, transcription.get("language"))
4138
+ payload["provider"] = provider["label"]
4072
4139
  if response_payload.get("model") or model:
4073
4140
  payload["model"] = response_payload.get("model") or model
4074
4141
  _write_transcript_artifacts(payload, artifacts)
@@ -4116,8 +4183,20 @@ def _transcribe(path: str, artifacts: Dict[str, Any], options: Dict[str, Any], c
4116
4183
  payload = {"success": True, "backend": backend, "language": transcription.get("language", "unknown"), "segments": segments, "text": " ".join(s.get("text", "") for s in segments)}
4117
4184
  _write_transcript_artifacts(payload, artifacts)
4118
4185
  return payload
4119
- if backend == "mlx_audio_router":
4120
- return _transcribe_with_mlx_audio_router(path, artifacts, transcription)
4186
+ if isinstance(backend, str) and backend.startswith(HTTP_TRANSCRIPTION_BACKEND_PREFIX):
4187
+ providers, config_error = _load_http_transcription_providers(os.environ)
4188
+ if config_error:
4189
+ return {"success": False, "backend": backend, "error": config_error}
4190
+ provider_id = backend[len(HTTP_TRANSCRIPTION_BACKEND_PREFIX):]
4191
+ provider = next((item for item in providers if item["id"] == provider_id), None)
4192
+ if provider is None:
4193
+ return {
4194
+ "success": False,
4195
+ "status": "skipped",
4196
+ "backend": backend,
4197
+ "reason": f"HTTP transcription provider '{provider_id}' is not configured.",
4198
+ }
4199
+ return _transcribe_with_http_provider(path, artifacts, transcription, provider)
4121
4200
  if backend in {"whisper_cli", "mlx_whisper"}:
4122
4201
  if not _coerce_bool(transcription.get("allow_model_download"), default=False):
4123
4202
  return {
@@ -4159,7 +4238,13 @@ def _transcribe(path: str, artifacts: Dict[str, Any], options: Dict[str, Any], c
4159
4238
  # original branches below so behaviour stays identical for those.
4160
4239
  if result is not None and result.get("status") != "fallthrough":
4161
4240
  return result
4162
- if backend in {"mock", "local_mock", "mlx_audio_router", "whisper_cli", "mlx_whisper"}:
4241
+ if (
4242
+ backend in {"mock", "local_mock", "whisper_cli", "mlx_whisper"}
4243
+ or (
4244
+ isinstance(backend, str)
4245
+ and backend.startswith(HTTP_TRANSCRIPTION_BACKEND_PREFIX)
4246
+ )
4247
+ ):
4163
4248
  return result if result is not None else {"success": False, "backend": backend}
4164
4249
  elif backend == "whisper_cpp":
4165
4250
  if not transcription.get("model_path"):