Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
describe('STT provider settings schema', () => {
|
|
let db: any = null
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
const { DatabaseSync } = await import('node:sqlite')
|
|
db = new DatabaseSync(':memory:')
|
|
vi.doMock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => db,
|
|
getStoragePath: () => ':memory:',
|
|
}))
|
|
})
|
|
|
|
afterEach(() => {
|
|
db?.close()
|
|
db = null
|
|
vi.doUnmock('../../packages/server/src/db/index')
|
|
vi.resetModules()
|
|
})
|
|
|
|
async function initStores(): Promise<void> {
|
|
const { initAllStores } = await import('../../packages/server/src/db/hermes/init')
|
|
initAllStores()
|
|
}
|
|
|
|
function tableNames(): string[] {
|
|
return db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((row: any) => row.name)
|
|
}
|
|
|
|
it('creates stt_provider_settings table during store init', async () => {
|
|
await initStores()
|
|
|
|
expect(tableNames()).toContain('stt_provider_settings')
|
|
const columns = db.prepare('PRAGMA table_info(stt_provider_settings)').all().map((row: any) => row.name)
|
|
expect(columns).toEqual(expect.arrayContaining([
|
|
'id',
|
|
'user_id',
|
|
'provider',
|
|
'settings_json',
|
|
'secrets_json',
|
|
'created_at',
|
|
'updated_at',
|
|
]))
|
|
})
|
|
|
|
it('rejects duplicate provider settings for the same user', async () => {
|
|
await initStores()
|
|
|
|
const insert = db.prepare('INSERT INTO stt_provider_settings (user_id, provider) VALUES (?, ?)')
|
|
insert.run(1, 'openai')
|
|
|
|
expect(() => insert.run(1, 'openai')).toThrow(/UNIQUE constraint failed/)
|
|
})
|
|
|
|
it('allows the same provider for different users', async () => {
|
|
await initStores()
|
|
|
|
const insert = db.prepare('INSERT INTO stt_provider_settings (user_id, provider) VALUES (?, ?)')
|
|
insert.run(1, 'openai')
|
|
|
|
expect(() => insert.run(2, 'openai')).not.toThrow()
|
|
expect(db.prepare('SELECT COUNT(*) AS count FROM stt_provider_settings WHERE provider = ?').get('openai').count).toBe(2)
|
|
})
|
|
|
|
it('migrates legacy stt_provider_settings tables that still default user_id to 0', async () => {
|
|
db.exec(`
|
|
CREATE TABLE stt_provider_settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL DEFAULT 0,
|
|
provider TEXT NOT NULL,
|
|
settings_json TEXT NOT NULL DEFAULT '{}',
|
|
secrets_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
)
|
|
`)
|
|
db.exec('CREATE INDEX idx_stt_provider_settings_user ON stt_provider_settings(user_id)')
|
|
db.exec('CREATE UNIQUE INDEX idx_stt_provider_settings_user_provider ON stt_provider_settings(user_id, provider)')
|
|
db.prepare(
|
|
'INSERT INTO stt_provider_settings (user_id, provider, settings_json, secrets_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(7, 'deepgram', '{"language":"en"}', '{"apiKey":"secret"}', 1710000000, 1710000100)
|
|
|
|
await initStores()
|
|
|
|
const userIdColumn = db.prepare('PRAGMA table_info(stt_provider_settings)').all().find((row: any) => row.name === 'user_id')
|
|
expect(userIdColumn?.dflt_value).toBeNull()
|
|
|
|
const insertWithoutUserId = db.prepare('INSERT INTO stt_provider_settings (provider) VALUES (?)')
|
|
expect(() => insertWithoutUserId.run('openai')).toThrow(/NOT NULL constraint failed: stt_provider_settings\.user_id/)
|
|
|
|
const preservedRow = db.prepare('SELECT * FROM stt_provider_settings WHERE user_id = ? AND provider = ?').get(7, 'deepgram')
|
|
expect(preservedRow).toMatchObject({
|
|
user_id: 7,
|
|
provider: 'deepgram',
|
|
settings_json: '{"language":"en"}',
|
|
secrets_json: '{"apiKey":"secret"}',
|
|
created_at: 1710000000,
|
|
updated_at: 1710000100,
|
|
})
|
|
})
|
|
|
|
it('rejects inserts that omit user_id', async () => {
|
|
await initStores()
|
|
|
|
const insertWithoutUserId = db.prepare('INSERT INTO stt_provider_settings (provider) VALUES (?)')
|
|
|
|
expect(() => insertWithoutUserId.run('openai')).toThrow(/NOT NULL constraint failed: stt_provider_settings\.user_id/)
|
|
})
|
|
})
|
|
|
|
describe('stt settings store', () => {
|
|
let db: any = null
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
const { DatabaseSync } = await import('node:sqlite')
|
|
db = new DatabaseSync(':memory:')
|
|
vi.doMock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => db,
|
|
getStoragePath: () => ':memory:',
|
|
}))
|
|
})
|
|
|
|
afterEach(() => {
|
|
db?.close()
|
|
db = null
|
|
vi.doUnmock('../../packages/server/src/db/index')
|
|
vi.resetModules()
|
|
})
|
|
|
|
async function initStore() {
|
|
const schemas = await import('../../packages/server/src/db/hermes/schemas')
|
|
schemas.initAllHermesTables()
|
|
return {
|
|
schemas,
|
|
store: await import('../../packages/server/src/db/hermes/stt-settings-store'),
|
|
}
|
|
}
|
|
|
|
it('stores settings and masks secrets on read', async () => {
|
|
const { store } = await initStore()
|
|
|
|
const saved = store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
},
|
|
secrets: {
|
|
apiKey: 'secret-value',
|
|
},
|
|
})
|
|
|
|
expect(saved).toMatchObject({
|
|
profile: 'default',
|
|
provider: 'openai',
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
},
|
|
secrets: {
|
|
apiKey: '[stored]',
|
|
},
|
|
})
|
|
expect(JSON.stringify(saved)).not.toContain('secret-value')
|
|
|
|
const fetched = store.getSttProviderSetting('default', 'openai')
|
|
expect(fetched).toEqual(saved)
|
|
expect(JSON.stringify(fetched)).not.toContain('secret-value')
|
|
})
|
|
|
|
it('returns raw secrets only for backend callers that opt in', async () => {
|
|
const { store } = await initStore()
|
|
|
|
store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
},
|
|
secrets: {
|
|
apiKey: 'secret-value',
|
|
},
|
|
})
|
|
|
|
expect(store.getSttProviderSetting('default', 'openai', { includeSecrets: true })).toMatchObject({
|
|
profile: 'default',
|
|
provider: 'openai',
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
},
|
|
secrets: {
|
|
apiKey: 'secret-value',
|
|
},
|
|
})
|
|
})
|
|
|
|
it('preserves existing raw secrets when the stored marker is submitted again', async () => {
|
|
const { store } = await initStore()
|
|
|
|
store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
},
|
|
secrets: {
|
|
apiKey: 'secret-value',
|
|
},
|
|
})
|
|
|
|
const updated = store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
language: 'en',
|
|
prompt: 'Transcribe carefully',
|
|
},
|
|
secrets: {
|
|
apiKey: '[stored]',
|
|
},
|
|
})
|
|
|
|
expect(updated).toMatchObject({
|
|
profile: 'default',
|
|
provider: 'openai',
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
prompt: 'Transcribe carefully',
|
|
},
|
|
secrets: {
|
|
apiKey: '[stored]',
|
|
},
|
|
})
|
|
|
|
expect(store.getSttProviderSetting('default', 'openai', { includeSecrets: true })?.secrets).toEqual({
|
|
apiKey: 'secret-value',
|
|
})
|
|
})
|
|
|
|
it('trims and bounds prompt length to 1000 characters', async () => {
|
|
const { store } = await initStore()
|
|
const trimmedPrompt = 'x'.repeat(1000)
|
|
|
|
const saved = store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
prompt: ` ${trimmedPrompt}extra `,
|
|
},
|
|
})
|
|
|
|
expect(saved.settings.prompt).toBe(trimmedPrompt)
|
|
expect(saved.settings.prompt).toHaveLength(1000)
|
|
|
|
const storedRow = db.prepare(
|
|
'SELECT settings_json FROM stt_profile_provider_settings WHERE profile = ? AND provider = ?'
|
|
).get('default', 'openai') as { settings_json: string }
|
|
|
|
expect(JSON.parse(storedRow.settings_json)).toMatchObject({
|
|
prompt: trimmedPrompt,
|
|
})
|
|
})
|
|
|
|
it('clears one stored secret without deleting settings', async () => {
|
|
const { store } = await initStore()
|
|
|
|
store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
},
|
|
secrets: {
|
|
apiKey: 'secret-value',
|
|
},
|
|
})
|
|
|
|
const cleared = store.clearStoredSttSecret('default', 'openai', 'apiKey')
|
|
expect(cleared).toMatchObject({
|
|
profile: 'default',
|
|
provider: 'openai',
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
language: 'en',
|
|
},
|
|
secrets: {},
|
|
})
|
|
|
|
expect(store.getSttProviderSetting('default', 'openai')).toEqual(cleared)
|
|
expect(store.getSttProviderSetting('default', 'openai', { includeSecrets: true })?.secrets).toEqual({})
|
|
})
|
|
|
|
it('lists only settings for the requested profile', async () => {
|
|
const { store } = await initStore()
|
|
|
|
const expected = store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
},
|
|
secrets: {
|
|
apiKey: 'secret-value',
|
|
},
|
|
})
|
|
|
|
store.saveSttProviderSetting('research', 'custom', {
|
|
settings: {
|
|
baseUrl: 'https://transcribe.example.com/v1',
|
|
model: 'custom-whisper',
|
|
},
|
|
secrets: {
|
|
apiKey: 'other-secret',
|
|
},
|
|
})
|
|
|
|
expect(store.listSttProviderSettings('default')).toEqual([expected])
|
|
})
|
|
|
|
it('ignores unsupported legacy provider rows when listing settings', async () => {
|
|
const { store } = await initStore()
|
|
|
|
const expected = store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
baseUrl: 'https://api.openai.com/v1/audio/transcriptions',
|
|
model: 'gpt-4o-transcribe',
|
|
},
|
|
secrets: {
|
|
apiKey: 'secret-value',
|
|
},
|
|
})
|
|
|
|
db.prepare(
|
|
'INSERT INTO stt_profile_provider_settings (profile, provider, settings_json, secrets_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run('default', 'deepgram', '{"language":"en"}', '{"apiKey":"legacy-secret"}', 1710000000, 1710000100)
|
|
|
|
expect(() => store.listSttProviderSettings('default')).not.toThrow()
|
|
expect(store.listSttProviderSettings('default')).toEqual([expected])
|
|
expect(() => store.getSttProviderSetting('default', 'deepgram' as any)).toThrow(/unknown STT provider/i)
|
|
})
|
|
|
|
it('rejects unsupported providers and secret names', async () => {
|
|
const { store } = await initStore()
|
|
|
|
expect(() => {
|
|
store.saveSttProviderSetting('default', 'deepgram', {
|
|
settings: {
|
|
model: 'nova-2',
|
|
},
|
|
})
|
|
}).toThrow(/unknown STT provider/i)
|
|
|
|
expect(() => {
|
|
store.saveSttProviderSetting('default', 'openai', {
|
|
secrets: {
|
|
token: 'secret-value',
|
|
},
|
|
})
|
|
}).toThrow(/unknown STT provider secret/i)
|
|
})
|
|
|
|
it.each([
|
|
'http://10.0.0.1:8000/v1/audio/transcriptions',
|
|
'http://172.16.0.1:8000/v1/audio/transcriptions',
|
|
'http://192.168.1.1:8000/v1/audio/transcriptions',
|
|
'http://127.0.0.1:8000/v1/audio/transcriptions',
|
|
'http://169.254.169.254/latest/meta-data',
|
|
'http://[::1]:8000/v1/audio/transcriptions',
|
|
'http://[fd00::1]:8000/v1/audio/transcriptions',
|
|
'http://[fe90::1]:8000/v1/audio/transcriptions',
|
|
])('allows local or private base urls using the shared url rules: %s', async (baseUrl) => {
|
|
const { store } = await initStore()
|
|
|
|
const saved = store.saveSttProviderSetting('default', 'openai', {
|
|
settings: {
|
|
baseUrl,
|
|
},
|
|
})
|
|
|
|
expect(saved.settings.baseUrl).toContain(new URL(baseUrl).origin)
|
|
})
|
|
|
|
it('stores only supported audio transcode modes', async () => {
|
|
const { store } = await initStore()
|
|
|
|
const saved = store.saveSttProviderSetting('default', 'custom', {
|
|
settings: {
|
|
baseUrl: 'http://127.0.0.1:8000/v1',
|
|
model: 'whisper-1',
|
|
audioTranscode: 'ffmpeg',
|
|
},
|
|
})
|
|
expect(saved.settings.audioTranscode).toBe('ffmpeg')
|
|
|
|
const ignored = store.saveSttProviderSetting('default', 'custom', {
|
|
settings: {
|
|
audioTranscode: 'auto',
|
|
},
|
|
})
|
|
expect(ignored.settings.audioTranscode).toBe('ffmpeg')
|
|
})
|
|
})
|