refactor(ai): explicit STT request format instead of OpenRouter host-sniffing

Replace the implicit `hostname endsWith openrouter.ai` detection with an
explicit, admin-chosen provider field `sttApiStyle` ('multipart' = OpenAI-
compatible multipart /audio/transcriptions; 'json' = OpenRouter-style JSON +
base64 input_audio). The transcription path now branches on the stored field,
not on the URL — nothing hidden from the admin.

- ai.types: add SttApiStyle + STT_API_STYLES; field on AiProviderSettings and
  MaskedAiSettings (resolved via ResolvedAiConfig).
- update-ai-settings.dto: validate sttApiStyle with @IsIn(STT_API_STYLES).
- ai-settings.service: plumb sttApiStyle through resolve()/getMasked() and the
  non-secret update whitelist; workspace.repo: add it to the ALLOWED array so it
  persists.
- ai.service: drop isOpenRouter(); transcribe() branches on cfg.sttApiStyle;
  rename helper to transcribeJsonBase64 with provider-neutral error text and a
  BadRequestException (400) when the base URL is missing for the JSON style.
- client: SttApiStyle type on IAiSettings/IAiSettingsUpdate; "Request format"
  Select on the Voice/STT settings card; i18n.
This commit is contained in:
vvzvlad
2026-06-18 19:40:05 +03:00
parent 77249d59c6
commit 01a5a4b5d2
8 changed files with 85 additions and 38 deletions
@@ -9,6 +9,7 @@ import {
Modal,
Paper,
PasswordInput,
Select,
Stack,
Switch,
Text,
@@ -32,7 +33,10 @@ import {
useTestAiConnectionMutation,
useUpdateAiSettingsMutation,
} from "@/features/workspace/queries/ai-settings-query.ts";
import { IAiSettingsUpdate } from "@/features/workspace/services/ai-settings-service.ts";
import {
IAiSettingsUpdate,
SttApiStyle,
} from "@/features/workspace/services/ai-settings-service.ts";
import AiMcpServers from "./ai-mcp-servers.tsx";
// No driver field: every endpoint is OpenAI-compatible, so the form carries only
@@ -50,6 +54,7 @@ const formSchema = z.object({
// STT-specific fields. Empty base URL / key fall back to the chat ones.
sttModel: z.string(),
sttBaseUrl: z.string(),
sttApiStyle: z.enum(["multipart", "json"]),
sttApiKey: z.string(),
});
@@ -139,6 +144,7 @@ export default function AiProviderSettings() {
embeddingApiKey: "",
sttModel: "",
sttBaseUrl: "",
sttApiStyle: "multipart" as SttApiStyle,
sttApiKey: "",
},
});
@@ -157,6 +163,7 @@ export default function AiProviderSettings() {
embeddingApiKey: "",
sttModel: settings.sttModel ?? "",
sttBaseUrl: settings.sttBaseUrl ?? "",
sttApiStyle: settings.sttApiStyle ?? "multipart",
sttApiKey: "",
});
form.resetDirty();
@@ -184,6 +191,7 @@ export default function AiProviderSettings() {
// server-side.
sttModel: values.sttModel,
sttBaseUrl: values.sttBaseUrl,
sttApiStyle: values.sttApiStyle,
};
// Key semantics (never send the stored key back):
@@ -671,6 +679,22 @@ export default function AiProviderSettings() {
</Stack>
</Group>
<Select
mt="sm"
label={t("Request format")}
description={t("How transcription requests are sent to the endpoint")}
data={[
{
value: "multipart",
label: t("OpenAI-compatible (multipart/form-data)"),
},
{ value: "json", label: t("OpenRouter (JSON, base64 audio)") },
]}
allowDeselect={false}
disabled={isLoading}
{...form.getInputProps("sttApiStyle")}
/>
<TextInput
mt="sm"
label={t("Base URL")}