authorgravatar for alex.vostres@gmail.comAlexander Vostres <alex.vostres@gmail.com> 2026-03-30 04:13:16+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-30 01:13:16+00:00
log792ad4b5c1ed226be33d1623de8cbaf828f9053d
tree76a57a5ed2343e91901a36df7618e1daae5e28dc
parentd41781987cea30ad3ff7d344292ecdfbf9325900
signature Signed by PGP key B5690EEEBB952194

Fix model settings getting stuck (#1538)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

12 files changed, 249 insertions(+), 93 deletions(-)

apps/server/src/git/Layers/ClaudeTextGeneration.ts+4-3
......@@ -29,8 +29,9 @@ import {
2929 sanitizeThreadTitle,
3030 toJsonSchemaObject,
3131} from "../Utils.ts";
32import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts";
32import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
3333import { ServerSettingsService } from "../../serverSettings.ts";
34import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
3435
3536const CLAUDE_TIMEOUT_MS = 180_000;
3637
......@@ -84,8 +85,8 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
8485 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
8586 Effect.gen(function* () {
8687 const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
87 const normalizedOptions = normalizeClaudeModelOptions(
88 modelSelection.model,
88 const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(
89 getClaudeModelCapabilities(modelSelection.model),
8990 modelSelection.options,
9091 );
9192 const settings = {
apps/server/src/git/Layers/CodexTextGeneration.ts+4-3
......@@ -28,8 +28,9 @@ import {
2828 sanitizeThreadTitle,
2929 toJsonSchemaObject,
3030} from "../Utils.ts";
31import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";
31import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts";
3232import { ServerSettingsService } from "../../serverSettings.ts";
33import { normalizeCodexModelOptionsWithCapabilities } from "@t3tools/shared/model";
3334
3435const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
3536const CODEX_TIMEOUT_MS = 180_000;
......@@ -156,8 +157,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
156157 ).pipe(Effect.catch(() => Effect.undefined));
157158
158159 const runCodexCommand = Effect.gen(function* () {
159 const normalizedOptions = normalizeCodexModelOptions(
160 modelSelection.model,
160 const normalizedOptions = normalizeCodexModelOptionsWithCapabilities(
161 getCodexModelCapabilities(modelSelection.model),
161162 modelSelection.options,
162163 );
163164 const reasoningEffort =
apps/server/src/provider/Layers/ClaudeProvider.ts-21
......@@ -1,6 +1,5 @@
11import type {
22 ClaudeSettings,
3 ClaudeModelOptions,
43 ModelCapabilities,
54 ServerProvider,
65 ServerProviderModel,
......@@ -9,7 +8,6 @@ import type {
98} from "@t3tools/contracts";
109import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
1110import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
1311import { decodeJsonResult } from "@t3tools/shared/schemaJson";
1412import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk";
1513
......@@ -98,25 +96,6 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo
9896 );
9997}
10098
101export function normalizeClaudeModelOptions(
102 model: string | null | undefined,
103 modelOptions: ClaudeModelOptions | null | undefined,
104): ClaudeModelOptions | undefined {
105 const caps = getClaudeModelCapabilities(model);
106 const effort = resolveEffort(caps, modelOptions?.effort);
107 const thinking =
108 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
109 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
110 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
111 const nextOptions: ClaudeModelOptions = {
112 ...(thinking === false ? { thinking: false } : {}),
113 ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
114 ...(fastMode ? { fastMode: true } : {}),
115 ...(contextWindow ? { contextWindow } : {}),
116 };
117 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
118}
119
12099export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
121100 readonly status: Exclude<ServerProviderState, "disabled">;
122101 readonly auth: Pick<ServerProviderAuth, "status">;
apps/server/src/provider/Layers/CodexProvider.ts-18
......@@ -1,7 +1,6 @@
11import * as OS from "node:os";
22import type {
33 ModelCapabilities,
4 CodexModelOptions,
54 CodexSettings,
65 ServerProvider,
76 ServerProviderModel,
......@@ -21,7 +20,6 @@ import {
2120 Stream,
2221} from "effect";
2322import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
24import { resolveEffort } from "@t3tools/shared/model";
2523
2624import {
2725 buildServerProvider,
......@@ -170,22 +168,6 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod
170168 );
171169}
172170
173export function normalizeCodexModelOptions(
174 model: string | null | undefined,
175 modelOptions: CodexModelOptions | null | undefined,
176): CodexModelOptions | undefined {
177 const caps = getCodexModelCapabilities(model);
178 const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
179 const fastModeEnabled = modelOptions?.fastMode === true;
180 const nextOptions: CodexModelOptions = {
181 ...(reasoningEffort
182 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
183 : {}),
184 ...(fastModeEnabled ? { fastMode: true } : {}),
185 };
186 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
187}
188
189171export function parseAuthStatusFromOutput(result: CommandResult): {
190172 readonly status: Exclude<ServerProviderState, "disabled">;
191173 readonly auth: Pick<ServerProviderAuth, "status">;
apps/server/src/serverSettings.ts+1-1
......@@ -136,7 +136,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelS
136136
137137function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
138138 if (Array.isArray(current) || Array.isArray(defaults)) {
139 return JSON.stringify(current) === JSON.stringify(defaults) ? undefined : current;
139 return Equal.equals(current, defaults) ? undefined : current;
140140 }
141141
142142 if (
apps/web/src/components/chat/composerProviderRegistry.test.tsx+145
......@@ -71,6 +71,41 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
7171 },
7272];
7373
74const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [
75 {
76 slug: "claude-opus-4-6",
77 name: "Claude Opus 4.6",
78 isCustom: false,
79 capabilities: {
80 reasoningEffortLevels: [
81 { value: "medium", label: "Medium" },
82 { value: "high", label: "High", isDefault: true },
83 { value: "max", label: "Max" },
84 { value: "ultrathink", label: "Ultrathink" },
85 ],
86 supportsFastMode: true,
87 supportsThinkingToggle: false,
88 contextWindowOptions: [
89 { value: "200k", label: "200k", isDefault: true },
90 { value: "1m", label: "1M" },
91 ],
92 promptInjectedEffortLevels: ["ultrathink"],
93 },
94 },
95 {
96 slug: "claude-haiku-4-5",
97 name: "Claude Haiku 4.5",
98 isCustom: false,
99 capabilities: {
100 reasoningEffortLevels: [],
101 supportsFastMode: false,
102 supportsThinkingToggle: true,
103 contextWindowOptions: [],
104 promptInjectedEffortLevels: [],
105 },
106 },
107];
108
74109describe("getComposerProviderState", () => {
75110 it("returns codex defaults when no codex draft options exist", () => {
76111 const state = getComposerProviderState({
......@@ -156,6 +191,7 @@ describe("getComposerProviderState", () => {
156191 promptEffort: "high",
157192 modelOptionsForDispatch: {
158193 reasoningEffort: "high",
194 fastMode: false,
159195 },
160196 });
161197 });
......@@ -268,7 +304,116 @@ describe("getComposerProviderState", () => {
268304 promptEffort: "high",
269305 modelOptionsForDispatch: {
270306 effort: "high",
307 fastMode: false,
308 },
309 });
310 });
311
312 it("preserves explicit fastMode: false so deepMerge can overwrite a prior true", () => {
313 // Regression: normalizeClaudeModelOptionsWithCapabilities used to strip
314 // fastMode: false, which meant deepMerge could never clear a previous true.
315 const state = getComposerProviderState({
316 provider: "claudeAgent",
317 model: "claude-opus-4-6",
318 models: CLAUDE_MODELS,
319 prompt: "",
320 modelOptions: {
321 claudeAgent: {
322 effort: "high",
323 fastMode: false,
324 },
325 },
326 });
327
328 expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false);
329 });
330
331 it("preserves explicit thinking: true so deepMerge can overwrite a prior false", () => {
332 // Regression: thinking: true (the default) used to be stripped, which
333 // meant deepMerge could never clear a previous thinking: false.
334 const state = getComposerProviderState({
335 provider: "claudeAgent",
336 model: "claude-haiku-4-5",
337 models: CLAUDE_MODELS,
338 prompt: "",
339 modelOptions: {
340 claudeAgent: {
341 thinking: true,
342 },
343 },
344 });
345
346 expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true);
347 });
348
349 it("preserves Claude default context window explicitly in dispatch options", () => {
350 const state = getComposerProviderState({
351 provider: "claudeAgent",
352 model: "claude-opus-4-6",
353 models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW,
354 prompt: "",
355 modelOptions: {
356 claudeAgent: {
357 effort: "high",
358 contextWindow: "200k",
359 },
360 },
361 });
362
363 expect(state.modelOptionsForDispatch).toMatchObject({
364 effort: "high",
365 contextWindow: "200k",
366 });
367 });
368
369 it("preserves explicit contextWindow default so deepMerge can overwrite a prior 1m", () => {
370 // Regression: the default contextWindow must survive normalization so
371 // deepMerge can clear an older non-default 1m selection.
372 const state = getComposerProviderState({
373 provider: "claudeAgent",
374 model: "claude-opus-4-6",
375 models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW,
376 prompt: "",
377 modelOptions: {
378 claudeAgent: {
379 contextWindow: "200k",
380 },
271381 },
272382 });
383
384 expect(state.modelOptionsForDispatch).toHaveProperty("contextWindow", "200k");
385 });
386
387 it("omits contextWindow when the model does not support it", () => {
388 const state = getComposerProviderState({
389 provider: "claudeAgent",
390 model: "claude-haiku-4-5",
391 models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW,
392 prompt: "",
393 modelOptions: {
394 claudeAgent: {
395 contextWindow: "1m",
396 },
397 },
398 });
399
400 expect(state.modelOptionsForDispatch).toBeUndefined();
401 });
402
403 it("omits fastMode when the model does not support it", () => {
404 const state = getComposerProviderState({
405 provider: "claudeAgent",
406 model: "claude-sonnet-4-6",
407 models: CLAUDE_MODELS,
408 prompt: "",
409 modelOptions: {
410 claudeAgent: {
411 effort: "high",
412 fastMode: true,
413 },
414 },
415 });
416
417 expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");
273418 });
274419});
apps/web/src/components/chat/composerProviderRegistry.tsx+3-3
......@@ -6,12 +6,12 @@ import {
66} from "@t3tools/contracts";
77import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
88import type { ReactNode } from "react";
9import { getProviderModelCapabilities } from "../../providerModels";
10import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
911import {
10 getProviderModelCapabilities,
1112 normalizeClaudeModelOptionsWithCapabilities,
1213 normalizeCodexModelOptionsWithCapabilities,
13} from "../../providerModels";
14import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
14} from "@t3tools/shared/model";
1515
1616export type ComposerProviderStateInput = {
1717 provider: ProviderKind;
apps/web/src/providerModels.ts+1-36
......@@ -1,13 +1,11 @@
11import {
22 DEFAULT_MODEL_BY_PROVIDER,
3 type ClaudeModelOptions,
4 type CodexModelOptions,
53 type ModelCapabilities,
64 type ProviderKind,
75 type ServerProvider,
86 type ServerProviderModel,
97} from "@t3tools/contracts";
10import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
8import { normalizeModelSlug } from "@t3tools/shared/model";
119
1210const EMPTY_CAPABILITIES: ModelCapabilities = {
1311 reasoningEffortLevels: [],
......@@ -69,36 +67,3 @@ export function getDefaultServerModel(
6967 DEFAULT_MODEL_BY_PROVIDER[provider]
7068 );
7169}
72
73export function normalizeCodexModelOptionsWithCapabilities(
74 caps: ModelCapabilities,
75 modelOptions: CodexModelOptions | null | undefined,
76): CodexModelOptions | undefined {
77 const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
78 const fastModeEnabled = modelOptions?.fastMode === true;
79 const nextOptions: CodexModelOptions = {
80 ...(reasoningEffort
81 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
82 : {}),
83 ...(fastModeEnabled ? { fastMode: true } : {}),
84 };
85 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
86}
87
88export function normalizeClaudeModelOptionsWithCapabilities(
89 caps: ModelCapabilities,
90 modelOptions: ClaudeModelOptions | null | undefined,
91): ClaudeModelOptions | undefined {
92 const effort = resolveEffort(caps, modelOptions?.effort);
93 const thinking =
94 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
95 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
96 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
97 const nextOptions: ClaudeModelOptions = {
98 ...(thinking === false ? { thinking: false } : {}),
99 ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
100 ...(fastMode ? { fastMode: true } : {}),
101 ...(contextWindow ? { contextWindow } : {}),
102 };
103 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
104}
packages/contracts/src/settings.test.ts deleted-8
......@@ -1,8 +0,0 @@
1import { describe, expect, it } from "vitest";
2import { DEFAULT_CLIENT_SETTINGS } from "./settings";
3
4describe("DEFAULT_CLIENT_SETTINGS", () => {
5 it("includes archive confirmation with a false default", () => {
6 expect(DEFAULT_CLIENT_SETTINGS.confirmThreadArchive).toBe(false);
7 });
8});
packages/contracts/src/settings.ts+1
......@@ -112,6 +112,7 @@ const ClaudeModelOptionsPatch = Schema.Struct({
112112 thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking),
113113 effort: Schema.optionalKey(ClaudeModelOptions.fields.effort),
114114 fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode),
115 contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),
115116});
116117
117118const ModelSelectionPatch = Schema.Union([
packages/shared/src/model.test.ts+56
......@@ -8,6 +8,8 @@ import {
88 hasContextWindowOption,
99 hasEffortLevel,
1010 isClaudeUltrathinkPrompt,
11 normalizeClaudeModelOptionsWithCapabilities,
12 normalizeCodexModelOptionsWithCapabilities,
1113 normalizeModelSlug,
1214 resolveApiModelId,
1315 resolveContextWindow,
......@@ -226,3 +228,57 @@ describe("resolveApiModelId", () => {
226228 expect(resolveApiModelId({ provider: "codex", model: "gpt-5.4" })).toBe("gpt-5.4");
227229 });
228230});
231
232describe("normalize*ModelOptionsWithCapabilities", () => {
233 it("preserves explicit false codex fast mode", () => {
234 expect(
235 normalizeCodexModelOptionsWithCapabilities(codexCaps, {
236 reasoningEffort: "high",
237 fastMode: false,
238 }),
239 ).toEqual({
240 reasoningEffort: "high",
241 fastMode: false,
242 });
243 });
244
245 it("preserves the default Claude context window explicitly", () => {
246 expect(
247 normalizeClaudeModelOptionsWithCapabilities(
248 {
249 ...claudeCaps,
250 contextWindowOptions: [
251 { value: "200k", label: "200k", isDefault: true },
252 { value: "1m", label: "1M" },
253 ],
254 },
255 {
256 effort: "high",
257 contextWindow: "200k",
258 },
259 ),
260 ).toEqual({
261 effort: "high",
262 contextWindow: "200k",
263 });
264 });
265
266 it("omits unsupported Claude context window options", () => {
267 expect(
268 normalizeClaudeModelOptionsWithCapabilities(
269 {
270 ...claudeCaps,
271 reasoningEffortLevels: [],
272 supportsThinkingToggle: true,
273 contextWindowOptions: [],
274 },
275 {
276 thinking: true,
277 contextWindow: "1m",
278 },
279 ),
280 ).toEqual({
281 thinking: true,
282 });
283 });
284});
packages/shared/src/model.ts+34
......@@ -2,6 +2,8 @@ import {
22 DEFAULT_MODEL_BY_PROVIDER,
33 MODEL_SLUG_ALIASES_BY_PROVIDER,
44 type ClaudeCodeEffort,
5 type ClaudeModelOptions,
6 type CodexModelOptions,
57 type ModelCapabilities,
68 type ModelSelection,
79 type ProviderKind,
......@@ -83,6 +85,38 @@ export function resolveContextWindow(
8385 return hasContextWindowOption(caps, raw) ? raw : (defaultValue ?? undefined);
8486}
8587
88export function normalizeCodexModelOptionsWithCapabilities(
89 caps: ModelCapabilities,
90 modelOptions: CodexModelOptions | null | undefined,
91): CodexModelOptions | undefined {
92 const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
93 const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined;
94 const nextOptions: CodexModelOptions = {
95 ...(reasoningEffort
96 ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
97 : {}),
98 ...(fastMode !== undefined ? { fastMode } : {}),
99 };
100 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
101}
102
103export function normalizeClaudeModelOptionsWithCapabilities(
104 caps: ModelCapabilities,
105 modelOptions: ClaudeModelOptions | null | undefined,
106): ClaudeModelOptions | undefined {
107 const effort = resolveEffort(caps, modelOptions?.effort);
108 const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined;
109 const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined;
110 const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
111 const nextOptions: ClaudeModelOptions = {
112 ...(thinking !== undefined ? { thinking } : {}),
113 ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
114 ...(fastMode !== undefined ? { fastMode } : {}),
115 ...(contextWindow !== undefined ? { contextWindow } : {}),
116 };
117 return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
118}
119
86120export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
87121 return typeof text === "string" && /\bultrathink\b/i.test(text);
88122}