Skip to content
Closed
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
18 changes: 18 additions & 0 deletions docs/chat-chain-changes/2026-07-26-stt-config-sync.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/server/src/controllers/hermes/stt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, unknown> | undefined
const secretsBody = body?.secrets as Record<string, unknown> | undefined
await syncSttProviderToHermesConfig(profile, {
baseUrl: settingsBody?.baseUrl,
apiKey: secretsBody?.apiKey,
})

ctx.body = { setting, activeProvider }
} catch (error) {
if (handleSettingsError(ctx, error)) return
Expand Down
90 changes: 90 additions & 0 deletions packages/server/src/services/hermes/voice-config-sync.ts
Original file line number Diff line number Diff line change
@@ -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<HermesSttTarget & { hostPattern: RegExp }> = [
{ 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<string, any>, provider: string): Record<string, any> {
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<boolean> {
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
}
}
77 changes: 77 additions & 0 deletions tests/server/voice-config-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}
applyHermesSttProvider(config, 'groq')
expect(config.stt).toEqual({ enabled: true, provider: 'groq' })
})

it('preserves unrelated stt keys written by the user', () => {
const config: Record<string, any> = {
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<string, any> = { stt: 'local' }
applyHermesSttProvider(config, 'groq')
expect(config.stt).toEqual({ enabled: true, provider: 'groq' })
})

it('leaves the rest of the config untouched', () => {
const config: Record<string, any> = { 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' })
})
})