Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/server/src/git/Layers/ClaudeTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ import {
sanitizeThreadTitle,
toJsonSchemaObject,
} from "../Utils.ts";
import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts";
import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model";
import { ServerSettingsService } from "../../serverSettings.ts";
import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";

const CLAUDE_TIMEOUT_MS = 180_000;

Expand Down Expand Up @@ -84,8 +85,8 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
}): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
Effect.gen(function* () {
const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
const normalizedOptions = normalizeClaudeModelOptions(
modelSelection.model,
const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(
getClaudeModelCapabilities(modelSelection.model),
modelSelection.options,
);
const settings = {
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ import {
sanitizeThreadTitle,
toJsonSchemaObject,
} from "../Utils.ts";
import { normalizeCodexModelOptions } from "../../provider/Layers/CodexProvider.ts";
import { getCodexModelCapabilities } from "../../provider/Layers/CodexProvider.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { normalizeCodexModelOptionsWithCapabilities } from "@t3tools/shared/model";

const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
const CODEX_TIMEOUT_MS = 180_000;
Expand Down Expand Up @@ -156,8 +157,8 @@ const makeCodexTextGeneration = Effect.gen(function* () {
).pipe(Effect.catch(() => Effect.undefined));

const runCodexCommand = Effect.gen(function* () {
const normalizedOptions = normalizeCodexModelOptions(
modelSelection.model,
const normalizedOptions = normalizeCodexModelOptionsWithCapabilities(
getCodexModelCapabilities(modelSelection.model),
modelSelection.options,
);
const reasoningEffort =
Expand Down
21 changes: 0 additions & 21 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type {
ClaudeSettings,
ClaudeModelOptions,
ModelCapabilities,
ServerProvider,
ServerProviderModel,
Expand All @@ -9,7 +8,6 @@ import type {
} from "@t3tools/contracts";
import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
import { decodeJsonResult } from "@t3tools/shared/schemaJson";
import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk";

Expand Down Expand Up @@ -98,25 +96,6 @@ export function getClaudeModelCapabilities(model: string | null | undefined): Mo
);
}

export function normalizeClaudeModelOptions(
model: string | null | undefined,
modelOptions: ClaudeModelOptions | null | undefined,
): ClaudeModelOptions | undefined {
const caps = getClaudeModelCapabilities(model);
const effort = resolveEffort(caps, modelOptions?.effort);
const thinking =
caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
const nextOptions: ClaudeModelOptions = {
...(thinking === false ? { thinking: false } : {}),
...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
...(fastMode ? { fastMode: true } : {}),
...(contextWindow ? { contextWindow } : {}),
};
return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
}

export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
readonly status: Exclude<ServerProviderState, "disabled">;
readonly auth: Pick<ServerProviderAuth, "status">;
Expand Down
18 changes: 0 additions & 18 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as OS from "node:os";
import type {
ModelCapabilities,
CodexModelOptions,
CodexSettings,
ServerProvider,
ServerProviderModel,
Expand All @@ -21,7 +20,6 @@ import {
Stream,
} from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { resolveEffort } from "@t3tools/shared/model";

import {
buildServerProvider,
Expand Down Expand Up @@ -170,22 +168,6 @@ export function getCodexModelCapabilities(model: string | null | undefined): Mod
);
}

export function normalizeCodexModelOptions(
model: string | null | undefined,
modelOptions: CodexModelOptions | null | undefined,
): CodexModelOptions | undefined {
const caps = getCodexModelCapabilities(model);
const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
const fastModeEnabled = modelOptions?.fastMode === true;
const nextOptions: CodexModelOptions = {
...(reasoningEffort
? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
: {}),
...(fastModeEnabled ? { fastMode: true } : {}),
};
return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
}

export function parseAuthStatusFromOutput(result: CommandResult): {
readonly status: Exclude<ServerProviderState, "disabled">;
readonly auth: Pick<ServerProviderAuth, "status">;
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set(["textGenerationModelS

function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
if (Array.isArray(current) || Array.isArray(defaults)) {
return JSON.stringify(current) === JSON.stringify(defaults) ? undefined : current;
return Equal.equals(current, defaults) ? undefined : current;
}

if (
Expand Down
145 changes: 145 additions & 0 deletions apps/web/src/components/chat/composerProviderRegistry.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,41 @@ const CLAUDE_MODELS: ReadonlyArray<ServerProviderModel> = [
},
];

const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray<ServerProviderModel> = [
{
slug: "claude-opus-4-6",
name: "Claude Opus 4.6",
isCustom: false,
capabilities: {
reasoningEffortLevels: [
{ value: "medium", label: "Medium" },
{ value: "high", label: "High", isDefault: true },
{ value: "max", label: "Max" },
{ value: "ultrathink", label: "Ultrathink" },
],
supportsFastMode: true,
supportsThinkingToggle: false,
contextWindowOptions: [
{ value: "200k", label: "200k", isDefault: true },
{ value: "1m", label: "1M" },
],
promptInjectedEffortLevels: ["ultrathink"],
},
},
{
slug: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
isCustom: false,
capabilities: {
reasoningEffortLevels: [],
supportsFastMode: false,
supportsThinkingToggle: true,
contextWindowOptions: [],
promptInjectedEffortLevels: [],
},
},
];

describe("getComposerProviderState", () => {
it("returns codex defaults when no codex draft options exist", () => {
const state = getComposerProviderState({
Expand Down Expand Up @@ -156,6 +191,7 @@ describe("getComposerProviderState", () => {
promptEffort: "high",
modelOptionsForDispatch: {
reasoningEffort: "high",
fastMode: false,
},
});
});
Expand Down Expand Up @@ -268,7 +304,116 @@ describe("getComposerProviderState", () => {
promptEffort: "high",
modelOptionsForDispatch: {
effort: "high",
fastMode: false,
},
});
});

it("preserves explicit fastMode: false so deepMerge can overwrite a prior true", () => {
// Regression: normalizeClaudeModelOptionsWithCapabilities used to strip
// fastMode: false, which meant deepMerge could never clear a previous true.
const state = getComposerProviderState({
provider: "claudeAgent",
model: "claude-opus-4-6",
models: CLAUDE_MODELS,
prompt: "",
modelOptions: {
claudeAgent: {
effort: "high",
fastMode: false,
},
},
});

expect(state.modelOptionsForDispatch).toHaveProperty("fastMode", false);
});

it("preserves explicit thinking: true so deepMerge can overwrite a prior false", () => {
// Regression: thinking: true (the default) used to be stripped, which
// meant deepMerge could never clear a previous thinking: false.
const state = getComposerProviderState({
provider: "claudeAgent",
model: "claude-haiku-4-5",
models: CLAUDE_MODELS,
prompt: "",
modelOptions: {
claudeAgent: {
thinking: true,
},
},
});

expect(state.modelOptionsForDispatch).toHaveProperty("thinking", true);
});

it("preserves Claude default context window explicitly in dispatch options", () => {
const state = getComposerProviderState({
provider: "claudeAgent",
model: "claude-opus-4-6",
models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW,
prompt: "",
modelOptions: {
claudeAgent: {
effort: "high",
contextWindow: "200k",
},
},
});

expect(state.modelOptionsForDispatch).toMatchObject({
effort: "high",
contextWindow: "200k",
});
});

it("preserves explicit contextWindow default so deepMerge can overwrite a prior 1m", () => {
// Regression: the default contextWindow must survive normalization so
// deepMerge can clear an older non-default 1m selection.
const state = getComposerProviderState({
provider: "claudeAgent",
model: "claude-opus-4-6",
models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW,
prompt: "",
modelOptions: {
claudeAgent: {
contextWindow: "200k",
},
},
});

expect(state.modelOptionsForDispatch).toHaveProperty("contextWindow", "200k");
});

it("omits contextWindow when the model does not support it", () => {
const state = getComposerProviderState({
provider: "claudeAgent",
model: "claude-haiku-4-5",
models: CLAUDE_MODELS_WITH_CONTEXT_WINDOW,
prompt: "",
modelOptions: {
claudeAgent: {
contextWindow: "1m",
},
},
});

expect(state.modelOptionsForDispatch).toBeUndefined();
});

it("omits fastMode when the model does not support it", () => {
const state = getComposerProviderState({
provider: "claudeAgent",
model: "claude-sonnet-4-6",
models: CLAUDE_MODELS,
prompt: "",
modelOptions: {
claudeAgent: {
effort: "high",
fastMode: true,
},
},
});

expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode");
});
});
6 changes: 3 additions & 3 deletions apps/web/src/components/chat/composerProviderRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import {
} from "@t3tools/contracts";
import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model";
import type { ReactNode } from "react";
import { getProviderModelCapabilities } from "../../providerModels";
import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
import {
getProviderModelCapabilities,
normalizeClaudeModelOptionsWithCapabilities,
normalizeCodexModelOptionsWithCapabilities,
} from "../../providerModels";
import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
} from "@t3tools/shared/model";

export type ComposerProviderStateInput = {
provider: ProviderKind;
Expand Down
37 changes: 1 addition & 36 deletions apps/web/src/providerModels.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import {
DEFAULT_MODEL_BY_PROVIDER,
type ClaudeModelOptions,
type CodexModelOptions,
type ModelCapabilities,
type ProviderKind,
type ServerProvider,
type ServerProviderModel,
} from "@t3tools/contracts";
import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
import { normalizeModelSlug } from "@t3tools/shared/model";

const EMPTY_CAPABILITIES: ModelCapabilities = {
reasoningEffortLevels: [],
Expand Down Expand Up @@ -69,36 +67,3 @@ export function getDefaultServerModel(
DEFAULT_MODEL_BY_PROVIDER[provider]
);
}

export function normalizeCodexModelOptionsWithCapabilities(
caps: ModelCapabilities,
modelOptions: CodexModelOptions | null | undefined,
): CodexModelOptions | undefined {
const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort);
const fastModeEnabled = modelOptions?.fastMode === true;
const nextOptions: CodexModelOptions = {
...(reasoningEffort
? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] }
: {}),
...(fastModeEnabled ? { fastMode: true } : {}),
};
return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
}

export function normalizeClaudeModelOptionsWithCapabilities(
caps: ModelCapabilities,
modelOptions: ClaudeModelOptions | null | undefined,
): ClaudeModelOptions | undefined {
const effort = resolveEffort(caps, modelOptions?.effort);
const thinking =
caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow);
const nextOptions: ClaudeModelOptions = {
...(thinking === false ? { thinking: false } : {}),
...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}),
...(fastMode ? { fastMode: true } : {}),
...(contextWindow ? { contextWindow } : {}),
};
return Object.keys(nextOptions).length > 0 ? nextOptions : undefined;
}
8 changes: 0 additions & 8 deletions packages/contracts/src/settings.test.ts

This file was deleted.

1 change: 1 addition & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const ClaudeModelOptionsPatch = Schema.Struct({
thinking: Schema.optionalKey(ClaudeModelOptions.fields.thinking),
effort: Schema.optionalKey(ClaudeModelOptions.fields.effort),
fastMode: Schema.optionalKey(ClaudeModelOptions.fields.fastMode),
contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow),
});

const ModelSelectionPatch = Schema.Union([
Expand Down
Loading
Loading