diff --git a/.github/workflows/publish-windows-alpha.yml b/.github/workflows/publish-windows-alpha.yml
index 3a6a954..9f08fcd 100644
--- a/.github/workflows/publish-windows-alpha.yml
+++ b/.github/workflows/publish-windows-alpha.yml
@@ -108,6 +108,9 @@ jobs:
- The build is unsigned, so Windows SmartScreen may show a warning.
- Faster Whisper safely falls back from unsupported float16 GPUs to CPU.
- Long-stream waveforms are generated with bounded memory through FFmpeg.
+ - Optional transcription engines that are not bundled are clearly disabled.
+ - OpenAI and xAI keys/models can be tested without sending transcript text.
+ - AI Editor shows the active provider and explains authentication failures.
This payload was promoted only after native Windows CI started the
packaged backend, rendered a vertical MP4 with ASS captions and a
diff --git a/ROADMAP.md b/ROADMAP.md
index bf2ed22..e171a5a 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -13,6 +13,13 @@
- Bug fixes from early users
- Clearer local setup guidance
+## v0.1.2
+
+- Verifiable OpenAI and xAI API keys without sending a transcript
+- Explicit active provider and friendly authentication errors
+- Honest desktop transcription-engine availability
+- Automatic Faster Whisper model guidance
+
## v0.2.0
- Better AI clipping workflow
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 71d7662..f24573f 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -6,9 +6,9 @@ python-multipart>=0.0.12
# Transcription with word-level timestamps
faster-whisper>=1.0.0
-# WhisperX already provides the default transcription path. The legacy
-# openai-whisper fallback is optional because its source distribution currently
-# fails in pip's isolated build environment on otherwise supported systems.
+# Faster Whisper is the bundled/default desktop path. WhisperX and the legacy
+# openai-whisper fallback are optional because their dependency stacks currently
+# fail in pip's isolated build environment on otherwise supported systems.
# Install it manually with:
# pip install "setuptools<81" wheel && pip install --no-build-isolation openai-whisper
# Optional Parakeet TDT v3 engine support uses NVIDIA NeMo:
diff --git a/backend/routers/ai.py b/backend/routers/ai.py
index 7fee8b7..b7e3a47 100644
--- a/backend/routers/ai.py
+++ b/backend/routers/ai.py
@@ -77,6 +77,13 @@ class ModelListRequest(BaseModel):
api_key: Optional[str] = None
+class ProviderCheckRequest(BaseModel):
+ provider: str
+ api_key: Optional[str] = None
+ model: Optional[str] = None
+ base_url: Optional[str] = None
+
+
@router.post("/ai/filler-removal")
async def filler_removal(req: FillerRequest):
try:
@@ -217,3 +224,13 @@ async def ollama_status(base_url: str = "http://localhost:11434"):
async def nine_router_models(req: ModelListRequest):
models = AIProvider.list_9router_models(req.base_url or "http://localhost:20128/v1", req.api_key)
return {"models": models}
+
+
+@router.post("/ai/provider-check")
+async def provider_check(req: ProviderCheckRequest):
+ return AIProvider.check_cloud_provider(
+ provider=req.provider,
+ api_key=req.api_key,
+ model=req.model,
+ base_url=req.base_url,
+ )
diff --git a/backend/routers/transcribe.py b/backend/routers/transcribe.py
index 54043fb..a89428d 100644
--- a/backend/routers/transcribe.py
+++ b/backend/routers/transcribe.py
@@ -36,7 +36,20 @@ def progress(percent: int, message: str):
progress_callback(percent, message)
try:
- progress(5, "Preparing transcription")
+ engine_label = {
+ "auto": "the best available transcription engine",
+ "faster-whisper": "Faster Whisper",
+ "whisperx": "WhisperX",
+ "whisper": "Whisper",
+ "parakeet": "Parakeet",
+ }.get(req.engine, req.engine)
+ progress(
+ 5,
+ (
+ f"Loading {engine_label} model '{req.model}'. "
+ "On first use, an available engine downloads its speech model automatically."
+ ),
+ )
result = transcribe_audio(
file_path=req.file_path,
model_name=req.model,
diff --git a/backend/scripts/smoke_backend.py b/backend/scripts/smoke_backend.py
index bf46319..6431161 100644
--- a/backend/scripts/smoke_backend.py
+++ b/backend/scripts/smoke_backend.py
@@ -473,9 +473,23 @@ def test_transcription_engine_status_includes_parakeet(self) -> None:
status = transcription.get_transcription_engine_status()
self.assertIn("faster-whisper", status["engines"])
self.assertTrue(status["engines"]["faster-whisper"]["first_class"])
+ self.assertIn("downloads automatically", status["engines"]["faster-whisper"]["download_behavior"])
self.assertIn("parakeet", status["engines"])
self.assertTrue(status["engines"]["parakeet"]["first_class"])
self.assertEqual(status["engines"]["parakeet"]["default_model"], transcription.PARAKEET_DEFAULT_MODEL)
+ if not status["engines"]["whisperx"]["available"]:
+ self.assertFalse(status["engines"]["whisperx"]["selectable"])
+ self.assertIn("desktop build", status["engines"]["whisperx"]["unavailable_reason"])
+
+ def test_unavailable_whisperx_explains_that_manual_model_download_is_not_enough(self) -> None:
+ transcription = self._load_transcription_service_or_skip()
+ original_available = transcription.WHISPERX_AVAILABLE
+ try:
+ transcription.WHISPERX_AVAILABLE = False
+ with self.assertRaisesRegex(RuntimeError, "Downloading a Whisper model manually"):
+ transcription._resolve_engine("whisperx")
+ finally:
+ transcription.WHISPERX_AVAILABLE = original_available
def test_faster_whisper_normalizes_word_timestamps(self) -> None:
transcription = self._load_transcription_service_or_skip()
@@ -801,6 +815,69 @@ def test_xai_provider_uses_official_openai_compatible_endpoint(self) -> None:
self.assertEqual(args[1], "grok-4.5")
self.assertEqual(args[3], "https://api.x.ai/v1")
self.assertEqual(args[6], "xAI")
+ self.assertEqual(args[7], "xai")
+
+ def test_cloud_provider_check_verifies_key_and_selected_model_without_completion(self) -> None:
+ response = SimpleNamespace(
+ ok=True,
+ status_code=200,
+ text="",
+ json=lambda: {
+ "object": "list",
+ "data": [
+ {"id": "grok-4.5"},
+ {"id": "grok-4.3"},
+ ],
+ },
+ )
+ with patch.object(ai_provider.requests, "get", return_value=response) as request:
+ result = ai_provider.AIProvider.check_cloud_provider(
+ provider="xai",
+ api_key="xai-test",
+ model="grok-4.5",
+ )
+
+ self.assertTrue(result["ok"])
+ self.assertTrue(result["authenticated"])
+ self.assertTrue(result["model_available"])
+ self.assertEqual(result["models"], ["grok-4.3", "grok-4.5"])
+ self.assertEqual(request.call_args.args[0], "https://api.x.ai/v1/models")
+ self.assertEqual(request.call_args.kwargs["headers"]["Authorization"], "Bearer xai-test")
+
+ def test_cloud_provider_check_explains_rejected_xai_key(self) -> None:
+ response = SimpleNamespace(
+ ok=False,
+ status_code=400,
+ text="",
+ json=lambda: {
+ "code": "invalid-argument",
+ "error": "Incorrect API key provided.",
+ },
+ )
+ with patch.object(ai_provider.requests, "get", return_value=response):
+ result = ai_provider.AIProvider.check_cloud_provider(
+ provider="xai",
+ api_key="xai-secret",
+ model="grok-4.5",
+ )
+
+ self.assertFalse(result["ok"])
+ self.assertFalse(result["authenticated"])
+ self.assertEqual(result["code"], "invalid_key")
+ self.assertIn("did reach xAI", result["message"])
+ self.assertNotIn("xai-secret", str(result))
+
+ def test_completion_error_explains_openai_api_is_separate_from_chatgpt(self) -> None:
+ error = SimpleNamespace(status_code=401)
+ error.__str__ = lambda self: "Incorrect API key provided"
+ message = ai_provider._friendly_completion_error(
+ "openai",
+ "OpenAI",
+ RuntimeError("Incorrect API key provided"),
+ "gpt-4o",
+ )
+
+ self.assertIn("ChatGPT subscription does not include OpenAI API usage", message)
def test_clip_request_includes_shorts_platform_guidance(self) -> None:
captured: dict[str, str] = {}
diff --git a/backend/services/ai_provider.py b/backend/services/ai_provider.py
index ed410d9..1249156 100644
--- a/backend/services/ai_provider.py
+++ b/backend/services/ai_provider.py
@@ -10,6 +10,19 @@
logger = logging.getLogger(__name__)
+_CLOUD_PROVIDER_CONFIG = {
+ "openai": {
+ "label": "OpenAI",
+ "base_url": "https://api.openai.com/v1",
+ "key_url": "https://platform.openai.com/api-keys",
+ },
+ "xai": {
+ "label": "xAI",
+ "base_url": "https://api.x.ai/v1",
+ "key_url": "https://console.x.ai/",
+ },
+}
+
class AIProvider:
"""Routes completion requests to the configured provider."""
@@ -39,6 +52,7 @@ def complete(
system_prompt,
temperature,
"xAI",
+ "xai",
)
elif provider == "9router":
return _nine_router_complete(
@@ -106,6 +120,138 @@ def list_9router_models(base_url: str = "http://localhost:20128/v1", api_key: Op
logger.error(f"9router model listing error: {e}")
return []
+ @staticmethod
+ def check_cloud_provider(
+ provider: str,
+ api_key: Optional[str],
+ model: Optional[str] = None,
+ base_url: Optional[str] = None,
+ ) -> dict:
+ """Verify a cloud key and selected model without making a completion request."""
+ config = _CLOUD_PROVIDER_CONFIG.get(provider)
+ if not config:
+ return {
+ "ok": False,
+ "authenticated": False,
+ "provider": provider,
+ "code": "unsupported_provider",
+ "message": f"Connection testing is not available for provider '{provider}'.",
+ "models": [],
+ "model_available": None,
+ }
+
+ provider_label = str(config["label"])
+ key = (api_key or "").strip()
+ selected_model = (model or "").strip()
+ endpoint = _normalize_base_url(base_url or str(config["base_url"]))
+ if not key:
+ return {
+ "ok": False,
+ "authenticated": False,
+ "provider": provider,
+ "code": "missing_key",
+ "message": f"Enter a {provider_label} API key first.",
+ "models": [],
+ "model_available": None,
+ }
+
+ try:
+ response = requests.get(
+ f"{endpoint}/models",
+ headers={"Authorization": f"Bearer {key}"},
+ timeout=15,
+ )
+ except requests.RequestException as error:
+ logger.warning("%s connection test failed: %s", provider_label, error)
+ return {
+ "ok": False,
+ "authenticated": False,
+ "provider": provider,
+ "code": "network_error",
+ "message": (
+ f"Could not reach {provider_label}. Check the internet connection, "
+ "VPN/firewall, and try again."
+ ),
+ "models": [],
+ "model_available": None,
+ }
+
+ if not response.ok:
+ error_text = _safe_provider_error_text(response, key)
+ code, message, authenticated = _classify_provider_error(
+ provider,
+ provider_label,
+ error_text,
+ response.status_code,
+ selected_model,
+ )
+ logger.warning(
+ "%s connection test was rejected with status %s (%s)",
+ provider_label,
+ response.status_code,
+ code,
+ )
+ return {
+ "ok": False,
+ "authenticated": authenticated,
+ "provider": provider,
+ "code": code,
+ "message": message,
+ "models": [],
+ "model_available": None,
+ }
+
+ try:
+ payload = response.json()
+ except ValueError:
+ return {
+ "ok": False,
+ "authenticated": True,
+ "provider": provider,
+ "code": "invalid_response",
+ "message": f"{provider_label} accepted the key but returned an unreadable model list.",
+ "models": [],
+ "model_available": None,
+ }
+
+ raw_models = payload.get("data", payload if isinstance(payload, list) else [])
+ models = sorted(
+ {
+ model_id
+ for model_id in (_extract_model_id(item) for item in raw_models)
+ if model_id
+ }
+ )
+ model_available = selected_model in models if selected_model else None
+ if selected_model and not model_available:
+ return {
+ "ok": False,
+ "authenticated": True,
+ "provider": provider,
+ "code": "model_unavailable",
+ "message": (
+ f"{provider_label} accepted the key, but model '{selected_model}' is not "
+ "available to this account. Choose one of the models loaded below."
+ ),
+ "models": models[:500],
+ "model_available": False,
+ }
+
+ return {
+ "ok": True,
+ "authenticated": True,
+ "provider": provider,
+ "code": "ok",
+ "message": (
+ f"{provider_label} connection verified. "
+ f"Model '{selected_model}' is available."
+ if selected_model
+ else f"{provider_label} connection verified."
+ ),
+ "models": models[:500],
+ "model_available": model_available,
+ }
+
def _normalize_base_url(base_url: Optional[str]) -> str:
url = (base_url or "http://localhost:11434").strip()
@@ -126,6 +272,114 @@ def _extract_model_id(model: object) -> Optional[str]:
return None
+def _safe_provider_error_text(response: requests.Response, api_key: str) -> str:
+ try:
+ payload = response.json()
+ text = json.dumps(payload, ensure_ascii=False)
+ except ValueError:
+ text = response.text
+ if api_key:
+ text = text.replace(api_key, "[redacted]")
+ return text[:1000]
+
+
+def _classify_provider_error(
+ provider: str,
+ provider_label: str,
+ error_text: str,
+ status_code: int,
+ model: str = "",
+) -> tuple[str, str, bool]:
+ lowered = error_text.lower()
+ key_url = str(_CLOUD_PROVIDER_CONFIG.get(provider, {}).get("key_url", ""))
+ if any(
+ marker in lowered
+ for marker in (
+ "incorrect api key",
+ "invalid api key",
+ "invalid_api_key",
+ "authentication_error",
+ "unauthorized",
+ )
+ ) or status_code == 401:
+ extra = (
+ " A ChatGPT subscription does not include OpenAI API usage."
+ if provider == "openai"
+ else ""
+ )
+ return (
+ "invalid_key",
+ (
+ f"{provider_label} rejected this API key before processing the transcript. "
+ f"The request did reach {provider_label}, but no completion tokens were used."
+ f"{extra} Create a new API key at {key_url} and test it in Settings."
+ ),
+ False,
+ )
+ if status_code == 403 or any(marker in lowered for marker in ("permission", "forbidden", "acl")):
+ permission_hint = (
+ " Make sure the key has access to the Models and Chat endpoints and to the selected model."
+ if provider == "xai"
+ else ""
+ )
+ return (
+ "permission_denied",
+ f"{provider_label} recognized the key but denied access.{permission_hint}",
+ True,
+ )
+ if any(
+ marker in lowered
+ for marker in (
+ "model_not_found",
+ "model not found",
+ "does not exist",
+ "not have access to model",
+ )
+ ):
+ model_label = f" '{model}'" if model else ""
+ return (
+ "model_unavailable",
+ (
+ f"{provider_label} accepted the key, but model{model_label} is not available. "
+ "Open Settings, test the connection, and choose a returned model."
+ ),
+ True,
+ )
+ if status_code == 429 or any(marker in lowered for marker in ("quota", "billing", "rate limit")):
+ return (
+ "quota_or_rate_limit",
+ (
+ f"{provider_label} accepted the request but the API account has no available "
+ "quota, billing, or rate-limit capacity."
+ ),
+ True,
+ )
+ return (
+ "provider_error",
+ f"{provider_label} rejected the request (HTTP {status_code}). Test the connection in Settings.",
+ False,
+ )
+
+
+def _friendly_completion_error(
+ provider: str,
+ provider_name: str,
+ error: Exception,
+ model: str,
+) -> str:
+ status_code = int(getattr(error, "status_code", 0) or 0)
+ code, message, _authenticated = _classify_provider_error(
+ provider,
+ provider_name,
+ str(error),
+ status_code,
+ model,
+ )
+ if code != "provider_error":
+ return message
+ return f"{provider_name} request failed. Test the active provider in Settings and try again."
+
+
def _ollama_complete(prompt: str, model: str, base_url: str, system_prompt: Optional[str], temperature: float) -> str:
base_url = _normalize_base_url(base_url)
body = {
@@ -147,7 +401,16 @@ def _ollama_complete(prompt: str, model: str, base_url: str, system_prompt: Opti
def _openai_complete(prompt: str, model: str, api_key: str, system_prompt: Optional[str], temperature: float) -> str:
- return _openai_compatible_complete(prompt, model, api_key, None, system_prompt, temperature, "OpenAI")
+ return _openai_compatible_complete(
+ prompt,
+ model,
+ api_key,
+ None,
+ system_prompt,
+ temperature,
+ "OpenAI",
+ "openai",
+ )
def _openai_compatible_complete(
@@ -158,6 +421,7 @@ def _openai_compatible_complete(
system_prompt: Optional[str],
temperature: float,
provider_name: str,
+ provider: str = "openai",
) -> str:
try:
from openai import OpenAI
@@ -177,8 +441,9 @@ def _openai_compatible_complete(
)
return response.choices[0].message.content.strip()
except Exception as e:
- logger.error(f"{provider_name} error: {e}")
- raise
+ friendly_error = _friendly_completion_error(provider, provider_name, e, model)
+ logger.error("%s request failed: %s", provider_name, friendly_error)
+ raise RuntimeError(friendly_error) from e
def _nine_router_complete(
diff --git a/backend/services/transcription.py b/backend/services/transcription.py
index be94e24..795f10c 100644
--- a/backend/services/transcription.py
+++ b/backend/services/transcription.py
@@ -160,14 +160,22 @@ def _resolve_engine(engine: TranscriptionEngine) -> TranscriptionEngine:
raise RuntimeError(f"Unknown transcription engine: {engine}")
if engine == "parakeet" and not NEMO_AVAILABLE:
raise RuntimeError(
- "Parakeet TDT v3 is not available. Install NVIDIA NeMo ASR dependencies or choose WhisperX/Whisper."
+ "Parakeet TDT v3 is not included in this ScriptCut build. "
+ "Choose Faster Whisper; its speech model downloads automatically on first use."
)
if engine == "faster-whisper" and not FASTER_WHISPER_AVAILABLE:
raise RuntimeError("faster-whisper is not installed. Run the standard backend setup.")
if engine == "whisperx" and not WHISPERX_AVAILABLE:
- raise RuntimeError("WhisperX is not installed. Install whisperx or choose another transcription engine.")
+ raise RuntimeError(
+ "WhisperX is not included in this ScriptCut build. Downloading a Whisper model "
+ "manually will not install WhisperX. Choose Faster Whisper; its selected model "
+ "downloads automatically on first use."
+ )
if engine == "whisper" and not WHISPER_AVAILABLE:
- raise RuntimeError("OpenAI Whisper is not installed. Install openai-whisper or choose another transcription engine.")
+ raise RuntimeError(
+ "Legacy Whisper is not included in this ScriptCut build. Choose Faster Whisper; "
+ "its selected model downloads automatically on first use."
+ )
return engine
if NEMO_AVAILABLE:
return "parakeet"
@@ -214,29 +222,57 @@ def get_transcription_engine_status() -> dict:
"engines": {
"faster-whisper": {
"available": FASTER_WHISPER_AVAILABLE,
+ "selectable": FASTER_WHISPER_AVAILABLE,
"default_model": "base",
"label": "Faster Whisper word timestamps",
"first_class": True,
+ "download_behavior": "Selected speech model downloads automatically on first use.",
+ "unavailable_reason": (
+ None
+ if FASTER_WHISPER_AVAILABLE
+ else "The core transcription package is missing from this installation."
+ ),
},
"parakeet": {
"available": NEMO_AVAILABLE,
+ "selectable": NEMO_AVAILABLE,
"default_model": PARAKEET_DEFAULT_MODEL,
"label": "Parakeet TDT v3 multilingual",
"first_class": True,
"languages": 25,
"install_hint": "pip install -U nemo_toolkit['asr']",
+ "download_behavior": "Optional engine; not installed by downloading a speech model.",
+ "unavailable_reason": (
+ None
+ if NEMO_AVAILABLE
+ else "Not included in this desktop build. Use Faster Whisper."
+ ),
},
"whisperx": {
"available": WHISPERX_AVAILABLE,
+ "selectable": WHISPERX_AVAILABLE,
"default_model": "base",
"label": "WhisperX aligned",
"first_class": True,
+ "download_behavior": "Optional engine; not installed by downloading a Whisper model.",
+ "unavailable_reason": (
+ None
+ if WHISPERX_AVAILABLE
+ else "Not included in this desktop build. Use Faster Whisper."
+ ),
},
"whisper": {
"available": WHISPER_AVAILABLE,
+ "selectable": WHISPER_AVAILABLE,
"default_model": "base",
"label": "Whisper fallback",
"first_class": True,
+ "download_behavior": "Optional legacy engine.",
+ "unavailable_reason": (
+ None
+ if WHISPER_AVAILABLE
+ else "Not included in this desktop build. Use Faster Whisper."
+ ),
},
},
}
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index bfcf941..b189243 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -96,6 +96,39 @@ ollama list
Cloud providers require valid API keys. ScriptCut keeps provider settings local.
+### Grok/OpenAI says the API key is incorrect
+
+Update to ScriptCut 0.1.2 or newer, then:
+
+1. Open **More → Settings**.
+2. Select the provider that should be used by AI Editor.
+3. Enter its API key and model.
+4. Click **Test connection**.
+
+Only the selected provider is used. Saving both an OpenAI key and an xAI key
+does not send the same request to both providers.
+
+The connection test reads the models available to the key. It does not send
+transcript text and does not use completion tokens. If xAI reports an incorrect
+key, the request reached xAI but was rejected before model processing, so it may
+not appear as billable usage. xAI keys also need access to the Models and Chat
+endpoints and to the selected model.
+
+A ChatGPT Plus/Pro subscription and OpenAI API billing are separate. Create an
+API key in the OpenAI API platform and make sure the API account has billing or
+credits available.
+
+### WhisperX or another transcription engine does not download
+
+Update to ScriptCut 0.1.2 or newer. The desktop build includes Faster Whisper
+and disables optional engines that are not installed. WhisperX is a separate
+program dependency; downloading a `medium` model by itself cannot install it.
+
+For the normal Windows build choose **Faster Whisper** and then `base`, `small`,
+or `medium`. The selected speech model downloads automatically on the first
+transcription, so no manual model installation is required. The first run can
+remain on the model-loading message while the download finishes.
+
## Background Removal Is Disabled
Background removal requires optional Python packages such as MediaPipe and OpenCV. Check availability in the export panel or by running:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7a8d40f..3821450 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "scriptcut-frontend",
- "version": "0.1.1",
+ "version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scriptcut-frontend",
- "version": "0.1.1",
+ "version": "0.1.2",
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.0.0",
diff --git a/frontend/package.json b/frontend/package.json
index daac054..26ec619 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "scriptcut-frontend",
"private": true,
- "version": "0.1.1",
+ "version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 42dfe44..702a6e8 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -53,11 +53,14 @@ type TranscriptionEngineStatus = {
default_model?: string;
engines?: Record
{aiActionError}
++ Откройте Settings → активный провайдер → Test connection. Повторная транскрибация не нужна. +
+{children}
; } +function CloudConnectionControls({ + active, + loading, + status, + keyUrl, + providerLabel, + onActivate, + onTest, +}: { + active: boolean; + loading: boolean; + status?: AIProviderConnectionCheck; + keyUrl: string; + providerLabel: string; + onActivate: () => void; + onTest: () => void; +}) { + return ( + + ); +} + function SetupCommands({ commands, copiedCommand, @@ -548,13 +703,18 @@ function InputField({ onChange, placeholder, type = 'text', + suggestions, }: { label: string; value: string; onChange: (value: string) => void; placeholder: string; type?: string; + suggestions?: string[]; }) { + const listId = suggestions?.length + ? `models-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${placeholder.replace(/[^a-z0-9]+/gi, '-')}` + : undefined; return (