opencode-voice-dictation/tests/config.test.ts
Sergey c074dfd777
feat(transcription): custom endpoint and temperature (#43)
## Что сделано

- `src/types.ts`: добавлены поля `endpoint: string` и `temperature:
number` в интерфейс `AppConfig`.
- `src/config.ts`:
- `DEFAULTS.endpoint =
"https://api.groq.com/openai/v1/audio/transcriptions"` (обратная
совместимость).
  - `DEFAULTS.temperature = 0` (детерминированный вывод).
- `DEFAULTS.whisperPrompt` сокращён с ~270 до 107 символов — термины
через запятую, без предложений.
- `getConfig()` читает новые поля через `GM_getValue` с fallback на
`DEFAULTS`.
- Добавлены 2 меню-команды: «Set STT Endpoint» и «Set Temperature» в
`registerMenuCommands` (параметры `onSetEndpoint`, `onSetTemperature`).
- `src/index.ts`: реализованы `promptForEndpoint` (fallback на дефолт
при пустом вводе) и `promptForTemperature` (clamp к `[0, 1]`,
NaN-валидация, сохранение текущего значения при cancel),
зарегистрированы в `registerMenuCommands`.
- `src/transcribe.ts`: удалён хардкод `GROQ_API_URL`, используется
`config.endpoint`; в `buildFormData` добавлено
`formData.append("temperature", String(config.temperature))`.
- `vite.config.ts`: `@connect api.groq.com` → `@connect *` (разрешает
`GM_xmlhttpRequest` к любому домену).
- `README.md`: добавлены разделы «Custom STT Endpoint» (EN+RU) с
примером nginx `proxy_pass` и «Temperature».
- `tests/`: обновлены `mockConfig` (новые поля), ассерты на
`temperature` в FormData, тест на длину `whisperPrompt < 120`,
`registerMenuCommands` теперь ожидает 7 команд.

## Почему

Groq периодически блокирует прямые IP-запросы к `api.groq.com` — нужен
обход через пользовательский nginx-прокси (свой endpoint). Whisper
галлюцинирует на тишине/шуме — `temperature=0` снижает галлюцинации.
Длинный `whisperPrompt` с целыми предложениями мог «утекать» в вывод
транскрипции — сокращён до списка терминов через запятую (~107
символов). Хардкод `GROQ_API_URL` и `@connect api.groq.com` блокировали
использование кастомных доменов.

## Watch out

- `@connect *` в метаблоке расширяет поверхность запросов userscript-а
на любой домен — юзер должен доверять установленному endpoint. Это
намеренный trade-off для поддержки произвольных прокси.
- Существующие юзеры со старым `whisperPrompt` в `GM_getValue` сохраняют
своё значение (новый короткий default применяется только если ключ не
задан) — обратная совместимость сохранена.
- Кламп температуры: ввод `NaN` → toast «Invalid temperature» (значение
не меняется); ввод `5` → `1`; ввод `-0.3` → `0`.
- Пустой endpoint (юзер ввёл пробелы) → fallback на `DEFAULTS.endpoint`
(`api.groq.com`), не пустая строка.
- Cancel в prompt (Esc) по endpoint/temperature → значение не меняется
(поведение `null`-check).
- Error-сообщения `transcribe.ts` всё ещё упоминают «Groq API» —
оставлено намеренно, т.к. дефолтный endpoint = Groq и большинство юзеров
используют его.

## Pending

- Обновить `@version` в `vite.config.ts` при релизе (сейчас `1.0.3` — не
тронуто, решает релиз-процесс).
- Trim тишины (RMS-based) — явно вне scope этого issue (отдельный PR
если `temperature=0` не поможет).

Closes #42

Closes #42

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-04 12:32:39 +03:00

129 lines
3.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
vi.mock("$", () => ({
GM_getValue: vi.fn((key: string, defaultValue: unknown) => defaultValue),
GM_setValue: vi.fn(),
GM_registerMenuCommand: vi.fn(),
GM_xmlhttpRequest: vi.fn(),
}));
import { GM_getValue, GM_registerMenuCommand, GM_setValue } from "$";
import {
DEFAULTS,
getConfig,
isFirstRun,
registerMenuCommands,
setConfig,
validateApiKey,
} from "../src/config.js";
describe("DEFAULTS", () => {
it("should have whisper-large-v3 as default model", () => {
expect(DEFAULTS.model).toBe("whisper-large-v3");
});
it("should have empty language for auto-detect", () => {
expect(DEFAULTS.language).toBe("");
});
it("should have non-empty whisperPrompt", () => {
expect(DEFAULTS.whisperPrompt.length).toBeGreaterThan(50);
});
it("should have whisperPrompt under 120 characters (terms only, no sentences)", () => {
expect(DEFAULTS.whisperPrompt.length).toBeLessThan(120);
});
it("should have autoSubmit disabled by default", () => {
expect(DEFAULTS.autoSubmit).toBe(false);
});
it("should have default Groq endpoint", () => {
expect(DEFAULTS.endpoint).toBe("https://api.groq.com/openai/v1/audio/transcriptions");
});
it("should have temperature 0 by default", () => {
expect(DEFAULTS.temperature).toBe(0);
});
});
describe("validateApiKey", () => {
it("should accept valid key with gsk_ prefix", () => {
expect(validateApiKey("gsk_test_fake_key_1234567890abcdef")).toBe(true);
});
it("should reject key without gsk_ prefix", () => {
expect(validateApiKey("sk_test_key_12345")).toBe(false);
});
it("should reject key that is too short", () => {
expect(validateApiKey("gsk_short")).toBe(false);
});
it("should reject empty string", () => {
expect(validateApiKey("")).toBe(false);
});
});
describe("getConfig", () => {
it("should return defaults when GM storage is empty", () => {
vi.mocked(GM_getValue).mockImplementation((key: string, def: unknown) => def);
const config = getConfig();
expect(config.model).toBe(DEFAULTS.model);
expect(config.language).toBe(DEFAULTS.language);
expect(config.groqApiKey).toBe("");
});
it("should return stored values when present", () => {
vi.mocked(GM_getValue).mockImplementation((key: string, def: unknown) => {
if (key === "groqApiKey") {
return "gsk_stored_key";
}
if (key === "model") {
return "whisper-large-v3-turbo";
}
return def;
});
const config = getConfig();
expect(config.groqApiKey).toBe("gsk_stored_key");
expect(config.model).toBe("whisper-large-v3-turbo");
});
});
describe("isFirstRun", () => {
it("should return true when no API key stored", () => {
vi.mocked(GM_getValue).mockReturnValue("");
expect(isFirstRun()).toBe(true);
});
it("should return false when API key is stored", () => {
vi.mocked(GM_getValue).mockReturnValue("gsk_stored_key");
expect(isFirstRun()).toBe(false);
});
});
describe("setConfig", () => {
it("should call GM_setValue for each key", () => {
setConfig({ groqApiKey: "gsk_new", model: "whisper-large-v3-turbo" });
expect(GM_setValue).toHaveBeenCalledWith("groqApiKey", "gsk_new");
expect(GM_setValue).toHaveBeenCalledWith("model", "whisper-large-v3-turbo");
});
});
describe("registerMenuCommands", () => {
it("should register all menu commands", () => {
const callbacks = {
onSetKey: () => {},
onToggleAutoSubmit: () => {},
onSetModel: () => {},
onSetLanguage: () => {},
onSetPrompt: () => {},
onSetEndpoint: () => {},
onSetTemperature: () => {},
};
registerMenuCommands(callbacks);
expect(GM_registerMenuCommand).toHaveBeenCalledTimes(7);
});
});