Skip to content

Selecting a cloud provider tab in Speech-to-Text silently overwrites the saved Custom endpoint URL, unrecoverably #1459

Description

@xlurie

Describe the bug

In Settings → Speech-to-Text → Cloud providers, merely visiting a provider tab writes settings: clicking any
built-in provider tab (Tinfoil, Groq, xAI, Mistral, Corti, …) immediately writes that
provider's baseUrl over the persisted cloudTranscriptionBaseUrl. That key is the only place the Custom
endpoint URL lives — there is no separate storage for it — so the user's custom URL is gone the moment the
tab is clicked, before any Save action and with no confirmation.

Clicking back to Custom does not restore it. The Custom branch of the handler returns early after setting
only the model, leaving the last-clicked provider's URL in place. The URL field is now populated with, say,
https://inference.tinfoil.sh/v1, and dictation silently posts audio there.

To Reproduce

  1. Settings → Speech-to-Text → Cloud providers → Custom.
  2. Set Endpoint URL to your own OpenAI-compatible transcription server, e.g. http://127.0.0.1:8587/v1
    (any self-hosted whisper server works). Model whisper-1. Confirm dictation works against it.
  3. Click any other provider tab — e.g. Tinfoil — to look at what it offers. Do not click any model card,
    do not save anything.
  4. Click Custom again.
  5. The Endpoint URL field now shows Tinfoil's base URL. The value you typed in step 2 is gone — it is not
    recoverable from the UI, and it is already overwritten in localStorage.
  6. Dictate. The request goes to the last-clicked provider, not to your endpoint.

Expected behavior

Switching tabs may well commit the provider selection — but it should not overwrite a field owned by another
tab. The Custom endpoint URL should survive tab navigation, and returning to the Custom tab should show the
URL that was configured there.

Actual behavior

The Custom endpoint URL is silently replaced on tab click and cannot be recovered. Our real-world hit: after
an app update we clicked through the new provider tabs to see what had been added, then went back to Custom.
Dictation started failing with

API Error: 404 {"error":{"message":"The model does not exist."}}

Our self-hosted server never received a request (request counter stayed at 0). The 404 was Tinfoil's — a
provider we had never configured, had no key for, and had never intentionally selected — answering a request
for model whisper-1. The error message names neither the provider nor the URL, so the failure looks like a
broken local stack rather than a settings clobber. It took a curl against the persisted base URL to find
out where the audio was actually going.

Root cause

src/components/TranscriptionModelPicker.tsx (v1.8.1), handleCloudProviderChange, lines 634–652:

const handleCloudProviderChange = useCallback(
  (providerId: string) => {
    onCloudProviderSelect(providerId);
    const provider = cloudProviders.find((p) => p.id === providerId);

    if (providerId === "custom") {
      onCloudModelSelect("whisper-1");   // line 640 — early return, base URL never restored
      return;
    }

    if (provider) {
      setCloudTranscriptionBaseUrl?.(provider.baseUrl);   // line 645 — clobbers the custom URL
      if (provider.models?.length) {
        onCloudModelSelect(provider.models[0].id);
      }
    }
  },
  [cloudProviders, onCloudProviderSelect, onCloudModelSelect, setCloudTranscriptionBaseUrl]
);

It is wired directly to the tab bar's onSelect (TranscriptionModelPicker.tsx:952), so the write happens on
plain tab navigation.

The write is persistent and immediate — no Save step, no undo:
src/stores/settingsStore.ts:1355 defines
setCloudTranscriptionBaseUrl: createStringSetter("cloudTranscriptionBaseUrl"), and createStringSetter
(settingsStore.ts:726-731) does localStorage.setItem(key, value) on every call. Since the Custom tab has
no key of its own, the previous value is destroyed, not shadowed.

