feat: refine language controls and add per-tier ChatGPT Fast mode

This commit is contained in:
2026-10-25 21:19:48 +03:00
parent f8ce0f21ef
commit 8c564946ef
17 changed files with 488 additions and 37 deletions
+35
View File
@@ -170,6 +170,41 @@ test("case-insensitive Authorization overrides including null are refused before
assert.equal(requests.length, before);
});
test("Fast opt-in pins priority on the actual OAuth request without altering reasoning or accepting caller tier overrides", async () => {
for (const fastMode of [undefined, false, true]) {
requests = []; responseEvents = [complete()];
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, thinkingLevel: "high", fastMode }, false);
const result = await engine.streamFn(engine.model, context(), {
samplingParams: { service_tier: "flex" },
onPayload: (raw) => ({ ...raw as object, service_tier: "default" }),
}).result();
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
assert.equal(requests[0]!.body.service_tier, fastMode ? "priority" : undefined);
assert.equal(requests[0]!.body.reasoning.effort, "high");
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
}
});
test("the Fast preference never injects priority into an API-key provider request", async () => {
requests = []; responseEvents = [complete()];
const engine = piEngineForSpec({ provider: "openai", id: model.id, fastMode: true }, false);
const result = await engine.streamFn(engine.model, context(), { apiKey: "sk-offline-api-key-fixture" }).result();
assert.equal(result.stopReason, "stop"); assert.equal(requests.length, 1);
assert.equal(requests[0]!.body.service_tier, undefined);
assert.equal(requests[0]!.authorization, "Bearer sk-offline-api-key-fixture");
});
test("an unsupported priority tier gives actionable feedback without retrying or using an API key", async () => {
requests = [];
responseEvents = [{ type: "error", code: "unsupported_parameter", param: "service_tier", message: "private provider diagnostic" }];
const engine = piEngineForSpec({ provider: "chatgpt", id: model.id, fastMode: true }, false);
const result = await engine.streamFn(engine.model, context()).result();
assert.equal(result.stopReason, "error"); assert.equal(requests.length, 1);
assert(result.errorMessage?.includes("Turn off Fast mode"));
assert(!result.errorMessage?.includes("private provider diagnostic"));
assert.equal(requests[0]!.authorization, "Bearer scratch-oauth-only");
});
test("full credential-storage invalidation aborts foreground and background controllers and clears capabilities", () => {
const foreground = chatGPTAbortController(false), background = chatGPTAbortController(true);
ipc({ channel: "lh:chatgpt", kind: "state", stamp: { epoch: 2, authorityRevision: 2, revision: 3 }, status: { ...status, available: false }, invalidate: "all" });
+74
View File
@@ -0,0 +1,74 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, it } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { AgentSection } from "../components/settings/agent-section.tsx";
import { I18nProvider } from "../components/i18n.tsx";
import type { ModelCatalogEntry, ModelsSettings } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true });
const originalFetch = globalThis.fetch;
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
const api = { provider: "openai", id: "gpt-5" };
const fixture: ModelCatalogEntry = { ...chatgpt, name: "Offline model", reasoning: true, input: ["text", "image"], supportedLevels: ["low", "high"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 2048 };
let root: Root | null = null;
let host: HTMLDivElement;
let writes: ModelsSettings[];
let saved: ModelsSettings;
let waitForSave: (() => Promise<void>) | undefined;
let failSave = false;
function button(slot: string, kind = "fast"): HTMLButtonElement {
const found = document.querySelector<HTMLButtonElement>(`[data-testid="slot-${kind}-${slot}"]`);
assert(found); return found;
}
async function click(target: HTMLButtonElement) { await act(async () => target.click()); }
async function mount() {
writes = []; waitForSave = undefined; failSave = false;
saved = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: chatgpt, thinkingLevel: "low" }, vision: { model: api } };
const responses: Record<string, unknown> = {
"/api/models": { models: [fixture, { ...fixture, provider: "openai" }], slots: saved },
"/api/search": { available: false, engine: null, reason: "No search engine is configured." },
"/api/keys": { keys: [] }, "/api/settings/prompts": { prober: [], teacher: [] },
};
globalThis.fetch = async input => { const name = String(input); assert(name in responses); return Response.json(responses[name]); };
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
function Harness() {
const [models, setModels] = React.useState(saved);
return <I18nProvider initialGeneral={{ language: "ru" }}><AgentSection agent={undefined} models={models} graph={undefined} compaction={undefined} onSectionChange={() => {}} onCompactionChange={() => {}} onModelsChange={async next => {
writes.push(next); await waitForSave?.();
if (failSave) throw new Error("offline save failure");
saved = next; setModels(next);
}} /></I18nProvider>;
}
await act(async () => { root!.render(<Harness />); });
}
afterEach(async () => { await act(async () => root?.unmount()); root = null; host?.remove(); globalThis.fetch = originalFetch; });
it("Fast controls wait for stored truth, lock related edits while saving, and preserve other tiers", async () => {
await mount();
assert.equal(button("strong").getAttribute("aria-pressed"), "false");
assert.equal(button("vision").disabled, true);
assert.equal(button("strong").textContent, "Быстро");
let release!: () => void;
const pending = new Promise<void>(resolve => { release = resolve; }); waitForSave = () => pending;
await click(button("strong"));
assert.equal(writes.length, 1);
for (const slot of ["strong", "cheap"]) for (const kind of ["fast", "model", "thinking"]) assert.equal(button(slot, kind).disabled, true);
assert.equal(button("strong").getAttribute("aria-pressed"), "false", "unconfirmed billing choices must not appear saved");
await act(async () => { release(); }); waitForSave = undefined;
assert.equal(button("strong").getAttribute("aria-pressed"), "true");
await click(button("cheap"));
assert.equal(saved.strong?.fastMode, true); assert.equal(saved.cheap?.fastMode, true);
assert.equal(saved.strong?.thinkingLevel, "high"); assert.equal(saved.vision?.fastMode, undefined);
});
it("failed Fast saves retain the previous choice, show a Russian error, and allow retry", async () => {
await mount(); failSave = true;
await click(button("strong"));
assert.equal(button("strong").getAttribute("aria-pressed"), "false"); assert.equal(button("strong").disabled, false);
assert.equal(document.querySelector('[role="alert"]')?.textContent, "Не удалось сохранить настройки моделей");
failSave = false; await click(button("strong"));
assert.equal(button("strong").getAttribute("aria-pressed"), "true"); assert.equal(document.querySelector('[role="alert"]'), null);
});
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { it } from "node:test";
import { sanitizeUserSettings, type ModelsSettings, type UserSettings } from "../src/core/settings.ts";
import { resolveConversationSpec, resolveModelSpec, resolveRoleSpec, resolveSettings } from "../src/core/settings-resolution.ts";
import { parseModelsSettingsBody } from "../src/server/settings-body.ts";
import { clampModelsLevels } from "../src/server/model-catalog.ts";
import { composeSlotFast, composeSlotModel } from "../components/settings/agent-section.tsx";
import { chatGPTPayload, chatGPTStreamError } from "../src/agent/chatgpt-stream.ts";
import { translate } from "../src/core/i18n.ts";
const chatgpt = { provider: "chatgpt", id: "gpt-5" };
const api = { provider: "openai", id: "gpt-5" };
it("validates and saves explicit per-tier Fast choices, dropping invalid and unsupported saved flags", () => {
const models = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: api } };
assert.deepEqual(parseModelsSettingsBody(models), { ok: true, settings: models });
assert.deepEqual(sanitizeUserSettings({ version: 1, models })?.models, models);
for (const fastMode of ["true", 1, null]) assert.equal(parseModelsSettingsBody({ strong: { model: chatgpt, fastMode } }).ok, false);
assert.equal(parseModelsSettingsBody({ strong: { model: api, fastMode: true } }).ok, false);
assert.deepEqual(sanitizeUserSettings({ version: 1, models: { strong: { model: api, fastMode: true }, cheap: { model: chatgpt, fastMode: "true" } } })?.models, { strong: { model: api }, cheap: { model: chatgpt } });
});
it("toggles one tier without altering its model, reasoning, or siblings; a provider swap clears Fast", () => {
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high" }, cheap: { model: api, thinkingLevel: "low" } };
const enabled = composeSlotFast(models, "strong", true);
assert.deepEqual(enabled, { ...models, strong: { ...models.strong, fastMode: true } });
assert.equal(models.strong?.fastMode, undefined);
assert.deepEqual(composeSlotFast(enabled, "cheap", true), enabled);
assert.equal(composeSlotModel(enabled, "strong", "chatgpt", "another-model", "low").strong?.fastMode, true);
assert.equal(composeSlotModel(enabled, "strong", "openai", "gpt-5", "high").strong?.fastMode, undefined);
assert.equal(composeSlotFast(enabled, "strong", false).strong?.fastMode, false);
});
it("updates existing conversations on the next resolution without replacing their saved model or reasoning", () => {
const chat = { model: { provider: "chatgpt", id: "saved-chat-model" }, thinkingLevel: "low" as const };
const before = structuredClone(chat);
const strong = { provider: "chatgpt", id: "new-session-default", thinkingLevel: "high" as const, fastMode: true };
assert.deepEqual(resolveConversationSpec(chat, strong), { provider: "chatgpt", id: "saved-chat-model", thinkingLevel: "low", fastMode: true });
assert.equal(resolveConversationSpec(chat, { ...strong, fastMode: false })?.fastMode, undefined);
assert.equal(resolveConversationSpec({ ...chat, model: api }, strong)?.fastMode, undefined);
assert.equal(resolveConversationSpec(chat, { ...strong, provider: "openai" })?.fastMode, undefined);
assert.deepEqual(chat, before);
});
it("background work uses only its mapped tier's Fast preference and never opts another provider in", () => {
const settings: UserSettings = { version: 1, models: { strong: { model: chatgpt, fastMode: true }, cheap: { model: chatgpt, fastMode: false }, vision: { model: chatgpt, fastMode: true } } };
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, undefined);
assert.equal(resolveRoleSpec("visualMaker", settings)?.fastMode, true);
assert.equal(resolveRoleSpec("pdfReader", settings)?.fastMode, true);
settings.models!.cheap!.fastMode = true;
assert.equal(resolveRoleSpec("researcher", settings)?.fastMode, true);
assert.equal(resolveModelSpec("researcher", { model: api }, settings.models)?.fastMode, undefined);
assert.equal(resolveSettings({ version: 1, models: { strong: { model: api, fastMode: true } } }).models.strong?.fastMode, undefined);
});
it("the server's reasoning clamp preserves Fast rather than silently dropping the opt-in on write", () => {
const models: ModelsSettings = { strong: { model: chatgpt, thinkingLevel: "high", fastMode: true }, cheap: { model: api, thinkingLevel: "low" } };
assert.equal(clampModelsLevels(models).strong?.fastMode, true);
assert.equal(clampModelsLevels(models).strong?.thinkingLevel, "high");
});
it("Fast is off for old records, request sanitization pins the chosen tier, and unsupported-mode errors are localized", () => {
const settings = resolveSettings({ version: 1, models: { strong: { model: chatgpt } } });
assert.equal(settings.models.strong?.fastMode, undefined);
assert.equal(chatGPTPayload({ input: [], service_tier: "priority" }, "gpt-5").service_tier, undefined);
assert.equal(chatGPTPayload({ input: [], service_tier: "flex" }, "gpt-5", true).service_tier, "priority");
const error = chatGPTStreamError("400 invalid service_tier");
assert(error.includes("Turn off Fast mode"));
assert(translate("ru", error).includes("Отключите быстрый режим"));
assert(chatGPTStreamError("429 service_tier quota exceeded").includes("https://chatgpt.com/settings/usage"));
});
+2 -3
View File
@@ -90,10 +90,9 @@ describe("translation quality and rendering", () => {
it("offers independent saved language choices with localized accessible labels", () => {
const html = renderToStaticMarkup(<I18nProvider initialGeneral={{ language: "ru", sttLanguage: "en" }}><LanguageSelect field="sttLanguage" /></I18nProvider>);
assert(html.includes('aria-label="Язык распознавания речи"'));
assert(html.includes('value="en" selected=""'));
assert(html.includes("Язык системы"));
assert(html.includes('aria-haspopup="menu"'));
assert(html.includes('aria-expanded="false"'));
assert(html.includes("English"));
assert(html.includes("Русский"));
});
it("translates dialog text and accessibility labels", () => {
const html = renderToStaticMarkup(<I18nProvider system="ru"><AbortTurnBody mode="turn" onClose={() => {}} onConfirm={() => {}} /></I18nProvider>);
+145
View File
@@ -0,0 +1,145 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, it } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { parseHTML } from "linkedom";
import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx";
import { translate, type GeneralSettings } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, {
window: dom.window, document: dom.document, Element: dom.window.Element,
HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement,
HTMLTextAreaElement: dom.window.HTMLTextAreaElement, CustomEvent: dom.window.CustomEvent,
IS_REACT_ACT_ENVIRONMENT: true,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(performance.now()), 0),
cancelAnimationFrame: (id: number) => clearTimeout(id),
});
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { languages: ["en-US"] } });
Object.assign(dom.window, { innerWidth: 1280, innerHeight: 800 });
const originalFetch = globalThis.fetch;
let root: Root | null = null;
let host: HTMLDivElement;
let general: GeneralSettings;
let writes: GeneralSettings[];
let failSave = false;
let holdSave: (() => Promise<void>) | undefined;
let recording: (active: boolean) => void;
function element(selector: string): HTMLElement {
const found = document.querySelector<HTMLElement>(selector);
assert(found, `expected ${selector}`);
return found;
}
function trigger(field: string, compact = false) { return element(`[data-testid="language-select-${field}${compact ? "-compact" : ""}"]`); }
async function click(target: HTMLElement) {
await act(async () => {
target.dispatchEvent(new dom.window.Event("pointerdown", { bubbles: true }));
target.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
});
}
async function pick(text: string) {
const option = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')].find((node) => node.textContent?.includes(text));
assert(option, `expected option ${text}`);
await click(option);
}
async function mount() {
general = { language: "ru", sttLanguage: "en", autoUpdate: false };
writes = []; failSave = false; holdSave = undefined;
globalThis.fetch = async (input, init) => {
const url = String(input);
if ((init?.method ?? "GET") === "GET") {
assert.equal(url, "/api/settings");
return Response.json({ settings: { general } });
}
assert.equal(url, "/api/settings/general"); assert.equal(init?.method, "PUT");
const next = JSON.parse(String(init.body)) as GeneralSettings;
writes.push(next);
await holdSave?.();
if (failSave) return new Response("failed", { status: 503 });
general = next;
return Response.json({ ok: true });
};
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
function Controls() {
recording = useI18n().setDictating;
return <><LanguageSelect field="language" /><LanguageSelect field="sttLanguage" /><LanguageSelect field="sttLanguage" compact /></>;
}
await act(async () => { root!.render(<I18nProvider initialGeneral={general}><Controls /></I18nProvider>); });
}
afterEach(async () => {
await act(async () => { root?.unmount(); }); root = null; host?.remove(); globalThis.fetch = originalFetch;
});
it("picks languages through the shared menu and preserves the other language and update preference", async () => {
await mount();
assert.equal(document.querySelector("select"), null);
await click(trigger("sttLanguage"));
assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3);
assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("English"));
assert(document.querySelector('[role="menu"]')?.textContent?.includes("Язык системы"));
await pick("Русский");
assert.deepEqual(general, { language: "ru", sttLanguage: "ru", autoUpdate: false });
assert(trigger("sttLanguage", true).textContent?.includes("Русский"));
assert.equal(document.querySelector('[role="menu"]'), null);
await click(trigger("language")); await pick("Язык системы");
assert.deepEqual(general, { sttLanguage: "ru", autoUpdate: false });
});
it("dismisses outside clicks and Escape, and opening another selector balances menu scroll locks", async () => {
await mount();
let opened = 0;
const onOpen = () => opened++; const onClose = () => opened--;
window.addEventListener(MENU_OPEN_EVENT, onOpen); window.addEventListener(MENU_CLOSE_EVENT, onClose);
try {
await click(trigger("language")); assert.equal(opened, 1);
await click(trigger("sttLanguage", true)); assert.equal(opened, 1);
assert.equal(trigger("language").getAttribute("aria-expanded"), "false");
await act(async () => {
const event = new dom.window.Event("keydown", { bubbles: true, cancelable: true });
Object.assign(event, { key: "Escape" }); element('[role="menuitemradio"]').dispatchEvent(event);
});
assert.equal(opened, 0);
await click(trigger("language")); await click(document.body);
assert.equal(opened, 0); assert.equal(writes.length, 0);
} finally { window.removeEventListener(MENU_OPEN_EVENT, onOpen); window.removeEventListener(MENU_CLOSE_EVENT, onClose); }
});
it("locks both STT selectors and closes an open menu when recording begins", async () => {
await mount(); await click(trigger("sttLanguage", true));
await act(async () => recording(true));
assert.equal(document.querySelector('[role="menu"]'), null);
assert((trigger("sttLanguage") as HTMLButtonElement).disabled);
assert((trigger("sttLanguage", true) as HTMLButtonElement).disabled);
assert(!(trigger("language") as HTMLButtonElement).disabled);
await click(trigger("sttLanguage")); assert.equal(document.querySelector('[role="menu"]'), null);
await act(async () => recording(false));
assert(!(trigger("sttLanguage", true) as HTMLButtonElement).disabled);
assert.equal(trigger("sttLanguage", true).getAttribute("aria-expanded"), "false");
assert.equal(writes.length, 0);
});
it("shows a localized save failure without changing the selected language and allows retry", async () => {
await mount(); failSave = true;
await click(trigger("sttLanguage")); await pick("Русский");
assert.equal(element('[role="alert"]').textContent, translate("ru", "Could not save language settings"));
assert.equal(general.sttLanguage, "en"); assert(trigger("sttLanguage").textContent?.includes("English"));
failSave = false;
await click(trigger("sttLanguage")); await pick("Русский");
assert.equal(general.sttLanguage, "ru"); assert.equal(document.querySelector('[role="alert"]'), null);
});
it("serializes rapid picks in independent selectors without losing either preference", async () => {
await mount(); let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
holdSave = () => pending;
await click(trigger("language")); await pick("English");
assert((trigger("language") as HTMLButtonElement).disabled);
await click(trigger("sttLanguage")); await pick("Русский");
assert.equal(writes.length, 1);
await act(async () => { release(); });
assert.deepEqual(general, { language: "en", sttLanguage: "ru", autoUpdate: false });
assert.equal(writes.length, 2);
});