diff --git a/addon/globalPlugins/AIHub/__init__.py b/addon/globalPlugins/AIHub/__init__.py index 865422b..d1ebb58 100644 --- a/addon/globalPlugins/AIHub/__init__.py +++ b/addon/globalPlugins/AIHub/__init__.py @@ -76,9 +76,11 @@ def getClient(self): if not manager.isReady(): continue api_key = manager.get_api_key() - base_url = manager.get_base_url() or BASE_URLs[manager.provider] + base_url = manager.get_base_url() or BASE_URLs.get(manager.provider) + if base_url: + base_url = base_url.rstrip("/") if provider not in _OPTIONAL_API_KEY_PROVIDERS and (not api_key or not api_key.strip()): - return None + continue organization = manager.get_api_key(use_org=True) org_val = organization.split(":=", 1)[1] if organization and organization.count(":=") == 1 else None return OpenAIClient(api_key=api_key, base_url=base_url, organization=org_val) diff --git a/addon/globalPlugins/AIHub/apiclient/_client.py b/addon/globalPlugins/AIHub/apiclient/_client.py index 159eec8..dadbec7 100644 --- a/addon/globalPlugins/AIHub/apiclient/_client.py +++ b/addon/globalPlugins/AIHub/apiclient/_client.py @@ -58,7 +58,7 @@ class OpenAIClient: def __init__( self, - api_key: str, + api_key: Optional[str] = "", base_url: str = "https://api.openai.com/v1", organization: Optional[str] = None, ): @@ -436,7 +436,8 @@ def _audio_text_create( body, content_type = _build_audio_text_body(file, model, response_format, kwargs) headers = _build_headers(self.api_key, self.organization) headers["Content-Type"] = content_type - url = f"{self.base_url}{endpoint}" + base = (self.base_url or "").rstrip("/") + url = f"{base}{endpoint}" req = urllib.request.Request(url, data=body, headers=headers, method="POST") raw, _ct = _open_bytes(self._opener, req, timeout=120) text = raw.decode("utf-8", errors="replace") @@ -560,7 +561,10 @@ def configure_client_for_provider( if clone and hasattr(client, "clone"): client = client.clone() manager = apikeymanager.get(provider) - client.base_url = manager.get_base_url(account_id=account_id) or BASE_URLs[provider] + base_url = manager.get_base_url(account_id=account_id) or BASE_URLs.get(provider) + if base_url: + base_url = base_url.rstrip("/") + client.base_url = base_url api_key = manager.get_api_key(account_id=account_id) # Ollama doesn't require an API key; supply a placeholder so the Bearer header is set # (some Ollama-compatible reverse proxies reject empty Authorization headers). diff --git a/addon/globalPlugins/AIHub/apikeymanager.py b/addon/globalPlugins/AIHub/apikeymanager.py index b427f83..51c3591 100644 --- a/addon/globalPlugins/AIHub/apikeymanager.py +++ b/addon/globalPlugins/AIHub/apikeymanager.py @@ -324,7 +324,7 @@ def add_account(self, name, api_key, org_name="", org_key="", base_url="", set_a account["base_url"] = _normalize_ollama_base_url((base_url or "").strip()) else: account["base_url"] = None - if self.provider != Provider.Ollama and not account["api_key"]: + if self.provider not in _USER_ENDPOINT_PROVIDERS and not account["api_key"]: raise ValueError("API key is required") bucket.setdefault("accounts", []).append(account) if set_active or not bucket.get("active_account_id"): diff --git a/addon/globalPlugins/AIHub/configspec.py b/addon/globalPlugins/AIHub/configspec.py index 89ac7a0..2709a62 100644 --- a/addon/globalPlugins/AIHub/configspec.py +++ b/addon/globalPlugins/AIHub/configspec.py @@ -43,6 +43,8 @@ "voxtralModel": f"string(default={VOXTRAL_MODELS[0]})", "openaiTranscriptionAccountId": 'string(default="")', "mistralTranscriptionAccountId": 'string(default="")', + "customOpenAITranscriptionAccountId": 'string(default="")', + "customOpenAITranscriptionModel": f"string(default={WHISPER_MODELS[0]})", "whisper.cpp": { "enabled": "boolean(default=False)", "host": "string(default='http://127.0.0.1:8081')" diff --git a/addon/globalPlugins/AIHub/consts.py b/addon/globalPlugins/AIHub/consts.py index 234e142..bb20380 100644 --- a/addon/globalPlugins/AIHub/consts.py +++ b/addon/globalPlugins/AIHub/consts.py @@ -138,6 +138,7 @@ class TranscriptionProvider(StrEnum): WHISPER_CPP = auto() OPENAI = auto() MISTRAL = auto() + CUSTOM_OPENAI = auto() TRANSCRIPTION_PROVIDERS = tuple(p.value for p in TranscriptionProvider) diff --git a/addon/globalPlugins/AIHub/conversation_dialog.py b/addon/globalPlugins/AIHub/conversation_dialog.py index f795ee8..a1a412c 100644 --- a/addon/globalPlugins/AIHub/conversation_dialog.py +++ b/addon/globalPlugins/AIHub/conversation_dialog.py @@ -2694,6 +2694,18 @@ def _resolveDictationConfig(self): and ("voxtral" in model_id.lower() or "transcribe" in model_id.lower()) ): transcription_model = model_id + elif transcription_provider == TranscriptionProvider.CUSTOM_OPENAI: + transcription_account_id = self.conf["audio"].get("customOpenAITranscriptionAccountId", "").strip() + if not transcription_account_id and account and account.get("provider") == Provider.CustomOpenAI: + transcription_account_id = account.get("id") + model_id = getattr(model, "id", "") + if ( + model + and getattr(model, "provider", "") == Provider.CustomOpenAI + and isinstance(model_id, str) + and (model_id == "whisper-1" or "transcribe" in model_id.lower() or "whisper" in model_id.lower()) + ): + transcription_model = model_id return { "use_direct": use_direct, "transcription_provider": transcription_provider, diff --git a/addon/globalPlugins/AIHub/conversations.py b/addon/globalPlugins/AIHub/conversations.py index 2fccb07..a17bc9e 100644 --- a/addon/globalPlugins/AIHub/conversations.py +++ b/addon/globalPlugins/AIHub/conversations.py @@ -51,6 +51,7 @@ def _generate_next_value_(name, start, count, last_values): "TOOL_MISTRAL_SPEECH_TO_TEXT": "tool/mistral/speech-to-text", "TOOL_OPENAI_TTS": "tool/openai/tts", "TOOL_OPENAI_TRANSCRIPTION": "tool/openai/transcription", + "TOOL_CUSTOM_OPENAI_TRANSCRIPTION": "tool/customopenai/transcription", } return mapping[name] @@ -61,6 +62,7 @@ def _generate_next_value_(name, start, count, last_values): TOOL_MISTRAL_SPEECH_TO_TEXT = auto() TOOL_OPENAI_TTS = auto() TOOL_OPENAI_TRANSCRIPTION = auto() + TOOL_CUSTOM_OPENAI_TRANSCRIPTION = auto() def normalize_conversation_format(value) -> ConversationFormat: diff --git a/addon/globalPlugins/AIHub/conversations_manager_dialog.py b/addon/globalPlugins/AIHub/conversations_manager_dialog.py index c459d73..8068752 100644 --- a/addon/globalPlugins/AIHub/conversations_manager_dialog.py +++ b/addon/globalPlugins/AIHub/conversations_manager_dialog.py @@ -20,7 +20,7 @@ from .tool_lyria_dialog import Lyria3ProToolDialog from .tool_mistral_ocr_dialog import MistralOCRToolDialog from .tool_mistral_transcription_dialog import MistralSpeechToTextToolDialog -from .tool_openai_transcription_dialog import OpenAITranscriptionToolDialog +from .tool_openai_transcription_dialog import OpenAITranscriptionToolDialog, CustomOpenAITranscriptionToolDialog from .tool_openai_tts_dialog import OpenAITTSToolDialog from .toolsmenu import open_tool_dialog_by_class from .tool_voxtral_tts_dialog import VoxtralTTSToolDialog @@ -768,6 +768,7 @@ def _open_selected_entries(self, selected_entries): ConversationFormat.TOOL_GOOGLE_LYRIA_PRO: Lyria3ProToolDialog, ConversationFormat.TOOL_OPENAI_TTS: OpenAITTSToolDialog, ConversationFormat.TOOL_OPENAI_TRANSCRIPTION: OpenAITranscriptionToolDialog, + ConversationFormat.TOOL_CUSTOM_OPENAI_TRANSCRIPTION: CustomOpenAITranscriptionToolDialog, } for data in loaded: conv_format = normalize_conversation_format(data.get("format", ConversationFormat.GENERIC.value)) diff --git a/addon/globalPlugins/AIHub/plugin_mixins.py b/addon/globalPlugins/AIHub/plugin_mixins.py index afa784d..6b44cf7 100644 --- a/addon/globalPlugins/AIHub/plugin_mixins.py +++ b/addon/globalPlugins/AIHub/plugin_mixins.py @@ -324,9 +324,21 @@ def script_askQuestion(self, gesture): from .model import getModels from .recordthread import RecordThread from . import conversation_dialog + from .transcription import get_transcription_provider + from .consts import TranscriptionProvider + from .apiclient import OpenAIClient, configure_client_for_provider - if not self.getClient(): - return ui.message(NO_AUTHENTICATION_KEY_PROVIDED_MSG) + client = self.getClient() + if not client: + provider = get_transcription_provider(conf.get("audio", {})) + if provider == TranscriptionProvider.WHISPER_CPP.value: + client = object() + elif provider == TranscriptionProvider.CUSTOM_OPENAI.value and apikeymanager.get(Provider.CustomOpenAI).isReady(): + client = configure_client_for_provider(OpenAIClient(), Provider.CustomOpenAI) + elif provider == TranscriptionProvider.MISTRAL.value and apikeymanager.get(Provider.MistralAI).isReady(): + client = object() + else: + return ui.message(NO_AUTHENTICATION_KEY_PROVIDED_MSG) if self._askAudioPlaying: mci_stop_ask_audio() self._askAudioPlaying = False @@ -393,9 +405,21 @@ def script_askQuestion(self, gesture): def script_toggleRecording(self, gesture): from . import conversation_dialog from .recordthread import RecordThread + from .transcription import get_transcription_provider + from .consts import TranscriptionProvider + from .apiclient import OpenAIClient, configure_client_for_provider - if not self.getClient(): - return ui.message(NO_AUTHENTICATION_KEY_PROVIDED_MSG) + client = self.getClient() + if not client: + provider = get_transcription_provider(conf.get("audio", {})) + if provider == TranscriptionProvider.WHISPER_CPP.value: + client = object() + elif provider == TranscriptionProvider.CUSTOM_OPENAI.value and apikeymanager.get(Provider.CustomOpenAI).isReady(): + client = configure_client_for_provider(OpenAIClient(), Provider.CustomOpenAI) + elif provider == TranscriptionProvider.MISTRAL.value and apikeymanager.get(Provider.MistralAI).isReady(): + client = object() + else: + return ui.message(NO_AUTHENTICATION_KEY_PROVIDED_MSG) dlg = conversation_dialog.activeChatDlg if dlg and dlg.IsShown() and hasattr(dlg, "_prompt_ctrl_has_focus") and dlg._prompt_ctrl_has_focus(): dlg.onRecord(None) @@ -404,5 +428,5 @@ def script_toggleRecording(self, gesture): stop_worker_thread(self.recordThread) self.recordThread = None return - self.recordThread = RecordThread(self.getClient(), conf=conf["audio"]) + self.recordThread = RecordThread(client, conf=conf["audio"]) self.recordThread.start() diff --git a/addon/globalPlugins/AIHub/recordthread.py b/addon/globalPlugins/AIHub/recordthread.py index 541cce4..07d6875 100644 --- a/addon/globalPlugins/AIHub/recordthread.py +++ b/addon/globalPlugins/AIHub/recordthread.py @@ -24,6 +24,7 @@ from . import apikeymanager from .apiclient import ( + OpenAIClient, Transcription, transcribe_audio_mistral, configure_client_for_provider, @@ -57,16 +58,19 @@ def transcribe_audio_file(path, conf, client=None): return None provider = get_transcription_provider(conf) if provider == TranscriptionProvider.WHISPER_CPP: - rt = RecordThread(client or object(), conf=conf) + rt = RecordThread(client or object(), conf=conf, transcriptionProvider=provider) result = rt._transcribe_whisper_cpp(path) elif provider == TranscriptionProvider.MISTRAL: - rt = RecordThread(client or object(), conf=conf) + rt = RecordThread(client or object(), conf=conf, transcriptionProvider=provider) result = rt._transcribe_mistral(path) + elif provider == TranscriptionProvider.CUSTOM_OPENAI: + rt = RecordThread(client or OpenAIClient(), conf=conf, transcriptionProvider=provider) + result = rt._transcribe_custom_openai(path) else: if not client: return None client = configure_client_for_provider(client, Provider.OpenAI, clone=True) - rt = RecordThread(client, conf=conf) + rt = RecordThread(client, conf=conf, transcriptionProvider=provider) result = rt._transcribe_openai(path) if result is None: return None @@ -154,6 +158,15 @@ def __init__( self._transcriptionAccountId = apikeymanager.get(Provider.MistralAI).get_active_account_id() if not self._transcriptionModel: self._transcriptionModel = (conf or {}).get("voxtralModel", "voxtral-mini-latest") + elif self._transcriptionProvider == TranscriptionProvider.CUSTOM_OPENAI: + if not self._transcriptionAccountId: + self._transcriptionAccountId = (conf or {}).get("customOpenAITranscriptionAccountId", "") + if self._transcriptionAccountId and not apikeymanager.get(Provider.CustomOpenAI).isReady(account_id=self._transcriptionAccountId): + self._transcriptionAccountId = "" + if not self._transcriptionAccountId: + self._transcriptionAccountId = apikeymanager.get(Provider.CustomOpenAI).get_active_account_id() + if not self._transcriptionModel: + self._transcriptionModel = (conf or {}).get("customOpenAITranscriptionModel", "") or (conf or {}).get("whisperModel", "whisper-1") def run(self): if self.audioFile: @@ -424,6 +437,29 @@ def _transcribe_openai(self, filename): response_format=self.responseFormat, ) + def _transcribe_custom_openai(self, filename): + """Transcribe via Custom OpenAI-compatible endpoint.""" + manager = apikeymanager.get(Provider.CustomOpenAI) + account_id = self._transcriptionAccountId or manager.get_active_account_id() + base_url = manager.get_base_url(account_id=account_id) + if not base_url or not base_url.strip(): + # Translators: Text in recording thread status and error messages. + raise ValueError(_("No Custom OpenAI endpoint URL configured. Please add one in AI-Hub settings.")) + client = self.client if (self.client and hasattr(self.client, "clone")) else OpenAIClient() + client = configure_client_for_provider( + client, + Provider.CustomOpenAI, + account_id=account_id, + clone=True, + ) + model = self._transcriptionModel or (self.conf or {}).get("customOpenAITranscriptionModel", "") or (self.conf or {}).get("whisperModel", "whisper-1") + with open(filename, "rb") as audio_file: + return client.audio.transcriptions.create( + model=model, + file=audio_file, + response_format=self.responseFormat, + ) + def process_transcription(self, filename): if self._wantAbort: return @@ -434,6 +470,8 @@ def process_transcription(self, filename): transcription = self._transcribe_whisper_cpp(filename) elif provider == TranscriptionProvider.MISTRAL: transcription = self._transcribe_mistral(filename) + elif provider == TranscriptionProvider.CUSTOM_OPENAI: + transcription = self._transcribe_custom_openai(filename) else: transcription = self._transcribe_openai(filename) except Exception as err: diff --git a/addon/globalPlugins/AIHub/settings_dialog.py b/addon/globalPlugins/AIHub/settings_dialog.py index f8e626f..87d9660 100644 --- a/addon/globalPlugins/AIHub/settings_dialog.py +++ b/addon/globalPlugins/AIHub/settings_dialog.py @@ -178,6 +178,8 @@ def makeSettings(self, settingsSizer): _("OpenAI Whisper"), # Translators: NVDA Preferences — AI-Hub category: Recording — Transcription provider list — Mistral Voxtral option. _("Mistral Voxtral"), + # Translators: NVDA Preferences — AI-Hub category: Recording — Transcription provider list — Custom OpenAI option. + _("Custom OpenAI"), ] self.transcriptionProviderChoice = providerGroup.addLabeledControl( # Translators: NVDA Preferences — AI-Hub category: Recording — Provider — which engine handles dictation transcription. @@ -224,6 +226,12 @@ def makeSettings(self, settingsSizer): wx.Choice, choices=[], ) + self.customOpenAITranscriptionAccountChoice = accountsMapGroup.addLabeledControl( + # Translators: NVDA Preferences — AI-Hub category: Recording — Provider account mapping — Custom OpenAI account drop-down label. + _("Custom OpenAI transcription account:"), + wx.Choice, + choices=[], + ) self._refreshTranscriptionAccountChoices() recordingGroup.addItem(accountsMapSizer) @@ -285,6 +293,7 @@ def _refreshTranscriptionAccountChoices(self): audio_conf = conf.get("audio", {}) openai_id = audio_conf.get("openaiTranscriptionAccountId", "") mistral_id = audio_conf.get("mistralTranscriptionAccountId", "") + custom_openai_id = audio_conf.get("customOpenAITranscriptionAccountId", "") labels, ids, selected_idx = self._buildTranscriptionAccountChoices(Provider.OpenAI, openai_id) self._openaiTranscriptionAccountIds = ids self.openaiTranscriptionAccountChoice.SetItems(labels) @@ -293,6 +302,11 @@ def _refreshTranscriptionAccountChoices(self): self._mistralTranscriptionAccountIds = ids self.mistralTranscriptionAccountChoice.SetItems(labels) self.mistralTranscriptionAccountChoice.SetSelection(selected_idx) + if hasattr(self, "customOpenAITranscriptionAccountChoice"): + labels, ids, selected_idx = self._buildTranscriptionAccountChoices(Provider.CustomOpenAI, custom_openai_id) + self._customOpenAITranscriptionAccountIds = ids + self.customOpenAITranscriptionAccountChoice.SetItems(labels) + self.customOpenAITranscriptionAccountChoice.SetSelection(selected_idx) def onManageAccounts(self, evt): from .accounts_dialog import show_accounts_management @@ -306,12 +320,12 @@ def onResize(self, evt): def onTranscriptionProviderChange(self, evt): idx = self.transcriptionProviderChoice.GetSelection() - is_whisper_cpp = idx == 0 - is_openai = idx == 1 - is_mistral = idx == 2 - self.whisperHost.Enable(is_whisper_cpp) - self.openaiTranscriptionAccountChoice.Enable(is_openai) - self.mistralTranscriptionAccountChoice.Enable(is_mistral) + provider = TRANSCRIPTION_PROVIDERS[idx] if 0 <= idx < len(TRANSCRIPTION_PROVIDERS) else TranscriptionProvider.OPENAI.value + self.whisperHost.Enable(provider == TranscriptionProvider.WHISPER_CPP.value) + self.openaiTranscriptionAccountChoice.Enable(provider == TranscriptionProvider.OPENAI.value) + self.mistralTranscriptionAccountChoice.Enable(provider == TranscriptionProvider.MISTRAL.value) + if hasattr(self, "customOpenAITranscriptionAccountChoice"): + self.customOpenAITranscriptionAccountChoice.Enable(provider == TranscriptionProvider.CUSTOM_OPENAI.value) def onTrimSilenceChange(self, evt): self.minSilenceSec.Enable(self.trimSilenceCheckbox.GetValue()) @@ -359,6 +373,13 @@ def onSave(self): if 0 <= mistral_idx < len(getattr(self, "_mistralTranscriptionAccountIds", [])) else "" ) + if hasattr(self, "customOpenAITranscriptionAccountChoice"): + custom_idx = self.customOpenAITranscriptionAccountChoice.GetSelection() + conf["audio"]["customOpenAITranscriptionAccountId"] = ( + self._customOpenAITranscriptionAccountIds[custom_idx] + if 0 <= custom_idx < len(getattr(self, "_customOpenAITranscriptionAccountIds", [])) + else "" + ) conf["audio"]["trimSilence"] = self.trimSilenceCheckbox.GetValue() conf["audio"]["minSilenceSec"] = int(self.minSilenceSec.GetValue()) for key, item in self.chatFeedback.items(): diff --git a/addon/globalPlugins/AIHub/tool_openai_transcription_dialog.py b/addon/globalPlugins/AIHub/tool_openai_transcription_dialog.py index daeac55..9c046cf 100644 --- a/addon/globalPlugins/AIHub/tool_openai_transcription_dialog.py +++ b/addon/globalPlugins/AIHub/tool_openai_transcription_dialog.py @@ -9,7 +9,14 @@ import wx from logHandler import log -from .apiclient import APIConnectionError, APIStatusError, Transcription +from . import apikeymanager +from .apiclient import ( + APIConnectionError, + APIStatusError, + OpenAIClient, + Transcription, + configure_client_for_provider, +) from .conversations import ConversationFormat from .consts import ( Provider, @@ -41,16 +48,18 @@ class OpenAITranscriptionToolDialog(ToolDialogBase): "gpt-4o-transcribe-diarize", ) - def __init__(self, parent, conversationData=None, parentDialog=None, plugin=None): + def __init__(self, parent, conversationData=None, parentDialog=None, plugin=None, provider=Provider.OpenAI): + self.provider = provider + title = _("Tool: Custom OpenAI Transcription") if provider == Provider.CustomOpenAI else _("Tool: OpenAI Transcription") super().__init__( parent, - # Translators: Window title of the AI-Hub OpenAI Transcription / Translation tool dialog. - title=_("Tool: OpenAI Transcription"), - provider=Provider.OpenAI, + title=title, + provider=provider, size=(860, 880), parentDialog=parentDialog, plugin=plugin, ) + self._accounts = self._load_transcription_accounts() self._worker = None self._textResultPath = "" self._rawResultPath = "" @@ -166,6 +175,61 @@ def _build_task_choice(): self._refreshTaskDependentControls() self._syncOpenButtons() + def _load_transcription_accounts(self): + accounts = [] + primary_provider = self.provider or Provider.OpenAI + try: + primary_mgr = apikeymanager.get(primary_provider) + for acc in primary_mgr.list_accounts(include_env=(primary_provider == Provider.OpenAI)): + a = dict(acc) + a["_provider"] = primary_provider + accounts.append(a) + except Exception: + pass + alt_provider = Provider.CustomOpenAI if primary_provider == Provider.OpenAI else Provider.OpenAI + try: + alt_mgr = apikeymanager.get(alt_provider) + for acc in alt_mgr.list_accounts(include_env=(alt_provider == Provider.OpenAI)): + a = dict(acc) + a["_provider"] = alt_provider + accounts.append(a) + except Exception: + pass + return accounts + + def build_account_choice(self, parent): + labels = [] + has_multiple_providers = len({acc.get("_provider") for acc in self._accounts}) > 1 + for acc in self._accounts: + name = acc.get("name", "Account") + p = acc.get("_provider", self.provider) + label = f"{name} ({p})" if has_multiple_providers else name + labels.append(label) + choice = wx.Choice(parent, choices=labels or [_("No account configured")]) + if labels: + active_id = self.manager.get_active_account_id() if self.manager else None + idx = 0 + for i, acc in enumerate(self._accounts): + if acc.get("id") == active_id and acc.get("_provider") == self.provider: + idx = i + break + choice.SetSelection(idx) + else: + choice.SetSelection(0) + choice.Disable() + return choice + + def configure_client(self, account_id): + if self.client is None: + self.client = OpenAIClient() + selected_acc = None + for acc in self._accounts: + if acc.get("id") == account_id: + selected_acc = acc + break + provider = selected_acc.get("_provider", self.provider) if selected_acc else self.provider + self.client = configure_client_for_provider(self.client, provider, account_id=account_id, clone=True) + def _getTaskValue(self) -> str: return "translation" if self.taskChoice.GetSelection() == 1 else "transcription" @@ -475,3 +539,15 @@ def onRun(self, evt): daemon=True, ) self._worker.start() + + +class CustomOpenAITranscriptionToolDialog(OpenAITranscriptionToolDialog): + def __init__(self, parent, conversationData=None, parentDialog=None, plugin=None): + super().__init__( + parent, + conversationData=conversationData, + parentDialog=parentDialog, + plugin=plugin, + provider=Provider.CustomOpenAI, + ) + diff --git a/addon/globalPlugins/AIHub/toolsmenu.py b/addon/globalPlugins/AIHub/toolsmenu.py index c0b7ed1..2155e37 100644 --- a/addon/globalPlugins/AIHub/toolsmenu.py +++ b/addon/globalPlugins/AIHub/toolsmenu.py @@ -19,6 +19,7 @@ class ToolId(StrEnum): LYRIA_3_PRO = auto() OPENAI_TTS = auto() OPENAI_TRANSCRIPTION = auto() + CUSTOM_OPENAI_TRANSCRIPTION = auto() OLLAMA_MODEL_MANAGER = auto() @@ -85,6 +86,15 @@ class ToolId(StrEnum): "dialog_module": ".tool_openai_transcription_dialog", "dialog_class": "OpenAITranscriptionToolDialog", }, + { + "id": ToolId.CUSTOM_OPENAI_TRANSCRIPTION, + # Translators: AI-Hub Tools menu: label for a submenu or action that opens a provider tool. + "label": _("Transcription / Translation..."), + "group_label": Provider.CustomOpenAI, + "manager_provider": Provider.CustomOpenAI, + "dialog_module": ".tool_openai_transcription_dialog", + "dialog_class": "CustomOpenAITranscriptionToolDialog", + }, { "id": ToolId.OLLAMA_MODEL_MANAGER, # Translators: AI-Hub Tools menu: label for a submenu or action that opens a provider tool. @@ -98,7 +108,7 @@ class ToolId(StrEnum): # Order of the per-vendor submenus in the Tools menu. -_GROUP_ORDER = (Provider.OpenAI, _GROUP_MISTRAL, Provider.Google, Provider.Ollama) +_GROUP_ORDER = (Provider.OpenAI, _GROUP_MISTRAL, Provider.Google, Provider.Ollama, Provider.CustomOpenAI) _OPEN_TOOL_DIALOGS = [] diff --git a/addon/globalPlugins/AIHub/transcription.py b/addon/globalPlugins/AIHub/transcription.py index e1071b5..db48a20 100644 --- a/addon/globalPlugins/AIHub/transcription.py +++ b/addon/globalPlugins/AIHub/transcription.py @@ -1,6 +1,15 @@ """Reusable transcription helpers: provider resolution, text extraction.""" -from .consts import TranscriptionProvider +import addonHandler +from . import apikeymanager +from .consts import ( + BASE_URLs, + Provider, + TranscriptionProvider, + TRANSCRIPTION_PROVIDERS, +) + +addonHandler.initTranslation() def get_transcription_provider(conf: dict) -> str: @@ -10,7 +19,115 @@ def get_transcription_provider(conf: dict) -> str: """ if conf.get("whisper.cpp", {}).get("enabled", False): return TranscriptionProvider.WHISPER_CPP.value - return conf.get("transcriptionProvider", TranscriptionProvider.OPENAI.value) + val = conf.get("transcriptionProvider", TranscriptionProvider.OPENAI.value) + if isinstance(val, str): + val_norm = val.lower().replace("-", "_") + if val_norm in (TranscriptionProvider.CUSTOM_OPENAI.value, "customopenai", "custom_openai"): + return TranscriptionProvider.CUSTOM_OPENAI.value + if val_norm in (TranscriptionProvider.MISTRAL.value, "mistralai"): + return TranscriptionProvider.MISTRAL.value + if val_norm == TranscriptionProvider.OPENAI.value: + return TranscriptionProvider.OPENAI.value + if val_norm in (TranscriptionProvider.WHISPER_CPP.value, "whisper_cpp", "whispercpp"): + return TranscriptionProvider.WHISPER_CPP.value + return val + + +def list_transcription_providers() -> tuple[str, ...]: + """Return the list of all supported transcription provider identifiers.""" + return TRANSCRIPTION_PROVIDERS + + +def get_transcription_provider_choices() -> list[tuple[str, str]]: + """Return (provider_id, display_label) tuples for UI controls.""" + return [ + # Translators: NVDA Preferences — AI-Hub category: Recording — Transcription provider list — local whisper.cpp option. + (TranscriptionProvider.WHISPER_CPP.value, _("whisper.cpp (local)")), + # Translators: NVDA Preferences — AI-Hub category: Recording — Transcription provider list — OpenAI Whisper option. + (TranscriptionProvider.OPENAI.value, _("OpenAI Whisper")), + # Translators: NVDA Preferences — AI-Hub category: Recording — Transcription provider list — Mistral Voxtral option. + (TranscriptionProvider.MISTRAL.value, _("Mistral Voxtral")), + # Translators: NVDA Preferences — AI-Hub category: Recording — Transcription provider list — Custom OpenAI option. + (TranscriptionProvider.CUSTOM_OPENAI.value, _("Custom OpenAI")), + ] + + +def resolve_transcription_endpoint(conf: dict, provider: str | None = None) -> dict: + """Resolve endpoint parameters (provider, account_id, base_url, api_key, model) for transcription. + + Accepts either the full AIHub config dict or the 'audio' section dict. + """ + audio_conf = conf.get("audio", conf) if isinstance(conf, dict) else {} + resolved_provider = provider or get_transcription_provider(audio_conf) + + if resolved_provider == TranscriptionProvider.WHISPER_CPP.value: + whisper_host = audio_conf.get("whisper.cpp", {}).get("host", "http://127.0.0.1:8081") + return { + "provider": TranscriptionProvider.WHISPER_CPP.value, + "api_provider": None, + "account_id": None, + "base_url": whisper_host, + "api_key": None, + "model": None, + } + + if resolved_provider == TranscriptionProvider.MISTRAL.value: + manager = apikeymanager.get(Provider.MistralAI) + account_id = audio_conf.get("mistralTranscriptionAccountId", "").strip() + if account_id and not manager.isReady(account_id=account_id): + account_id = "" + if not account_id: + account_id = manager.get_active_account_id() or "" + api_key = manager.get_api_key(account_id=account_id) if account_id else manager.get_api_key() + base_url = BASE_URLs.get(Provider.MistralAI, "https://api.mistral.ai/v1") + model = audio_conf.get("voxtralModel", "voxtral-mini-latest") + return { + "provider": TranscriptionProvider.MISTRAL.value, + "api_provider": Provider.MistralAI, + "account_id": account_id, + "base_url": base_url, + "api_key": api_key, + "model": model, + } + + if resolved_provider == TranscriptionProvider.CUSTOM_OPENAI.value: + manager = apikeymanager.get(Provider.CustomOpenAI) + account_id = audio_conf.get("customOpenAITranscriptionAccountId", "").strip() + if account_id and not manager.isReady(account_id=account_id): + account_id = "" + if not account_id: + account_id = manager.get_active_account_id() or "" + base_url = manager.get_base_url(account_id=account_id) if account_id else manager.get_base_url() + api_key = manager.get_api_key(account_id=account_id) if account_id else manager.get_api_key() + model = audio_conf.get("customOpenAITranscriptionModel", "").strip() or audio_conf.get("whisperModel", "whisper-1") + return { + "provider": TranscriptionProvider.CUSTOM_OPENAI.value, + "api_provider": Provider.CustomOpenAI, + "account_id": account_id, + "base_url": base_url, + "api_key": api_key, + "model": model, + } + + # Default: OpenAI + manager = apikeymanager.get(Provider.OpenAI) + account_id = audio_conf.get("openaiTranscriptionAccountId", "").strip() + if account_id and not manager.isReady(account_id=account_id): + account_id = "" + if not account_id: + account_id = manager.get_active_account_id() or "" + base_url = manager.get_base_url(account_id=account_id) if account_id else manager.get_base_url() + base_url = base_url or BASE_URLs.get(Provider.OpenAI, "https://api.openai.com/v1") + api_key = manager.get_api_key(account_id=account_id) if account_id else manager.get_api_key() + model = audio_conf.get("whisperModel", "whisper-1") + return { + "provider": TranscriptionProvider.OPENAI.value, + "api_provider": Provider.OpenAI, + "account_id": account_id, + "base_url": base_url, + "api_key": api_key, + "model": model, + } def get_transcription_text(transcription) -> str: diff --git a/readme.md b/readme.md index 3240527..8743ad2 100644 --- a/readme.md +++ b/readme.md @@ -27,7 +27,7 @@ Configure **one or more providers** in NVDA **Preferences → Settings → AI-Hu |----------|------| | [OpenAI](https://platform.openai.com/) | GPT and related models; official transcription and TTS tool dialogs | | [DeepSeek](https://www.deepseek.com/) | DeepSeek API (OpenAI-compatible) | -| **Custom OpenAI** | Any OpenAI-compatible HTTP API (custom base URL + key) | +| **Custom OpenAI** | Any OpenAI-compatible HTTP API (custom base URL + key); transcription tool dialog | | **Ollama** | Local models via OpenAI-compatible endpoint; model manager tool | | [Mistral AI](https://mistral.ai/) | Mistral / Pixtral; Voxtral TTS, OCR, and speech-to-text tools | | [OpenRouter](https://openrouter.ai/) | Many third-party models behind one key | @@ -39,7 +39,7 @@ The add-on can pick up API keys from **environment variables** when set (for exa ### Speech-to-text (transcription) backends -For **microphone / file transcription** inside the main flow (not the separate OpenAI transcription tool), you can choose among **whisper_cpp** (local), **openai** (Whisper API), and **mistral**, under the **Audio** section of AI-Hub settings. +For **microphone / file transcription** inside the main flow, you can choose among **whisper_cpp** (local), **openai** (Whisper API), **mistral**, and **custom_openai** (custom OpenAI-compatible endpoint), under the **Audio** section of AI-Hub settings. ## Installation diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..c8b76eb --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests package for AIHub addon.""" diff --git a/tests/test_custom_transcription.py b/tests/test_custom_transcription.py new file mode 100644 index 0000000..7a8d704 --- /dev/null +++ b/tests/test_custom_transcription.py @@ -0,0 +1,531 @@ +"""Unit tests for Custom OpenAI-compatible endpoint transcription. + +Verifies: +1. TranscriptionProvider enum contains CUSTOM_OPENAI. +2. Transcription provider listing and choices functions include Custom OpenAI. +3. Custom endpoint resolution (base URL, API key, model, account mapping). +4. Audio transcription request wiring to custom endpoint URL (/audio/transcriptions). +5. Config specifications and tools menu registry entries. +""" + +import builtins +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Ensure built-in translation is available +builtins._ = lambda s: s + +# Setup mocks for NVDA-specific dependencies before importing addon modules +mock_modules = [ + "addonHandler", + "globalVars", + "winsound", + "config", + "gui", + "gui.guiHelper", + "speech", + "queueHandler", + "controlTypes", + "api", + "core", + "wx", + "logHandler", + "scriptHandler", + "globalPluginHandler", + "ui", + "tones", + "keyboardHandler", + "braille", +] +for mod in mock_modules: + if mod not in sys.modules: + sys.modules[mod] = MagicMock() + +class _GlobalPluginBase: + pass + +class _WxDialog: + def __init__(self, *args, **kwargs): + pass + def Bind(self, *args, **kwargs): + pass + +sys.modules["wx"].Dialog = _WxDialog +sys.modules["wx"].Panel = type("Panel", (), {}) +sys.modules["globalPluginHandler"].GlobalPlugin = _GlobalPluginBase +sys.modules["addonHandler"].initTranslation = MagicMock() + +_mock_conf = MagicMock() +_mock_conf.spec = {} +_mock_conf.__getitem__.side_effect = lambda k: {"audio": {}, "images": {}, "chatFeedback": {}}.get(k, {}) +sys.modules["config"].conf = _mock_conf + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from addon.globalPlugins.AIHub.consts import ( + Provider, + TranscriptionProvider, + TRANSCRIPTION_PROVIDERS, +) +from addon.globalPlugins.AIHub import configspec +from addon.globalPlugins.AIHub import apikeymanager +from addon.globalPlugins.AIHub import transcription +from addon.globalPlugins.AIHub.apiclient import ( + OpenAIClient, + configure_client_for_provider, +) +from addon.globalPlugins.AIHub.recordthread import RecordThread, transcribe_audio_file +from addon.globalPlugins.AIHub.toolsmenu import ToolId, TOOLS_REGISTRY, _GROUP_ORDER +from addon.globalPlugins.AIHub.tool_openai_transcription_dialog import ( + OpenAITranscriptionToolDialog, + CustomOpenAITranscriptionToolDialog, +) +from addon.globalPlugins.AIHub.conversations import ConversationFormat + + +class TestTranscriptionProviderListing(unittest.TestCase): + """Test transcription provider enum and listing functions.""" + + def test_enum_contains_custom_openai(self): + self.assertIn("CUSTOM_OPENAI", TranscriptionProvider.__members__) + self.assertEqual(TranscriptionProvider.CUSTOM_OPENAI.value, "custom_openai") + + def test_transcription_providers_tuple_contains_custom_openai(self): + self.assertIn("custom_openai", TRANSCRIPTION_PROVIDERS) + + def test_list_transcription_providers(self): + providers = transcription.list_transcription_providers() + self.assertIn(TranscriptionProvider.CUSTOM_OPENAI.value, providers) + self.assertIn(TranscriptionProvider.OPENAI.value, providers) + self.assertIn(TranscriptionProvider.MISTRAL.value, providers) + self.assertIn(TranscriptionProvider.WHISPER_CPP.value, providers) + + def test_get_transcription_provider_choices(self): + choices = transcription.get_transcription_provider_choices() + choice_dict = dict(choices) + self.assertIn("custom_openai", choice_dict) + self.assertEqual(choice_dict["custom_openai"], "Custom OpenAI") + self.assertIn("openai", choice_dict) + self.assertIn("mistral", choice_dict) + self.assertIn("whisper_cpp", choice_dict) + + def test_get_transcription_provider_resolution(self): + # Default fallback + self.assertEqual( + transcription.get_transcription_provider({}), + TranscriptionProvider.OPENAI.value, + ) + # Whisper.cpp backward compatibility flag + self.assertEqual( + transcription.get_transcription_provider({"whisper.cpp": {"enabled": True}}), + TranscriptionProvider.WHISPER_CPP.value, + ) + # Exact custom_openai + self.assertEqual( + transcription.get_transcription_provider({"transcriptionProvider": "custom_openai"}), + TranscriptionProvider.CUSTOM_OPENAI.value, + ) + # Casing / format variations + self.assertEqual( + transcription.get_transcription_provider({"transcriptionProvider": "CustomOpenAI"}), + TranscriptionProvider.CUSTOM_OPENAI.value, + ) + self.assertEqual( + transcription.get_transcription_provider({"transcriptionProvider": "customopenai"}), + TranscriptionProvider.CUSTOM_OPENAI.value, + ) + self.assertEqual( + transcription.get_transcription_provider({"transcriptionProvider": "CUSTOM-OPENAI"}), + TranscriptionProvider.CUSTOM_OPENAI.value, + ) + # Mistral variations + self.assertEqual( + transcription.get_transcription_provider({"transcriptionProvider": "MistralAI"}), + TranscriptionProvider.MISTRAL.value, + ) + + +class TestCustomEndpointResolution(unittest.TestCase): + """Test custom endpoint resolution for base URL, API key, account, and model.""" + + def setUp(self): + self.test_dir = tempfile.TemporaryDirectory() + apikeymanager._managers.clear() + apikeymanager._store_cache_by_dir.clear() + apikeymanager._migrated_dirs.clear() + apikeymanager._loaded_data_dir = None + apikeymanager.load(self.test_dir.name) + self.manager = apikeymanager.get(Provider.CustomOpenAI) + + def tearDown(self): + self.test_dir.cleanup() + + def test_resolve_custom_endpoint_active_account(self): + acc_id = self.manager.add_account( + name="LocalAI Server", + api_key="sk-local-test-key", + base_url="http://192.168.1.100:8080/v1", + set_active=True, + ) + conf = { + "audio": { + "transcriptionProvider": "custom_openai", + "whisperModel": "whisper-1", + } + } + endpoint_info = transcription.resolve_transcription_endpoint(conf) + self.assertEqual(endpoint_info["provider"], "custom_openai") + self.assertEqual(endpoint_info["api_provider"], Provider.CustomOpenAI) + self.assertEqual(endpoint_info["account_id"], acc_id) + self.assertEqual(endpoint_info["base_url"], "http://192.168.1.100:8080/v1") + self.assertEqual(endpoint_info["api_key"], "sk-local-test-key") + self.assertEqual(endpoint_info["model"], "whisper-1") + + def test_resolve_custom_endpoint_mapped_account(self): + acc1 = self.manager.add_account( + name="LocalAI Server 1", + api_key="key-1", + base_url="http://host1:8080/v1", + set_active=True, + ) + acc2 = self.manager.add_account( + name="OmniRoute Endpoint", + api_key="key-2", + base_url="https://api.omniroute.ai/v1", + set_active=False, + ) + conf = { + "audio": { + "transcriptionProvider": "custom_openai", + "customOpenAITranscriptionAccountId": acc2, + "customOpenAITranscriptionModel": "custom-whisper-large", + } + } + endpoint_info = transcription.resolve_transcription_endpoint(conf) + self.assertEqual(endpoint_info["account_id"], acc2) + self.assertEqual(endpoint_info["base_url"], "https://api.omniroute.ai/v1") + self.assertEqual(endpoint_info["api_key"], "key-2") + self.assertEqual(endpoint_info["model"], "custom-whisper-large") + + def test_configure_client_for_custom_provider_normalizes_trailing_slash(self): + acc = self.manager.add_account( + name="Custom", + api_key="test-key", + base_url="http://localhost:8000/v1/", + set_active=True, + ) + client = OpenAIClient() + configured = configure_client_for_provider( + client, + Provider.CustomOpenAI, + account_id=acc, + ) + self.assertEqual(configured.base_url, "http://localhost:8000/v1") + self.assertEqual(configured.api_key, "test-key") + self.assertEqual(configured.provider, Provider.CustomOpenAI) + + +class TestTranscriptionWiring(unittest.TestCase): + """Test wiring of transcription calls to /audio/transcriptions on custom endpoint.""" + + def setUp(self): + self.test_dir = tempfile.TemporaryDirectory() + apikeymanager._managers.clear() + apikeymanager._store_cache_by_dir.clear() + apikeymanager._migrated_dirs.clear() + apikeymanager._loaded_data_dir = None + apikeymanager.load(self.test_dir.name) + self.manager = apikeymanager.get(Provider.CustomOpenAI) + + def tearDown(self): + self.test_dir.cleanup() + + def test_record_thread_transcribes_to_custom_url(self): + self.manager.add_account( + name="OmniRoute", + api_key="sk-omni-test", + base_url="https://proxy.example.com/v1", + set_active=True, + ) + conf = { + "transcriptionProvider": "custom_openai", + "whisperModel": "whisper-1", + } + + captured_req = None + + def fake_open_bytes(opener, req, timeout=120): + nonlocal captured_req + captured_req = req + return b'{"text": "Transcribed text successfully"}', "application/json" + + with patch("addon.globalPlugins.AIHub.apiclient._client._open_bytes", side_effect=fake_open_bytes): + audio_path = os.path.join(self.test_dir.name, "sample.wav") + with open(audio_path, "wb") as f: + f.write(b"RIFFdummywavecontentdata") + + rt = RecordThread( + OpenAIClient(), + conf=conf, + transcriptionProvider="custom_openai", + ) + res = rt._transcribe_custom_openai(audio_path) + + self.assertIsNotNone(captured_req) + self.assertEqual(captured_req.full_url, "https://proxy.example.com/v1/audio/transcriptions") + self.assertEqual(captured_req.headers.get("Authorization"), "Bearer sk-omni-test") + self.assertEqual(res.text, "Transcribed text successfully") + + def test_record_thread_custom_endpoint_without_api_key(self): + self.manager.add_account( + name="Local Server Without Key", + api_key="", + base_url="http://localhost:11434/v1", + set_active=True, + ) + conf = { + "transcriptionProvider": "custom_openai", + "whisperModel": "whisper-1", + } + + captured_req = None + + def fake_open_bytes(opener, req, timeout=120): + nonlocal captured_req + captured_req = req + return b'{"text": "Local transcription response"}', "application/json" + + with patch("addon.globalPlugins.AIHub.apiclient._client._open_bytes", side_effect=fake_open_bytes): + audio_path = os.path.join(self.test_dir.name, "sample.wav") + with open(audio_path, "wb") as f: + f.write(b"RIFFdummywavecontentdata") + + rt = RecordThread( + OpenAIClient(), + conf=conf, + transcriptionProvider="custom_openai", + ) + res = rt._transcribe_custom_openai(audio_path) + + self.assertIsNotNone(captured_req) + self.assertEqual(captured_req.full_url, "http://localhost:11434/v1/audio/transcriptions") + self.assertNotIn("Authorization", captured_req.headers) + self.assertEqual(res.text, "Local transcription response") + + def test_transcribe_audio_file_dispatches_to_custom_openai(self): + self.manager.add_account( + name="LocalAI", + api_key="key", + base_url="http://localhost:8080/v1", + set_active=True, + ) + conf = { + "transcriptionProvider": "custom_openai", + "whisperModel": "whisper-1", + } + + def fake_open_bytes(opener, req, timeout=120): + return b'{"text": "File helper transcription"}', "application/json" + + with patch("addon.globalPlugins.AIHub.apiclient._client._open_bytes", side_effect=fake_open_bytes): + audio_path = os.path.join(self.test_dir.name, "test.wav") + with open(audio_path, "wb") as f: + f.write(b"RIFFdummydata") + + result_text = transcribe_audio_file(audio_path, conf) + + self.assertEqual(result_text, "File helper transcription") + + +class TestToolsRegistryAndConfigspec(unittest.TestCase): + """Test configuration specs and tools registry integration.""" + + def test_configspec_contains_custom_transcription_keys(self): + audio_specs = configspec.confSpecs["audio"] + self.assertIn("customOpenAITranscriptionAccountId", audio_specs) + self.assertIn("customOpenAITranscriptionModel", audio_specs) + self.assertIn("custom_openai", audio_specs["transcriptionProvider"]) + + def test_tools_menu_registry_has_custom_openai(self): + self.assertIn("CUSTOM_OPENAI_TRANSCRIPTION", ToolId.__members__) + tool_ids = [t["id"] for t in TOOLS_REGISTRY] + self.assertIn(ToolId.CUSTOM_OPENAI_TRANSCRIPTION, tool_ids) + + custom_tool = next(t for t in TOOLS_REGISTRY if t["id"] == ToolId.CUSTOM_OPENAI_TRANSCRIPTION) + self.assertEqual(custom_tool["group_label"], Provider.CustomOpenAI) + self.assertEqual(custom_tool["manager_provider"], Provider.CustomOpenAI) + self.assertEqual(custom_tool["dialog_class"], "CustomOpenAITranscriptionToolDialog") + + self.assertIn(Provider.CustomOpenAI, _GROUP_ORDER) + + def test_conversation_format_enum(self): + self.assertIn("TOOL_CUSTOM_OPENAI_TRANSCRIPTION", ConversationFormat.__members__) + self.assertEqual( + ConversationFormat.TOOL_CUSTOM_OPENAI_TRANSCRIPTION.value, + "tool/customopenai/transcription", + ) + + +class TestToolDialogAccounts(unittest.TestCase): + """Test account handling in OpenAITranscriptionToolDialog and CustomOpenAITranscriptionToolDialog.""" + + def setUp(self): + self.test_dir = tempfile.TemporaryDirectory() + apikeymanager._managers.clear() + apikeymanager._store_cache_by_dir.clear() + apikeymanager._migrated_dirs.clear() + apikeymanager._loaded_data_dir = None + apikeymanager.load(self.test_dir.name) + self.custom_mgr = apikeymanager.get(Provider.CustomOpenAI) + self.openai_mgr = apikeymanager.get(Provider.OpenAI) + + def tearDown(self): + self.test_dir.cleanup() + + def test_load_transcription_accounts_includes_custom_when_openai_empty(self): + acc_id = self.custom_mgr.add_account( + name="Local Whisper", + api_key="sk-local", + base_url="http://localhost:5000/v1", + set_active=True, + ) + # Create a dummy dialog instance without GUI initialization + dlg = OpenAITranscriptionToolDialog.__new__(OpenAITranscriptionToolDialog) + dlg.provider = Provider.OpenAI + dlg.manager = self.openai_mgr + accounts = dlg._load_transcription_accounts() + + self.assertEqual(len(accounts), 1) + self.assertEqual(accounts[0]["id"], acc_id) + self.assertEqual(accounts[0]["_provider"], Provider.CustomOpenAI) + self.assertEqual(accounts[0]["base_url"], "http://localhost:5000/v1") + + def test_configure_client_with_custom_account(self): + acc_id = self.custom_mgr.add_account( + name="OmniRoute Endpoint", + api_key="omni-secret-key", + base_url="https://api.omniroute.ai/v1", + set_active=True, + ) + dlg = OpenAITranscriptionToolDialog.__new__(OpenAITranscriptionToolDialog) + dlg.provider = Provider.OpenAI + dlg.manager = self.openai_mgr + dlg._accounts = dlg._load_transcription_accounts() + dlg.client = None + + dlg.configure_client(acc_id) + self.assertIsNotNone(dlg.client) + self.assertEqual(dlg.client.base_url, "https://api.omniroute.ai/v1") + self.assertEqual(dlg.client.api_key, "omni-secret-key") + self.assertEqual(dlg.client.provider, Provider.CustomOpenAI) + + def test_custom_dialog_subclass_provider(self): + self.assertEqual(CustomOpenAITranscriptionToolDialog.__bases__[0], OpenAITranscriptionToolDialog) + + +class TestSettingsDialogChoices(unittest.TestCase): + """Test settings dialog choices alignment.""" + + def test_transcription_choices_alignment(self): + choices = transcription.get_transcription_provider_choices() + choice_ids = [c[0] for c in choices] + self.assertEqual(tuple(choice_ids), TRANSCRIPTION_PROVIDERS) + self.assertEqual(TRANSCRIPTION_PROVIDERS[3], "custom_openai") + + +class TestRecordThreadEdgeCases(unittest.TestCase): + """Test RecordThread error handling and fallback behaviors.""" + + def setUp(self): + self.test_dir = tempfile.TemporaryDirectory() + apikeymanager._managers.clear() + apikeymanager._store_cache_by_dir.clear() + apikeymanager._migrated_dirs.clear() + apikeymanager._loaded_data_dir = None + apikeymanager.load(self.test_dir.name) + self.manager = apikeymanager.get(Provider.CustomOpenAI) + + def tearDown(self): + self.test_dir.cleanup() + + def test_record_thread_fallback_to_active_account(self): + active_id = self.manager.add_account( + name="Active Account", + api_key="key", + base_url="http://active:8000/v1", + set_active=True, + ) + conf = { + "transcriptionProvider": "custom_openai", + "customOpenAITranscriptionAccountId": "non_existent_id", + } + rt = RecordThread( + OpenAIClient(), + conf=conf, + transcriptionProvider="custom_openai", + ) + self.assertEqual(rt._transcriptionAccountId, active_id) + + def test_record_thread_raises_when_no_base_url_configured(self): + # No account configured in manager + conf = { + "transcriptionProvider": "custom_openai", + } + audio_path = os.path.join(self.test_dir.name, "sample.wav") + with open(audio_path, "wb") as f: + f.write(b"RIFFdata") + + rt = RecordThread( + OpenAIClient(), + conf=conf, + transcriptionProvider="custom_openai", + ) + with self.assertRaises(ValueError) as ctx: + rt._transcribe_custom_openai(audio_path) + self.assertIn("No Custom OpenAI endpoint URL configured", str(ctx.exception)) + + def test_transcribe_audio_file_unsupported_extension_returns_none(self): + txt_path = os.path.join(self.test_dir.name, "sample.txt") + with open(txt_path, "w") as f: + f.write("not an audio file") + result = transcribe_audio_file(txt_path, {"transcriptionProvider": "custom_openai"}) + self.assertIsNone(result) + + +class TestConversationDialogResolution(unittest.TestCase): + """Test conversation dialog dictation config resolution for Custom OpenAI.""" + + def test_resolve_dictation_config_custom_openai(self): + from addon.globalPlugins.AIHub.conversation_dialog import ConversationDialog + dlg = ConversationDialog.__new__(ConversationDialog) + dlg.conf = { + "audio": { + "transcriptionProvider": "custom_openai", + "customOpenAITranscriptionAccountId": "acc-123", + "whisperModel": "whisper-1", + } + } + model = MagicMock() + model.audioInput = False + model.provider = Provider.CustomOpenAI + model.id = "custom-whisper" + dlg.getCurrentModel = lambda: model + dlg.getCurrentAccount = lambda: {"id": "acc-current", "provider": Provider.CustomOpenAI} + + cfg = dlg._resolveDictationConfig() + self.assertFalse(cfg["use_direct"]) + self.assertEqual(cfg["transcription_provider"], "custom_openai") + self.assertEqual(cfg["transcription_account_id"], "acc-123") + self.assertEqual(cfg["transcription_model"], "custom-whisper") + + +if __name__ == "__main__": + unittest.main() +