Andrey Epifancev ab698d00f3 fix(core): redact() Content-Disposition cut index; errorText never throws; design.md accuracy
M-1: redact()'s Content-Disposition cut computed its index against a
lowercased copy but sliced the original string — some Unicode characters
change length under toLowerCase(), which could let a fragment of
"Content-Disposition" survive the cut. Now uses a case-insensitive regex
matched directly against the original string.

T3.5: errorText() could itself throw on a pathological thrown value (a
null-prototype object with no toString/valueOf/Symbol.toPrimitive),
defeating transcribe()'s "never throws" guarantee in an already-caught
branch. Now wrapped defensively with a generic fallback message.

Also corrects two stale descriptions in design.md (classify/redact
signatures, and the post-promise rejection handling) so the archived
design document matches the shipped code.

Refs #5

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 01:24:58 +04:00

206 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { buildMultipart, makeBoundary } from './multipart';
import { extFromMime } from './naming';
import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports';
export interface OpenAiSettings {
baseUrl: string;
model: string;
apiKey: string;
language: string;
}
export const DEFAULT_OPENAI: Readonly<OpenAiSettings> = {
baseUrl: 'https://api.aitunnel.ru/v1',
model: 'gpt-4o-transcribe',
apiKey: '',
language: '',
};
// MUST быть меньше SENT_STALE_MS (record.ts): иначе зависший sent
// восстановится раньше, чем провайдер сдастся по таймауту.
export const REQUEST_TIMEOUT_MS = 5 * 60_000;
export const NO_API_KEY = 'не задан API-ключ';
export const NO_BASE_URL = 'не задан адрес сервиса';
export const EMPTY_AUDIO = 'пустая запись';
export const CANCELLED = 'запрос отменён';
export const TIMED_OUT = 'таймаут запроса (5 мин)';
const DETAIL_LIMIT = 200;
const REASON_LIMIT = 300;
const FIELDS = ['baseUrl', 'model', 'apiKey', 'language'] as const;
// Сохранённое поверх умолчаний; нестроковое поле → умолчание; всё обрезается.
export function readSettings(saved: unknown): OpenAiSettings {
const src = saved !== null && typeof saved === 'object' ? (saved as Record<string, unknown>) : {};
const out: OpenAiSettings = { ...DEFAULT_OPENAI };
for (const key of FIELDS) {
const value = src[key];
if (typeof value === 'string') out[key] = value.trim();
}
return out;
}
function parseJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function textField(body: string): string | null {
const json = parseJson(body);
if (json === null || typeof json !== 'object') return null;
const text = (json as { text?: unknown }).text;
return typeof text === 'string' ? text : null;
}
function detail(body: string): string {
const json = parseJson(body);
if (json !== null && typeof json === 'object') {
const error = (json as { error?: unknown }).error;
if (error !== null && typeof error === 'object') {
const message = (error as { message?: unknown }).message;
if (typeof message === 'string' && message.trim() !== '') return message.trim();
}
}
return body.trim().slice(0, DETAIL_LIMIT);
}
// Причины здесь ещё не очищены: redact применяет OpenAiTranscriber.
export function classify(res: HttpResponse): TranscribeResult {
const { status, text } = res;
if (status >= 200 && status < 300) {
const value = textField(text);
if (value !== null) return { ok: true, text: value };
const snippet = text.trim().slice(0, DETAIL_LIMIT);
return {
ok: false,
kind: 'permanent',
message: `HTTP ${status}: ответ без поля text${snippet ? `: ${snippet}` : ''}`,
};
}
const info = detail(text);
const message = info ? `HTTP ${status}: ${info}` : `HTTP ${status}`;
// 3xx до нас доходит только при неверном baseUrl (редиректы адаптер проходит сам).
const permanent = status >= 300 && status < 500 && status !== 408 && status !== 429;
return { ok: false, kind: permanent ? 'permanent' : 'temporary', message };
}
// Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса.
export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string {
let out = message;
const cdMatch = /content-disposition/i.exec(out);
const cuts = [cdMatch ? cdMatch.index : -1];
if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary));
const cut = Math.min(...cuts.filter((i) => i >= 0), out.length);
out = out.slice(0, cut);
if (cut < message.length) out = out.replace(/[\s:]+$/, '');
if (secrets.apiKey) out = out.split(secrets.apiKey).join('***');
out = out.replace(/Bearer\s+\S+/g, 'Bearer ***');
return out.slice(0, REASON_LIMIT);
}
const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message });
const errorText = (e: unknown): string => {
try {
return e instanceof Error ? e.message : String(e);
} catch {
return 'неизвестная ошибка';
}
};
function partType(mime: string): string {
return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream';
}
export class OpenAiTranscriber implements Transcriber {
// settings — геттер: исправленный во вкладке ключ применяется к следующему
// запросу без пересоздания очереди.
constructor(
private readonly http: HttpClient,
private readonly settings: () => OpenAiSettings,
private readonly random: () => number,
) {}
// Никогда не бросает: любой исход — TranscribeResult с очищенной причиной.
async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult> {
let apiKey = '';
let boundary: string | undefined;
let result: TranscribeResult;
try {
const s = readSettings(this.settings());
apiKey = s.apiKey;
boundary = makeBoundary(this.random);
result = await this.request(s, audio, mime, signal, boundary);
} catch (e) {
result = fail('temporary', `внутренняя ошибка: ${errorText(e)}`);
}
if (result.ok) return result;
try {
return { ...result, message: redact(result.message, { apiKey, boundary }) };
} catch {
return { ...result, message: 'внутренняя ошибка' };
}
}
private request(
s: OpenAiSettings,
audio: ArrayBuffer,
mime: string,
signal: AbortSignal,
boundary: string,
): Promise<TranscribeResult> {
if (!s.apiKey) return Promise.resolve(fail('permanent', NO_API_KEY));
if (!s.baseUrl) return Promise.resolve(fail('permanent', NO_BASE_URL));
if (audio.byteLength === 0) return Promise.resolve(fail('permanent', EMPTY_AUDIO));
if (signal.aborted) return Promise.resolve(fail('temporary', CANCELLED));
const fields: [string, string][] = [
['model', s.model],
['response_format', 'json'],
];
if (s.language) fields.push(['language', s.language]);
const { body, contentType } = buildMultipart(
fields,
{ name: 'file', filename: `audio.${extFromMime(mime)}`, contentType: partType(mime), data: audio },
boundary,
);
const url = `${s.baseUrl.replace(/\/+$/, '')}/audio/transcriptions`;
// post вызывается синхронно (до того, как abort успеет сработать);
// синхронный throw клиента превращается в rejection.
let sent: Promise<HttpResponse>;
try {
sent = Promise.resolve(
this.http.post({ url, headers: { Authorization: `Bearer ${s.apiKey}` }, contentType, body }),
);
} catch (e) {
sent = Promise.reject(e);
}
const answer = sent.then(
(res) => classify(res),
(e: unknown) => fail('temporary', `сеть: ${errorText(e)}`),
);
return raceWithDeadline(answer, signal);
}
}
// requestUrl нельзя оборвать: при таймауте или отмене запрос доживает в фоне,
// его исход игнорируется. answer никогда не отклоняется — unhandled rejection нет.
function raceWithDeadline(answer: Promise<TranscribeResult>, signal: AbortSignal): Promise<TranscribeResult> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const deadline = new Promise<TranscribeResult>((resolve) => {
timer = setTimeout(() => resolve(fail('temporary', TIMED_OUT)), REQUEST_TIMEOUT_MS);
onAbort = () => resolve(fail('temporary', CANCELLED));
signal.addEventListener('abort', onAbort, { once: true });
});
return Promise.race([answer, deadline]).finally(() => {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
});
}