diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 9a8d1d93f0..a444627c3f 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -17,7 +18,6 @@ import { TextGeneration, } from "../Services/TextGeneration.ts"; -const CODEX_MODEL = "gpt-5.3-codex"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; @@ -187,6 +187,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson, imagePaths = [], cleanupPaths = [], + model, }: { operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; @@ -194,6 +195,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { outputSchemaJson: S; imagePaths?: ReadonlyArray; cleanupPaths?: ReadonlyArray; + model?: string; }): Effect.Effect => Effect.gen(function* () { const schemaPath = yield* writeTempFile( @@ -212,7 +214,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { "-s", "read-only", "--model", - CODEX_MODEL, + model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL, "--config", `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`, "--output-schema", @@ -353,6 +355,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { cwd: input.cwd, prompt, outputSchemaJson, + ...(input.model ? { model: input.model } : {}), }).pipe( Effect.map( (generated) => @@ -398,6 +401,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { title: Schema.String, body: Schema.String, }), + ...(input.model ? { model: input.model } : {}), }).pipe( Effect.map( (generated) => @@ -449,6 +453,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { branch: Schema.String, }), imagePaths, + ...(input.model ? { model: input.model } : {}), }); return { diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 8357795173..1a3cf2bb35 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -639,6 +639,7 @@ export const makeGitManager = Effect.gen(function* () { /** When true, also produce a semantic feature branch name. */ includeBranch?: boolean; filePaths?: readonly string[]; + model?: string; }) => Effect.gen(function* () { const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); @@ -665,6 +666,7 @@ export const makeGitManager = Effect.gen(function* () { stagedSummary: limitContext(context.stagedSummary, 8_000), stagedPatch: limitContext(context.stagedPatch, 50_000), ...(input.includeBranch ? { includeBranch: true } : {}), + ...(input.model ? { model: input.model } : {}), }) .pipe(Effect.map((result) => sanitizeCommitMessage(result))); @@ -682,6 +684,7 @@ export const makeGitManager = Effect.gen(function* () { commitMessage?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], + model?: string, ) => Effect.gen(function* () { const suggestion = @@ -691,6 +694,7 @@ export const makeGitManager = Effect.gen(function* () { branch, ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), + ...(model ? { model } : {}), })); if (!suggestion) { return { status: "skipped_no_changes" as const }; @@ -704,7 +708,7 @@ export const makeGitManager = Effect.gen(function* () { }; }); - const runPrStep = (cwd: string, fallbackBranch: string | null) => + const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) => Effect.gen(function* () { const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; @@ -748,6 +752,7 @@ export const makeGitManager = Effect.gen(function* () { commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), + ...(model ? { model } : {}), }); const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); @@ -972,6 +977,7 @@ export const makeGitManager = Effect.gen(function* () { branch: string | null, commitMessage?: string, filePaths?: readonly string[], + model?: string, ) => Effect.gen(function* () { const suggestion = yield* resolveCommitAndBranchSuggestion({ @@ -980,6 +986,7 @@ export const makeGitManager = Effect.gen(function* () { ...(commitMessage ? { commitMessage } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, + ...(model ? { model } : {}), }); if (!suggestion) { return yield* gitManagerError( @@ -1028,6 +1035,7 @@ export const makeGitManager = Effect.gen(function* () { initialStatus.branch, input.commitMessage, input.filePaths, + input.textGenerationModel, ); branchStep = result.branchStep; commitMessageForStep = result.resolvedCommitMessage; @@ -1044,6 +1052,7 @@ export const makeGitManager = Effect.gen(function* () { commitMessageForStep, preResolvedCommitSuggestion, input.filePaths, + input.textGenerationModel, ); const push = wantsPush @@ -1051,7 +1060,7 @@ export const makeGitManager = Effect.gen(function* () { : { status: "skipped_not_requested" as const }; const pr = wantsPr - ? yield* runPrStep(input.cwd, currentBranch) + ? yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel) : { status: "skipped_not_requested" as const }; return { diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe66..b4650ed570 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -19,6 +19,8 @@ export interface CommitMessageGenerationInput { stagedPatch: string; /** When true, the model also returns a semantic branch name for the change. */ includeBranch?: boolean; + /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + model?: string; } export interface CommitMessageGenerationResult { @@ -35,6 +37,8 @@ export interface PrContentGenerationInput { commitSummary: string; diffSummary: string; diffPatch: string; + /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + model?: string; } export interface PrContentGenerationResult { @@ -46,6 +50,8 @@ export interface BranchNameGenerationInput { cwd: string; message: string; attachments?: ReadonlyArray | undefined; + /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */ + model?: string; } export interface BranchNameGenerationResult { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fe02188450..d8203bd93d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1,6 +1,7 @@ import { type ChatAttachment, CommandId, + DEFAULT_GIT_TEXT_GENERATION_MODEL, EventId, type OrchestrationEvent, type ProviderModelOptions, @@ -391,6 +392,7 @@ const make = Effect.gen(function* () { cwd, message: input.messageText, ...(attachments.length > 0 ? { attachments } : {}), + model: DEFAULT_GIT_TEXT_GENERATION_MODEL, }) .pipe( Effect.catch((error) => diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaacf..15579809a4 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -28,6 +28,7 @@ describe("getAppModelOptions", () => { expect(options.map((option) => option.slug)).toEqual([ "gpt-5.4", + "gpt-5.4-mini", "gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.2-codex", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f92..d060c2ef06 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; -import { type ProviderKind } from "@t3tools/contracts"; +import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { useLocalStorage } from "./hooks/useLocalStorage"; @@ -34,6 +34,7 @@ const AppSettingsSchema = Schema.Struct({ customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index e4fad02af2..0771875135 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -15,6 +15,7 @@ import { resolveQuickAction, summarizeGitResult, } from "./GitActionsControl.logic"; +import { useAppSettings } from "~/appSettings"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; import { @@ -154,6 +155,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { } export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) { + const { settings } = useAppSettings(); const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -191,7 +193,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); const runImmediateGitActionMutation = useMutation( - gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }), + gitRunStackedActionMutationOptions({ + cwd: gitCwd, + queryClient, + model: settings.textGenerationModel ?? null, + }), ); const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 9b5fe7731f..d520f73c1c 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -112,6 +112,7 @@ export function gitCheckoutMutationOptions(input: { export function gitRunStackedActionMutationOptions(input: { cwd: string | null; queryClient: QueryClient; + model?: string | null; }) { return mutationOptions({ mutationKey: gitMutationKeys.runStackedAction(input.cwd), @@ -134,6 +135,7 @@ export function gitRunStackedActionMutationOptions(input: { ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), + ...(input.model ? { model: input.model } : {}), }); }, onSettled: async () => { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa1..e79592c99b 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,9 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; -import { type ProviderKind } from "@t3tools/contracts"; +import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -112,6 +112,17 @@ function SettingsRouteView() { const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const gitTextGenerationModelOptions = getAppModelOptions( + "codex", + settings.customCodexModels, + settings.textGenerationModel, + ); + const selectedGitTextGenerationModelLabel = + gitTextGenerationModelOptions.find( + (option) => + option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL), + )?.name ?? settings.textGenerationModel; + const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -502,6 +513,65 @@ function SettingsRouteView() { +
+
+

Git

+

+ Configure the model used for generating commit messages, PR titles, and branch + names. +

+
+ +
+
+

Text generation model

+

+ Model used for auto-generated git content. +

+
+ +
+ + {settings.textGenerationModel !== defaults.textGenerationModel ? ( +
+ +
+ ) : null} +
+

Threads

diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 081b4d0d82..e64ca13d72 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ -import { Schema } from "effect"; +import { Option, Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -64,6 +65,9 @@ export const GitRunStackedActionInput = Schema.Struct({ filePaths: Schema.optional( Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), ), + textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)), + ), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09dc..2b1174e237 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -23,6 +23,7 @@ type ModelOption = { export const MODEL_OPTIONS_BY_PROVIDER = { codex: [ { slug: "gpt-5.4", name: "GPT-5.4" }, + { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, @@ -38,6 +39,8 @@ export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", } as const satisfies Record; +export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const; + export const MODEL_SLUG_ALIASES_BY_PROVIDER = { codex: { "5.4": "gpt-5.4",