diff --git a/src/core/openai.ts b/src/core/openai.ts new file mode 100644 index 0000000..404afed --- /dev/null +++ b/src/core/openai.ts @@ -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 = { + 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) : {}; + 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); +} diff --git a/tests/openai-classify.test.ts b/tests/openai-classify.test.ts new file mode 100644 index 0000000..2fda343 --- /dev/null +++ b/tests/openai-classify.test.ts @@ -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(['oops', '{"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 = `${'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 "', (body) => { + expect(classify(res(503, body))).toEqual({ ok: false, kind: 'temporary', message: 'HTTP 503' }); + }); +}); diff --git a/tests/openai-redact.test.ts b/tests/openai-redact.test.ts new file mode 100644 index 0000000..d1a0f97 --- /dev/null +++ b/tests/openai-redact.test.ts @@ -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-'); + }); +}); diff --git a/tests/openai-settings.test.ts b/tests/openai-settings.test.ts new file mode 100644 index 0000000..85d6b45 --- /dev/null +++ b/tests/openai-settings.test.ts @@ -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(''); + }); +});