diff --git a/docs/chat-chain-changes/2026-07-26-stt-config-sync.md b/docs/chat-chain-changes/2026-07-26-stt-config-sync.md new file mode 100644 index 000000000..145c72c0f --- /dev/null +++ b/docs/chat-chain-changes/2026-07-26-stt-config-sync.md @@ -0,0 +1,18 @@ +--- +date: 2026-07-26 +pr: pending +feature: Mirror Web UI STT provider into the Hermes Agent profile config +impact: Saving a Groq STT provider in Settings -> Voice now also configures Hermes Agent, so gateway platforms transcribe voice messages with the same provider instead of falling back to local. +--- + +Saving an STT provider whose base URL maps unambiguously to a Hermes Agent +provider now mirrors two values into the profile: the credential is written to +the profile `.env` under the env var Hermes reads, and `stt.enabled` / +`stt.provider` are written to the profile `config.yaml`. Only Groq is mapped for +now, so a generic OpenAI-compatible endpoint is never silently relabelled. + +The mirror never fails the request: a write error is logged and the settings +response is unchanged. Web UI transcription keeps using the Web UI settings +store, so browser behaviour is unchanged. + +Chat text, session persistence, and message ordering are unchanged. diff --git a/packages/server/src/controllers/hermes/stt.ts b/packages/server/src/controllers/hermes/stt.ts index c04a06c9d..3a3063494 100644 --- a/packages/server/src/controllers/hermes/stt.ts +++ b/packages/server/src/controllers/hermes/stt.ts @@ -20,6 +20,7 @@ import { config } from '../../config' import { SttProviderConfigError, transcribeWithProvider } from '../../services/hermes/stt-providers' import { SttNoSpeechDetectedError } from '../../services/hermes/stt-providers/types' import { logger } from '../../services/logger' +import { syncSttProviderToHermesConfig } from '../../services/hermes/voice-config-sync' import { getActiveGlobalAgentServer } from '../../services/global-agent/server' import { MCU_TTS_SAMPLE_RATE, mcuPromptText, mcuPromptUrl } from '../../services/hermes/mcu-prompts' @@ -346,6 +347,15 @@ export async function saveSettings(ctx: Context) { ? saveActiveSttProvider(profile, storedProvider) : saveActiveSttProvider(profile, assertActiveSttProvider(String(body.activeProvider))) + // Mirror the provider into the Hermes Agent profile config so gateway + // platforms (Telegram, Discord, ...) transcribe with the same provider. + const settingsBody = body?.settings as Record | undefined + const secretsBody = body?.secrets as Record | undefined + await syncSttProviderToHermesConfig(profile, { + baseUrl: settingsBody?.baseUrl, + apiKey: secretsBody?.apiKey, + }) + ctx.body = { setting, activeProvider } } catch (error) { if (handleSettingsError(ctx, error)) return diff --git a/packages/server/src/services/hermes/voice-config-sync.ts b/packages/server/src/services/hermes/voice-config-sync.ts new file mode 100644 index 000000000..e5e037fe2 --- /dev/null +++ b/packages/server/src/services/hermes/voice-config-sync.ts @@ -0,0 +1,90 @@ +import { saveEnvValueForProfile, updateConfigYamlForProfile } from '../config-helpers' +import { logger } from '../logger' + +/** + * Mirror Web UI speech settings into the Hermes Agent profile configuration. + * + * The Web UI stores its own voice provider settings and uses them for + * `POST /api/hermes/stt/transcribe`, which only serves browser transcription. + * Hermes Agent reads a separate `stt:` block from the profile `config.yaml` + * plus credentials from the profile `.env`, and that is what gateway platforms + * (Telegram, Discord, ...) use for inbound voice messages. Without mirroring, + * a provider configured in the Web UI never reaches those platforms and Hermes + * keeps resolving `stt.provider` to `local`. + * + * Key names follow the Hermes Agent reference documentation + * (`website/docs/user-guide/features/voice-mode.md`): + * + * stt: + * enabled: true + * provider: "local" | "groq" | "openai" | "mistral" | "xai" + * + * Only hosts with an unambiguous Hermes provider mapping are mirrored, so a + * generic OpenAI-compatible endpoint is never silently relabelled. + */ + +const STORED_SECRET_PLACEHOLDER = '[stored]' + +export interface HermesSttTarget { + /** Value written to `stt.provider` in config.yaml. */ + provider: string + /** Env var Hermes Agent reads the credential from. */ + apiKeyEnv: string +} + +const HERMES_STT_HOSTS: Array = [ + { hostPattern: /(^|\.)groq\.com$/i, provider: 'groq', apiKeyEnv: 'GROQ_API_KEY' }, +] + +export function resolveHermesSttTarget(baseUrl: unknown): HermesSttTarget | null { + if (typeof baseUrl !== 'string') return null + const trimmed = baseUrl.trim() + if (!trimmed) return null + + let host = '' + try { + host = new URL(trimmed).hostname + } catch { + return null + } + + const match = HERMES_STT_HOSTS.find(entry => entry.hostPattern.test(host)) + return match ? { provider: match.provider, apiKeyEnv: match.apiKeyEnv } : null +} + +export function isUsableSecret(value: unknown): value is string { + if (typeof value !== 'string') return false + const trimmed = value.trim() + return trimmed.length > 0 && trimmed !== STORED_SECRET_PLACEHOLDER +} + +export function applyHermesSttProvider(config: Record, provider: string): Record { + const stt = (typeof config.stt === 'object' && config.stt !== null) ? config.stt : {} + stt.enabled = true + stt.provider = provider + config.stt = stt + return config +} + +/** + * Returns true when the profile configuration was updated. + * Never throws: a failed mirror must not fail the settings request itself. + */ +export async function syncSttProviderToHermesConfig( + profile: string, + input: { baseUrl?: unknown, apiKey?: unknown }, +): Promise { + const target = resolveHermesSttTarget(input.baseUrl) + if (!target) return false + + try { + if (isUsableSecret(input.apiKey)) { + await saveEnvValueForProfile(profile, target.apiKeyEnv, input.apiKey.trim()) + } + await updateConfigYamlForProfile(profile, config => applyHermesSttProvider(config, target.provider)) + return true + } catch (error) { + logger.warn(`[voice-config-sync] failed to mirror STT settings for profile "${profile}": ${String(error)}`) + return false + } +} diff --git a/tests/server/voice-config-sync.test.ts b/tests/server/voice-config-sync.test.ts new file mode 100644 index 000000000..304ae2cdb --- /dev/null +++ b/tests/server/voice-config-sync.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { + applyHermesSttProvider, + isUsableSecret, + resolveHermesSttTarget, +} from '../../packages/server/src/services/hermes/voice-config-sync' + +describe('resolveHermesSttTarget', () => { + it('maps a Groq base URL to the groq provider and GROQ_API_KEY', () => { + expect(resolveHermesSttTarget('https://api.groq.com/openai/v1')).toEqual({ + provider: 'groq', + apiKeyEnv: 'GROQ_API_KEY', + }) + }) + + it('ignores hosts without an unambiguous Hermes provider', () => { + expect(resolveHermesSttTarget('https://api.openai.com/v1')).toBeNull() + expect(resolveHermesSttTarget('https://speech.example.com/v1')).toBeNull() + }) + + it('does not match a look-alike host', () => { + expect(resolveHermesSttTarget('https://api.groq.com.evil.test/v1')).toBeNull() + }) + + it('returns null for empty or malformed input', () => { + expect(resolveHermesSttTarget('')).toBeNull() + expect(resolveHermesSttTarget(' ')).toBeNull() + expect(resolveHermesSttTarget('not a url')).toBeNull() + expect(resolveHermesSttTarget(undefined)).toBeNull() + expect(resolveHermesSttTarget(42)).toBeNull() + }) +}) + +describe('isUsableSecret', () => { + it('accepts a real key', () => { + expect(isUsableSecret('gsk_live_key')).toBe(true) + }) + + it('rejects the stored-secret placeholder and blanks', () => { + expect(isUsableSecret('[stored]')).toBe(false) + expect(isUsableSecret(' ')).toBe(false) + expect(isUsableSecret('')).toBe(false) + expect(isUsableSecret(undefined)).toBe(false) + }) +}) + +describe('applyHermesSttProvider', () => { + it('creates the stt block when missing', () => { + const config: Record = {} + applyHermesSttProvider(config, 'groq') + expect(config.stt).toEqual({ enabled: true, provider: 'groq' }) + }) + + it('preserves unrelated stt keys written by the user', () => { + const config: Record = { + stt: { provider: 'local', local: { model: 'base', language: 'ar' }, groq: { language: 'ar' } }, + } + applyHermesSttProvider(config, 'groq') + expect(config.stt.provider).toBe('groq') + expect(config.stt.enabled).toBe(true) + expect(config.stt.local).toEqual({ model: 'base', language: 'ar' }) + expect(config.stt.groq).toEqual({ language: 'ar' }) + }) + + it('replaces a non-object stt value instead of throwing', () => { + const config: Record = { stt: 'local' } + applyHermesSttProvider(config, 'groq') + expect(config.stt).toEqual({ enabled: true, provider: 'groq' }) + }) + + it('leaves the rest of the config untouched', () => { + const config: Record = { model: { default: 'glm-5.2:cloud' }, tts: { provider: 'edge' } } + applyHermesSttProvider(config, 'groq') + expect(config.model).toEqual({ default: 'glm-5.2:cloud' }) + expect(config.tts).toEqual({ provider: 'edge' }) + }) +})