Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions addon/globalPlugins/AIHub/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions addon/globalPlugins/AIHub/apiclient/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
):
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion addon/globalPlugins/AIHub/apikeymanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
2 changes: 2 additions & 0 deletions addon/globalPlugins/AIHub/configspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')"
Expand Down
1 change: 1 addition & 0 deletions addon/globalPlugins/AIHub/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions addon/globalPlugins/AIHub/conversation_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions addon/globalPlugins/AIHub/conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion addon/globalPlugins/AIHub/conversations_manager_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down
34 changes: 29 additions & 5 deletions addon/globalPlugins/AIHub/plugin_mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()
44 changes: 41 additions & 3 deletions addon/globalPlugins/AIHub/recordthread.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from . import apikeymanager
from .apiclient import (
OpenAIClient,
Transcription,
transcribe_audio_mistral,
configure_client_for_provider,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
33 changes: 27 additions & 6 deletions addon/globalPlugins/AIHub/settings_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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())
Expand Down Expand Up @@ -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():
Expand Down
Loading