Ctrl K
ComunidadGuías…

voice-transcription/openai-stt.client.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { randomBytes } from 'node:crypto';
import type { PluginNetCapability } from '../types/openwa';
import { buildMultipartBody, MultipartField } from './multipart.ts';

/** Derive the multipart part Content-Type from an untrusted media mimetype: strip the codec suffix, then
 *  accept only a well-formed `type/subtype` token. Anything else (empty, spaces, an injected CRLF) falls
 *  back to audio/ogg — the part filename is fixed to voice.ogg, so the decoder keys off that regardless. */
export function sanitizeContentType(mimetype: string): string {
  const token = mimetype.split(';')[0].trim();
  return /^[\w.+-]+\/[\w.+-]+$/.test(token) ? token : 'audio/ogg';
}

export interface SttResult {
  text: string;
  language?: string;
}

export interface SttProvider {
  transcribe(audio: Uint8Array, mimetype: string): Promise<SttResult>;
}

export interface OpenAiSttOptions {
  /** Base URL of an OpenAI-compatible STT server (e.g. http://localhost:8000 — Speaches/faster-whisper). */
  baseUrl: string;
  apiKey?: string;
  model: string;
  /** Optional language hint (BCP-47); empty/undefined = auto-detect. */
  language?: string;
  timeoutMs: number;
  net: PluginNetCapability;
  /** Consecutive failures before the circuit opens (default 5). */
  failureThreshold?: number;
  /** How long the circuit stays open after tripping (default 30000ms). */
  cooldownMs?: number;
  /** Injectable clock (defaults to Date.now). */
  now?: () => number;
}

/**
 * Calls an OpenAI-compatible `/v1/audio/transcriptions` endpoint over the host-proxied, SSRF-guarded
 * `ctx.net.fetch`. The audio is uploaded as a binary multipart body (a Buffer) — it crosses the
 * sandbox→host boundary via structuredClone intact, which a string body could not.
 */
/**
 * True when the host refused the call because IT is saturated, not because the backend failed. Both
 * limits are shared with every other plugin on the gateway, so this says nothing about the STT service
 * and must not count toward the circuit breaker. The host throws a plain Error carrying no code, so the
 * message is the only discriminator available; the match is deliberately on the phrase both messages
 * share rather than on either exact string.
 */
export function isHostBackpressure(err: unknown): boolean {
  const message = err instanceof Error ? err.message : String(err ?? '');
  return /too many concurrent/i.test(message);
}

export class OpenAiSttClient implements SttProvider {
  private readonly base: string;
  private readonly failureThreshold: number;
  private readonly cooldownMs: number;
  private readonly now: () => number;
  private consecutiveFailures = 0;
  private openUntil = 0;

  constructor(private readonly opts: OpenAiSttOptions) {
    this.base = opts.baseUrl.replace(/\/+$/, '');
    this.failureThreshold = opts.failureThreshold ?? 5;
    this.cooldownMs = opts.cooldownMs ?? 30000;
    this.now = opts.now ?? (() => Date.now());
  }

  isHealthy(): boolean {
    return this.consecutiveFailures < this.failureThreshold;
  }

  async transcribe(audio: Uint8Array, mimetype: string): Promise<SttResult> {
    // Circuit breaker: while open, fail fast without touching a known-bad backend.
    if (this.now() < this.openUntil) {
      throw new Error('STT circuit open');
    }
    try {
      const result = await this.doTranscribe(audio, mimetype);
      this.consecutiveFailures = 0;
      return result;
    } catch (err) {
      // Host backpressure is not a backend failure. The host rejects a fetch past its GLOBAL 16-slot
      // limit ("too many concurrent plugin net.fetch calls") and a capability call past the per-plugin
      // in-flight limit ("too many concurrent capability calls"), both instantly and both shared with
      // every other plugin on the gateway. Counting them opened the breaker on a perfectly healthy
      // backend: a busy line saturates the pool, the rejections are immediate and therefore
      // consecutive, and five in a row stopped transcription for the whole cooldown. Matched on the
      // message because the host throws a plain Error with no code; matched loosely so a reworded
      // limit still lands here rather than being counted as a backend fault.
      if (!isHostBackpressure(err)) {
        this.consecutiveFailures++;
        if (this.consecutiveFailures >= this.failureThreshold) {
          this.openUntil = this.now() + this.cooldownMs;
        }
      }
      throw err;
    }
  }

  private async doTranscribe(audio: Uint8Array, mimetype: string): Promise<SttResult> {
    // Random from the CSPRNG, not Math.random: the audio bytes come from a sender, and a boundary that
    // can be predicted is one that can be embedded to forge extra parts. Matches the other two producers.
    const boundary = `----openwaFormBoundary${randomBytes(16).toString('hex')}`;
    const fields: MultipartField[] = [
      { name: 'model', value: this.opts.model },
      { name: 'response_format', value: 'json' },
    ];
    if (this.opts.language) fields.push({ name: 'language', value: this.opts.language });

    // Strip the codec suffix ("audio/ogg; codecs=opus" → "audio/ogg") and always name the part
    // `voice.ogg`: OpenAI-compatible servers key the decoder off the filename extension and reject a
    // bare ".opus", but accept ogg/oga.
    const contentType = sanitizeContentType(mimetype);
    const formBody = buildMultipartBody(boundary, fields, [
      { name: 'file', filename: 'voice.ogg', contentType, data: audio },
    ]);

    const headers: Record<string, string> = { 'content-type': `multipart/form-data; boundary=${boundary}` };
    if (this.opts.apiKey) headers['authorization'] = `Bearer ${this.opts.apiKey}`;

    const response = await this.opts.net.fetch(`${this.base}/v1/audio/transcriptions`, {
      method: 'POST',
      headers,
      body: formBody,
      timeoutMs: this.opts.timeoutMs,
    });
    if (!response.ok) {
      throw new Error(`STT request failed: HTTP ${response.status}`);
    }
    const data = JSON.parse(response.body) as { text?: unknown; language?: unknown };
    if (typeof data?.text !== 'string') {
      throw new Error('STT response contained no text');
    }
    return { text: data.text, language: typeof data.language === 'string' ? data.language : undefined };
  }
}