The consequence at request time — src/helpers/audioManager.js:2798 getTranscriptionEndpoint(), lines
2857–2858:

} else if (currentProvider === "custom") {
  base = currentBaseUrl.trim() || API_ENDPOINTS.TRANSCRIPTION_BASE;

so with the provider back on custom and the base URL now holding a foreign provider's endpoint, dictation
posts to that provider's /audio/transcriptions. The exact error we saw follows from the same handler: the
Tinfoil tab click also selected Tinfoil's model (voxtral-mini-4b-realtime), and clicking back to Custom
reset the model to whisper-1 (line 640) while leaving Tinfoil's URL in place — so the request that went out
was whisper-1 against https://inference.tinfoil.sh/v1, which is exactly what "The model does not exist"
answers.

One more consequence worth flagging: 1.8.1 deliberately refuses to send Tinfoil transcriptions through this
generic path — getTranscriptionEndpoint throws "Tinfoil transcription must go through the attested
main-process proxy" (audioManager.js:2804-2806). That guard keys off the provider id, so it does not fire
after a clobber: the provider id is custom while the URL points at inference.tinfoil.sh, and the recording
is uploaded there outside the attested path the guard exists to enforce.

Worth noting: the component already models "a base URL that is neither the default nor any known provider" as
evidence of a custom setup — ensureValidCloudSelection (TranscriptionModelPicker.tsx:465-492) uses exactly
that test to flip the picker back to the Custom tab. That recovery can never fire here, because by then the
custom URL is gone and the persisted provider id is a valid built-in one. The inverse coupling exists too:
handleBaseUrlBlur (TranscriptionModelPicker.tsx:682-710) converts a typed custom URL into the matching
provider tab. So the code understands the relationship between the two — it just doesn't preserve the user's
value across a tab switch.

Design argument

This is the same bug class as #1287 ("Provider tab switches silently overwrites the persisted model selection
in AI models"): browsing a tab commits state. #1287 is the model-selection flavor and costs a re-pick; this
one is the endpoint flavor and costs data the UI cannot regenerate — the user must remember and retype their
own URL, assuming they realize that is what happened.

If the answer to #1287 is that a tab bar is a radio selector and selecting a provider is legitimately a
commit, that reasoning still does not cover this case: the value destroyed here does not belong to the tab
being selected. Committing "provider = Tinfoil" is visible and one click to undo; overwriting the Custom
tab's URL as a side effect is neither. Whatever is decided about tab-switch semantics in general, one tab's
selection should not consume another tab's only stored field.

Possible fixes, most robust first:

  1. Give Custom its own persisted key, the way self-hosted mode already does with remoteTranscriptionUrl
    (plus a one-time migration of the existing cloudTranscriptionBaseUrl value). Provider tabs then
    physically cannot reach it. Self-hosted mode is immune to this bug for exactly that reason.
  2. Don't write baseUrl on tab change at all — resolve a built-in provider's base URL at request time from
    the provider id, and treat cloudTranscriptionBaseUrl as a Custom-only field. getTranscriptionEndpoint
    already does this for groq/xai/mistral, and corti/tinfoil never use that value anyway (they go through
    their main-process proxies). As far as I can tell the stored URL is only ever read on the custom
    branch — both in getTranscriptionEndpoint and in the main-process file-transcription handler
    (ipcHandlers.js, provider === "custom") — so writing it for the other tabs buys nothing and costs the
    Custom tab its only field.
  3. Minimum viable fix: remember the last Custom URL and restore it in the providerId === "custom" branch
    instead of returning early.

Impact

Anyone running a self-hosted or otherwise custom OpenAI-compatible transcription endpoint through the Custom
tab loses that configuration to a single exploratory click, with no warning, no undo, and no trace in the UI.
The follow-on failure is worse than a plain reset: audio is sent to a third-party provider the user never
chose, and the resulting error message (The model does not exist.) points at neither the provider nor the
URL, so debugging starts in the wrong place.

Desktop (please complete the following information):

  • OS: Windows 11 Pro (26200)
  • Version: 1.8.1

Additional context

  • Observed originally on 1.7.4; handleCloudProviderChange is byte-identical at v1.7.4 and v1.8.1, so
    this is not an update regression — the tabs that made it easy to trip over simply arrived in 1.7.4.
  • The Language Models page shares the pattern, but only for the model: ReasoningModelSelector's
    handleCloudProviderChange (src/components/ReasoningModelSelector.tsx:488-492) calls
    selectDefaultModelForProvider on every tab switch, which persists the new provider's default model — and
    for custom/OpenRouter clears the saved model outright (ReasoningModelSelector.tsx:444-450). The custom
    URL there lives under its own keys and survives, so that page loses a re-pickable selection, not typed
    data — which is what Provider tab switches silently overwrites the persisted model selection in AI models #1287 already reports. The transcription picker is the one place where the same
    "browsing commits state" design destroys a value the app cannot reconstruct.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingworking on itThis issue or feature is actively being worked on.

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions