0b44adbaea
- Add URL validation blocking localhost, private IPs, and non-http protocols - Use response Content-Type header instead of hardcoded audio/wav for URL downloads - Add 25 tests covering validation, SSRF, config errors, transcription paths, and error handling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
248 lines
7.4 KiB
TypeScript
248 lines
7.4 KiB
TypeScript
import type { Tool, ToolResult } from '../types.js';
|
|
|
|
interface AudioTranscribeArgs {
|
|
data?: string;
|
|
url?: string;
|
|
mime_type?: string;
|
|
language?: string;
|
|
prompt?: string;
|
|
}
|
|
|
|
const SUPPORTED_MIME_TYPES = new Set([
|
|
'audio/ogg',
|
|
'audio/mpeg',
|
|
'audio/mp3',
|
|
'audio/wav',
|
|
'audio/webm',
|
|
'audio/mp4',
|
|
'audio/x-m4a',
|
|
]);
|
|
|
|
const PROVIDER_ENDPOINTS: Record<string, string> = {
|
|
openai: 'https://api.openai.com/v1/audio/transcriptions',
|
|
groq: 'https://api.groq.com/openai/v1/audio/transcriptions',
|
|
ollama: 'http://localhost:11434/api/generate',
|
|
llamacpp: 'http://localhost:8080/v1/audio/transcriptions',
|
|
};
|
|
|
|
function validateUrl(url: string): { valid: boolean; error?: string } {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return { valid: false, error: `Invalid URL: ${url}` };
|
|
}
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return { valid: false, error: `Only http/https URLs are allowed, got ${parsed.protocol}` };
|
|
}
|
|
|
|
const hostname = parsed.hostname;
|
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') {
|
|
return { valid: false, error: 'URLs pointing to localhost are not allowed' };
|
|
}
|
|
|
|
// Block private/internal IP ranges
|
|
if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/.test(hostname)) {
|
|
return { valid: false, error: 'URLs pointing to private/internal networks are not allowed' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
function validateInput(args: AudioTranscribeArgs): { valid: boolean; error?: string } {
|
|
const hasData = args.data !== undefined && args.data !== '';
|
|
const hasUrl = args.url !== undefined && args.url !== '';
|
|
|
|
if (!hasData && !hasUrl) {
|
|
return { valid: false, error: 'Either data or url must be provided' };
|
|
}
|
|
|
|
if (hasData && hasUrl) {
|
|
return { valid: false, error: 'Only one of data or url can be provided' };
|
|
}
|
|
|
|
if (hasData && !args.mime_type) {
|
|
return { valid: false, error: 'mime_type is required when using data' };
|
|
}
|
|
|
|
if (args.mime_type && !SUPPORTED_MIME_TYPES.has(args.mime_type)) {
|
|
return { valid: false, error: `Unsupported MIME type: ${args.mime_type}. Supported: ${Array.from(SUPPORTED_MIME_TYPES).join(', ')}` };
|
|
}
|
|
|
|
if (hasUrl) {
|
|
const urlValidation = validateUrl(args.url!);
|
|
if (!urlValidation.valid) {
|
|
return urlValidation;
|
|
}
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
interface AudioTranscriptionConfig {
|
|
endpoint?: string;
|
|
apiKey?: string;
|
|
model?: string;
|
|
}
|
|
|
|
export function createAudioTranscribeTool(audioConfig?: AudioTranscriptionConfig): Tool {
|
|
return {
|
|
name: 'audio.transcribe',
|
|
description: 'Transcribe an audio file to text using a Whisper-compatible API. Accepts base64-encoded audio data or a URL to download to audio file.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
data: {
|
|
type: 'string',
|
|
description: 'Base64-encoded audio data (alternative to url)',
|
|
},
|
|
url: {
|
|
type: 'string',
|
|
description: 'URL to download to audio file (alternative to data)',
|
|
},
|
|
mime_type: {
|
|
type: 'string',
|
|
description: 'MIME type of audio file (required when using data, e.g., audio/ogg, audio/mpeg, audio/wav)',
|
|
},
|
|
language: {
|
|
type: 'string',
|
|
description: 'Language code (e.g., en, es, fr) - optional',
|
|
},
|
|
prompt: {
|
|
type: 'string',
|
|
description: 'Optional text to guide transcription (OpenAI/Groq/custom only)',
|
|
},
|
|
},
|
|
},
|
|
|
|
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
|
const args = rawArgs as AudioTranscribeArgs;
|
|
|
|
const validation = validateInput(args);
|
|
if (!validation.valid) {
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: validation.error,
|
|
};
|
|
}
|
|
|
|
if (!audioConfig?.endpoint) {
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: 'Audio transcription endpoint not configured. Set audio.endpoint in config.yaml',
|
|
};
|
|
}
|
|
|
|
try {
|
|
let filename = 'audio.bin';
|
|
let audioBlob: Blob | undefined;
|
|
|
|
if (args.data) {
|
|
const rawBuffer = Buffer.from(args.data, 'base64');
|
|
const audioBuffer = rawBuffer.buffer;
|
|
|
|
const extMap: Record<string, string> = {
|
|
'audio/ogg': 'ogg',
|
|
'audio/mpeg': 'mp3',
|
|
'audio/mp3': 'mp3',
|
|
'audio/wav': 'wav',
|
|
'audio/webm': 'webm',
|
|
'audio/mp4': 'm4a',
|
|
'audio/x-m4a': 'm4a',
|
|
};
|
|
const ext = extMap[args.mime_type!] || 'bin';
|
|
filename = `audio.${ext}`;
|
|
|
|
const mimeType = args.mime_type ?? 'audio/wav';
|
|
audioBlob = new Blob([audioBuffer], { type: mimeType });
|
|
} else if (args.url) {
|
|
const response = await fetch(args.url);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download audio from ${args.url}: ${response.status} ${response.statusText}`);
|
|
}
|
|
const arrayBuffer = await response.arrayBuffer();
|
|
|
|
const urlExt = args.url.split('.').pop()?.split('?')[0] || 'bin';
|
|
filename = `audio.${urlExt}`;
|
|
|
|
const contentType = response.headers.get('content-type') ?? 'audio/wav';
|
|
audioBlob = new Blob([arrayBuffer], { type: contentType });
|
|
}
|
|
|
|
const endpoint = audioConfig.endpoint;
|
|
const model = audioConfig.model ?? 'whisper-1';
|
|
|
|
if (endpoint === PROVIDER_ENDPOINTS.ollama) {
|
|
const ollamaResponse = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model: model,
|
|
audio: args.data ?? args.url,
|
|
stream: false,
|
|
}),
|
|
});
|
|
|
|
if (!ollamaResponse.ok) {
|
|
throw new Error(`Ollama transcription failed: ${ollamaResponse.status}`);
|
|
}
|
|
|
|
const ollamaJson = await ollamaResponse.json() as { response?: string };
|
|
return {
|
|
success: true,
|
|
output: ollamaJson.response ?? 'No response from Ollama',
|
|
};
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', audioBlob, filename);
|
|
formData.append('model', model);
|
|
|
|
if (args.language) {
|
|
formData.append('language', args.language);
|
|
}
|
|
|
|
if (args.prompt) {
|
|
formData.append('prompt', args.prompt);
|
|
}
|
|
|
|
const fetchOptions: RequestInit = {
|
|
method: 'POST',
|
|
body: formData,
|
|
};
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (audioConfig.apiKey) {
|
|
headers['Authorization'] = `Bearer ${audioConfig.apiKey}`;
|
|
}
|
|
|
|
if (Object.keys(headers).length > 0) {
|
|
fetchOptions.headers = headers;
|
|
}
|
|
|
|
const response = await fetch(endpoint, fetchOptions);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Transcription request failed (${response.status}): ${errorText}`);
|
|
}
|
|
|
|
const json = await response.json() as { text: string };
|
|
return {
|
|
success: true,
|
|
output: json.text,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|