fix: Opus review pass — harden before building

- Widen voice ID validation to 20-64 alphanumeric (future-proof)
- Remove hardcoded default voiceId (SJ personal clone)
- Require voiceId in isConfigured + synthesize guard with clear error
- Add model header comment explaining Fish Audio's non-standard API
- Truncate error bodies to 500 chars to prevent log pollution
- Update tests and README to match
This commit is contained in:
Clawdbot
2026-03-29 18:17:06 +11:00
parent 4842dc64a5
commit ed505dcce1
4 changed files with 43 additions and 23 deletions

View File

@@ -11,7 +11,10 @@ import { fishAudioTTS, listFishAudioVoices } from "./tts.js";
// ── Defaults ────────────────────────────────────────────────────────────────
const DEFAULT_FISH_AUDIO_BASE_URL = "https://api.fish.audio";
const DEFAULT_VOICE_ID = "8a2d42279389471993460b85340235c5"; // SJ voice
// No default voice — users must configure one. Fish Audio has no universal
// "default" voice like ElevenLabs does, and shipping a personal clone ID
// as default would be wrong for community users.
const DEFAULT_VOICE_ID = "";
const DEFAULT_MODEL = "s2-pro";
const DEFAULT_LATENCY = "normal" as const;
@@ -67,9 +70,11 @@ function normalizeModel(value: unknown): string {
return s || DEFAULT_MODEL;
}
/** Fish Audio ref IDs are 32-char hex strings */
/** Fish Audio voice ref IDs — alphanumeric, 20-64 chars. Permissive enough
* to handle future ID format changes while still rejecting path traversal
* and injection attempts. */
export function isValidFishAudioVoiceId(voiceId: string): boolean {
return /^[a-f0-9]{24,40}$/i.test(voiceId);
return /^[a-zA-Z0-9]{20,64}$/.test(voiceId);
}
// ── Config resolution ───────────────────────────────────────────────────────
@@ -270,11 +275,12 @@ export function buildFishAudioSpeechProvider(): SpeechProviderPlugin {
return raw as SpeechVoiceOption[];
},
isConfigured: ({ providerConfig }) =>
Boolean(
readFishAudioProviderConfig(providerConfig).apiKey ||
process.env.FISH_AUDIO_API_KEY,
),
isConfigured: ({ providerConfig }) => {
const config = readFishAudioProviderConfig(providerConfig);
const hasKey = Boolean(config.apiKey || process.env.FISH_AUDIO_API_KEY);
const hasVoice = Boolean(config.voiceId);
return hasKey && hasVoice;
},
synthesize: async (req) => {
const config = readFishAudioProviderConfig(req.providerConfig);
@@ -285,6 +291,13 @@ export function buildFishAudioSpeechProvider(): SpeechProviderPlugin {
throw new Error("Fish Audio API key missing");
}
const voiceId = trimToUndefined(overrides.voiceId) ?? config.voiceId;
if (!voiceId) {
throw new Error(
"Fish Audio: no voiceId configured. Set messages.tts.providers.fish-audio.voiceId",
);
}
// Pick format based on target channel
const useOpus = req.target === "voice-note";
const format = useOpus ? "opus" : "mp3";
@@ -298,7 +311,7 @@ export function buildFishAudioSpeechProvider(): SpeechProviderPlugin {
text: req.text,
apiKey,
baseUrl: config.baseUrl,
referenceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
referenceId: voiceId,
model: trimToUndefined(overrides.model) ?? config.model,
format,
latency: