feat(core): provider settings, response classification, secret redaction

Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
This commit is contained in:
Andrey Epifancev 2026-09-28 00:18:33 +04:00
parent 3f6debc18b
commit bfe44fa648
4 changed files with 260 additions and 0 deletions

102
src/core/openai.ts Normal file
View File

@ -0,0 +1,102 @@
import type { HttpResponse, TranscribeResult } 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 lower = out.toLowerCase();
const cuts = [lower.indexOf('content-disposition')];
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);
}

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { classify } from '../src/core/openai';
const res = (status: number, text = '') => ({ status, text });
describe('classify: 2xx', () => {
it('returns the text as is', () => {
expect(classify(res(200, '{"text":"привет"}'))).toEqual({ ok: true, text: 'привет' });
});
it('returns whitespace-only text unchanged (queue decides "no speech")', () => {
expect(classify(res(200, '{"text":" "}'))).toEqual({ ok: true, text: ' ' });
});
it.each(['<html>oops</html>', '{"foo":1}', '{"text":5}', 'null', '[]', '"str"'])(
'200 with %s → permanent',
(body) => {
expect(classify(res(200, body))).toEqual({
ok: false,
kind: 'permanent',
message: `HTTP 200: ответ без поля text: ${body}`,
});
},
);
it('cuts the body to 200 chars', () => {
const body = `<html>${'x'.repeat(1000)}`;
expect(classify(res(200, body))).toEqual({
ok: false,
kind: 'permanent',
message: `HTTP 200: ответ без поля text: ${body.slice(0, 200)}`,
});
});
it('empty 2xx body → permanent without snippet', () => {
expect(classify(res(204, ''))).toEqual({ ok: false, kind: 'permanent', message: 'HTTP 204: ответ без поля text' });
});
});
describe('classify: errors', () => {
it.each([301, 302, 399, 400, 401, 403, 404, 413, 415, 422, 499])('%i → permanent', (status) => {
expect(classify(res(status, 'x'))).toEqual({ ok: false, kind: 'permanent', message: `HTTP ${status}: x` });
});
it.each([100, 101, 408, 429, 500, 502, 503, 599, 600])('%i → temporary', (status) => {
expect(classify(res(status, 'x'))).toEqual({ ok: false, kind: 'temporary', message: `HTTP ${status}: x` });
});
it('takes error.message from an OpenAI-style body', () => {
expect(classify(res(401, '{"error":{"message":"Invalid API key"}}'))).toEqual({
ok: false,
kind: 'permanent',
message: 'HTTP 401: Invalid API key',
});
});
it.each(['{"error":{"message":""}}', '{"error":"bad"}', '{"error":null}'])(
'falls back to the body when error.message is unusable: %s',
(body) => {
expect(classify(res(400, body))).toEqual({ ok: false, kind: 'permanent', message: `HTTP 400: ${body}` });
},
);
it('cuts a plain body to 200 chars after trim', () => {
expect(classify(res(500, ` ${'y'.repeat(1000)}`))).toEqual({
ok: false,
kind: 'temporary',
message: `HTTP 500: ${'y'.repeat(200)}`,
});
});
it.each(['', ' \n '])('empty body %j → "HTTP <code>"', (body) => {
expect(classify(res(503, body))).toEqual({ ok: false, kind: 'temporary', message: 'HTTP 503' });
});
});

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { redact } from '../src/core/openai';
const KEY = 'sk-test-123';
describe('redact', () => {
it('replaces every occurrence of the key', () => {
expect(redact(`a ${KEY} b ${KEY}`, { apiKey: KEY })).toBe('a *** b ***');
});
it('masks any Bearer token', () => {
expect(redact('Authorization: Bearer abc.def-1', { apiKey: KEY })).toBe('Authorization: Bearer ***');
});
it('leaves the message alone for an empty key', () => {
expect(redact('HTTP 401: nope', { apiKey: '' })).toBe('HTTP 401: nope');
});
it('cuts at the multipart boundary and trims trailing ": "', () => {
const b = '----voice-note-0000';
expect(redact(`HTTP 400: echo --${b}\r\nContent-Disposition: form-data`, { apiKey: KEY, boundary: b })).toBe(
'HTTP 400: echo',
);
expect(redact(`HTTP 400: --${b}\r\n`, { apiKey: KEY, boundary: b })).toBe('HTTP 400');
});
it('cuts at Content-Disposition regardless of case', () => {
expect(redact('HTTP 400: got content-disposition: form-data; name="file"', { apiKey: KEY })).toBe('HTTP 400: got');
});
it('caps the result at 300 chars', () => {
expect(redact('x'.repeat(500), { apiKey: KEY })).toHaveLength(300);
});
it('replaces the key before capping, so no key fragment survives the cut', () => {
const out = redact(`${'y'.repeat(295)}${KEY}`, { apiKey: KEY });
expect(out).toBe(`${'y'.repeat(295)}***`);
expect(out).not.toContain('sk-');
});
});

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_OPENAI, readSettings } from '../src/core/openai';
describe('DEFAULT_OPENAI', () => {
it('points to AITUNNEL gpt-4o-transcribe with no key and auto language', () => {
expect(DEFAULT_OPENAI).toEqual({
baseUrl: 'https://api.aitunnel.ru/v1',
model: 'gpt-4o-transcribe',
apiKey: '',
language: '',
});
});
});
describe('readSettings', () => {
it.each([undefined, null, 'str', 42, []])('falls back to defaults for %j', (saved) => {
expect(readSettings(saved)).toEqual(DEFAULT_OPENAI);
});
it('overlays saved fields on defaults', () => {
expect(readSettings({ apiKey: 'sk-test-1' })).toEqual({ ...DEFAULT_OPENAI, apiKey: 'sk-test-1' });
});
it('trims all four fields', () => {
expect(
readSettings({ baseUrl: ' https://x/v1 ', model: ' m \n', apiKey: ' sk-test-1 \n', language: ' ru ' }),
).toEqual({ baseUrl: 'https://x/v1', model: 'm', apiKey: 'sk-test-1', language: 'ru' });
});
it('uses the default for a non-string field', () => {
expect(readSettings({ model: 5, apiKey: null })).toEqual(DEFAULT_OPENAI);
});
it('keeps an explicitly blanked baseUrl blank (precheck reports it)', () => {
expect(readSettings({ baseUrl: ' ' }).baseUrl).toBe('');
});
it('returns a fresh object', () => {
const s = readSettings(undefined);
s.apiKey = 'changed';
expect(DEFAULT_OPENAI.apiKey).toBe('');
});
});