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
+11 -5
View File
@@ -12,6 +12,7 @@ export function chatGPTStreamError(message: string): string {
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
if (/service[_ -]?tier|priority processing|fast mode/i.test(message)) return "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.";
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
@@ -19,9 +20,10 @@ export function chatGPTStreamError(message: string): string {
}
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> {
export function chatGPTPayload(raw: unknown, selectedModelId: string, fastMode = false): Record<string, unknown> {
const payload = raw as Record<string, unknown>;
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
if (fastMode) out.service_tier = "priority";
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
if (payload[key] !== undefined) out[key] = payload[key];
}
@@ -44,7 +46,7 @@ export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<A
function empty(model: Model<Api>): AssistantMessage {
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
}
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle, fastMode = false): StreamFunction<Api, SimpleStreamOptions> {
return (logicalModel, context, options) => {
const output = new AssistantMessageEventStream();
const purpose = background();
@@ -83,14 +85,18 @@ export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDisp
},
onPayload: async (raw, model) => {
const replacement = await options?.onPayload?.(raw, model);
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id, fastMode);
check();
return payload;
},
onProviderStreamEvent: async (raw, model) => {
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
const event = raw as { type?: string; code?: string; param?: string; error?: { code?: string; param?: string }; response?: { error?: { code?: string; param?: string }; status?: string } };
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) {
const code = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
failure = [event.param, event.error?.param, event.response?.error?.param].includes("service_tier")
? `${code} service_tier` : code;
}
await options?.onProviderStreamEvent?.(raw, model);
},
});
+2 -1
View File
@@ -80,6 +80,7 @@ export interface ResolvedModelSpec {
provider: string;
id: string;
thinkingLevel?: ThinkingLevel;
fastMode?: boolean;
}
/** The first entry of the baked default order the model supports (owner,
@@ -119,7 +120,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
// the pin honest even when config or settings drift off the map.
streamFn: (requestModel, context, options) => {
const purpose = typeof background === "function" ? background : () => background;
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle, spec.fastMode === true) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
},
};
+7 -1
View File
@@ -835,5 +835,11 @@
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "search engine is set to \"{v0}\" but its API key is missing — set the {v1} key",
"search engine: {v0} (chosen)": "search engine: {v0} (chosen)",
"search engine: {v0} (auto)": "search engine: {v0} (auto)",
"update": "update"
"update": "update",
"Fast": "Fast",
"{v0} fast mode": "{v0} fast mode",
"Fast mode uses more ChatGPT allowance.": "Fast mode uses more ChatGPT allowance.",
"Fast mode is available only for ChatGPT models.": "Fast mode is available only for ChatGPT models.",
"Could not save model settings": "Could not save model settings",
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model."
}
+7 -1
View File
@@ -871,5 +871,11 @@
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "Выбрана поисковая система «{v0}», но её API-ключ отсутствует — добавьте ключ {v1}",
"search engine: {v0} (chosen)": "Поисковая система: {v0} (выбрана вручную)",
"search engine: {v0} (auto)": "Поисковая система: {v0} (автоматически)",
"update": "обновление"
"update": "обновление",
"Fast": "Быстро",
"{v0} fast mode": "Быстрый режим: {v0}",
"Fast mode uses more ChatGPT allowance.": "Быстрый режим быстрее расходует лимит ChatGPT.",
"Fast mode is available only for ChatGPT models.": "Быстрый режим доступен только для моделей ChatGPT.",
"Could not save model settings": "Не удалось сохранить настройки моделей",
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Быстрый режим недоступен для этой модели или учётной записи ChatGPT. Отключите быстрый режим или выберите другую модель."
}
+8 -1
View File
@@ -52,6 +52,7 @@ export interface EffectiveSlot {
provider: string;
id: string;
thinkingLevel?: ThinkingLevel;
fastMode?: boolean;
}
/** A fully-resolved model pin for one role — same shape/level semantics
@@ -61,6 +62,7 @@ export interface EffectiveModelSpec {
provider: string;
id: string;
thinkingLevel?: ThinkingLevel;
fastMode?: boolean;
}
/** The concrete appearance view — same shape as the factory's. */
@@ -110,6 +112,8 @@ export function resolveConversationSpec(
provider: model.provider,
id: model.id,
thinkingLevel: chat?.thinkingLevel ?? strong?.thinkingLevel,
// Speed is a live tier preference, including for existing conversations.
...(model.provider === "chatgpt" && strong?.provider === "chatgpt" && strong.fastMode === true ? { fastMode: true } : {}),
};
}
@@ -131,7 +135,9 @@ export function resolveRoleSpec(role: ModelRole, record: UserSettings | null): E
* to ride without the factory floor). */
export function resolveSlot(slot: ModelSlot | undefined): EffectiveSlot | null {
if (!slot?.model) return null;
return { provider: slot.model.provider, id: slot.model.id, thinkingLevel: slot.thinkingLevel };
return { provider: slot.model.provider, id: slot.model.id, thinkingLevel: slot.thinkingLevel,
...(slot.model.provider === "chatgpt" && slot.fastMode === true ? { fastMode: true } : {}),
};
}
/** Resolve one role's spec — the BYOK chain (owner 2026-09-22): role
@@ -153,6 +159,7 @@ export function resolveModelSpec(
provider: model.provider,
id: model.id,
thinkingLevel: override?.thinkingLevel ?? slot?.thinkingLevel,
...(model.provider === "chatgpt" && slot?.model?.provider === "chatgpt" && slot.fastMode === true ? { fastMode: true } : {}),
};
}
+9 -1
View File
@@ -193,6 +193,8 @@ export interface ModelSlot {
model?: { provider: string; id: string };
/** The thinking budget swap. */
thinkingLevel?: ThinkingLevel;
/** Opt-in ChatGPT priority processing for this tier. Absent = off. */
fastMode?: boolean;
}
/** The `models` settings section (owner decision 2026-09-20: a dedicated
@@ -490,7 +492,13 @@ function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
const out: ModelsSettings = {};
for (const slot of MODEL_SLOTS) {
const cleaned = sanitizeRoleOverride(raw[slot]);
if (cleaned) out[slot] = cleaned;
if (cleaned) {
const value = raw[slot] as Record<string, unknown>;
out[slot] = {
...cleaned,
...(cleaned.model?.provider === "chatgpt" && typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}),
};
}
}
return out;
}
+1 -1
View File
@@ -300,7 +300,7 @@ export function clampModelsLevels(settings: ModelsSettings): ModelsSettings {
out[name] =
slot.thinkingLevel !== undefined
? {
model: slot.model,
...slot,
thinkingLevel: clampLevelFor(slot.model.provider, slot.model.id, slot.thinkingLevel),
}
: slot;
+11 -1
View File
@@ -348,7 +348,17 @@ export function parseModelsSettingsBody(body: unknown): SettingsBodyResult<Model
if (!parsed.override.model) {
return { ok: false, error: `slot "${key}" must set a model (thinkingLevel alone cannot run — absent slot = unset)` };
}
settings[key as (typeof MODEL_SLOTS)[number]] = parsed.override;
const value = asRecord(raw[key])!;
if (value.fastMode !== undefined && typeof value.fastMode !== "boolean") {
return { ok: false, error: `${key}.fastMode must be a boolean` };
}
if (value.fastMode === true && parsed.override.model.provider !== "chatgpt") {
return { ok: false, error: "Fast mode is available only for ChatGPT models." };
}
settings[key as (typeof MODEL_SLOTS)[number]] = {
...parsed.override,
...(typeof value.fastMode === "boolean" ? { fastMode: value.fastMode } : {}),
};
}
return { ok: true, settings };
}