authorgravatar for seifalmotazbellah@gmail.comSeif Almotaz Bellah <seifalmotazbellah@gmail.com> 2026-03-18 00:33:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-17 15:33:36-07:00
log579a69eb7756b2169d26299d36f936838db39caa
tree594f6b85cfdb645425b60063c03580bf64fb0d41
parent9ee8c4f5430a221d6569ba9c2f984d8501ec4fe5
signature Signed by PGP key B5690EEEBB952194

feat: add configurable model for git text generation (#1171)

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

11 files changed, 118 insertions(+), 9 deletions(-)

apps/server/src/git/Layers/CodexTextGeneration.ts+7-2
......@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
33import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
44import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
67import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
78
89import { resolveAttachmentPath } from "../../attachmentStore.ts";
......@@ -17,7 +18,6 @@ import {
1718 TextGeneration,
1819} from "../Services/TextGeneration.ts";
1920
20const CODEX_MODEL = "gpt-5.3-codex";
2121const CODEX_REASONING_EFFORT = "low";
2222const CODEX_TIMEOUT_MS = 180_000;
2323
......@@ -187,6 +187,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
187187 outputSchemaJson,
188188 imagePaths = [],
189189 cleanupPaths = [],
190 model,
190191 }: {
191192 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
192193 cwd: string;
......@@ -194,6 +195,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
194195 outputSchemaJson: S;
195196 imagePaths?: ReadonlyArray<string>;
196197 cleanupPaths?: ReadonlyArray<string>;
198 model?: string;
197199 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
198200 Effect.gen(function* () {
199201 const schemaPath = yield* writeTempFile(
......@@ -212,7 +214,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
212214 "-s",
213215 "read-only",
214216 "--model",
215 CODEX_MODEL,
217 model ?? DEFAULT_GIT_TEXT_GENERATION_MODEL,
216218 "--config",
217219 `model_reasoning_effort="${CODEX_REASONING_EFFORT}"`,
218220 "--output-schema",
......@@ -353,6 +355,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
353355 cwd: input.cwd,
354356 prompt,
355357 outputSchemaJson,
358 ...(input.model ? { model: input.model } : {}),
356359 }).pipe(
357360 Effect.map(
358361 (generated) =>
......@@ -398,6 +401,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
398401 title: Schema.String,
399402 body: Schema.String,
400403 }),
404 ...(input.model ? { model: input.model } : {}),
401405 }).pipe(
402406 Effect.map(
403407 (generated) =>
......@@ -449,6 +453,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
449453 branch: Schema.String,
450454 }),
451455 imagePaths,
456 ...(input.model ? { model: input.model } : {}),
452457 });
453458
454459 return {
apps/server/src/git/Layers/GitManager.ts+11-2
......@@ -639,6 +639,7 @@ export const makeGitManager = Effect.gen(function* () {
639639 /** When true, also produce a semantic feature branch name. */
640640 includeBranch?: boolean;
641641 filePaths?: readonly string[];
642 model?: string;
642643 }) =>
643644 Effect.gen(function* () {
644645 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
......@@ -665,6 +666,7 @@ export const makeGitManager = Effect.gen(function* () {
665666 stagedSummary: limitContext(context.stagedSummary, 8_000),
666667 stagedPatch: limitContext(context.stagedPatch, 50_000),
667668 ...(input.includeBranch ? { includeBranch: true } : {}),
669 ...(input.model ? { model: input.model } : {}),
668670 })
669671 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
670672
......@@ -682,6 +684,7 @@ export const makeGitManager = Effect.gen(function* () {
682684 commitMessage?: string,
683685 preResolvedSuggestion?: CommitAndBranchSuggestion,
684686 filePaths?: readonly string[],
687 model?: string,
685688 ) =>
686689 Effect.gen(function* () {
687690 const suggestion =
......@@ -691,6 +694,7 @@ export const makeGitManager = Effect.gen(function* () {
691694 branch,
692695 ...(commitMessage ? { commitMessage } : {}),
693696 ...(filePaths ? { filePaths } : {}),
697 ...(model ? { model } : {}),
694698 }));
695699 if (!suggestion) {
696700 return { status: "skipped_no_changes" as const };
......@@ -704,7 +708,7 @@ export const makeGitManager = Effect.gen(function* () {
704708 };
705709 });
706710
707 const runPrStep = (cwd: string, fallbackBranch: string | null) =>
711 const runPrStep = (cwd: string, fallbackBranch: string | null, model?: string) =>
708712 Effect.gen(function* () {
709713 const details = yield* gitCore.statusDetails(cwd);
710714 const branch = details.branch ?? fallbackBranch;
......@@ -748,6 +752,7 @@ export const makeGitManager = Effect.gen(function* () {
748752 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
749753 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
750754 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
755 ...(model ? { model } : {}),
751756 });
752757
753758 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
......@@ -972,6 +977,7 @@ export const makeGitManager = Effect.gen(function* () {
972977 branch: string | null,
973978 commitMessage?: string,
974979 filePaths?: readonly string[],
980 model?: string,
975981 ) =>
976982 Effect.gen(function* () {
977983 const suggestion = yield* resolveCommitAndBranchSuggestion({
......@@ -980,6 +986,7 @@ export const makeGitManager = Effect.gen(function* () {
980986 ...(commitMessage ? { commitMessage } : {}),
981987 ...(filePaths ? { filePaths } : {}),
982988 includeBranch: true,
989 ...(model ? { model } : {}),
983990 });
984991 if (!suggestion) {
985992 return yield* gitManagerError(
......@@ -1028,6 +1035,7 @@ export const makeGitManager = Effect.gen(function* () {
10281035 initialStatus.branch,
10291036 input.commitMessage,
10301037 input.filePaths,
1038 input.textGenerationModel,
10311039 );
10321040 branchStep = result.branchStep;
10331041 commitMessageForStep = result.resolvedCommitMessage;
......@@ -1044,6 +1052,7 @@ export const makeGitManager = Effect.gen(function* () {
10441052 commitMessageForStep,
10451053 preResolvedCommitSuggestion,
10461054 input.filePaths,
1055 input.textGenerationModel,
10471056 );
10481057
10491058 const push = wantsPush
......@@ -1051,7 +1060,7 @@ export const makeGitManager = Effect.gen(function* () {
10511060 : { status: "skipped_not_requested" as const };
10521061
10531062 const pr = wantsPr
1054 ? yield* runPrStep(input.cwd, currentBranch)
1063 ? yield* runPrStep(input.cwd, currentBranch, input.textGenerationModel)
10551064 : { status: "skipped_not_requested" as const };
10561065
10571066 return {
apps/server/src/git/Services/TextGeneration.ts+6
......@@ -19,6 +19,8 @@ export interface CommitMessageGenerationInput {
1919 stagedPatch: string;
2020 /** When true, the model also returns a semantic branch name for the change. */
2121 includeBranch?: boolean;
22 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
23 model?: string;
2224}
2325
2426export interface CommitMessageGenerationResult {
......@@ -35,6 +37,8 @@ export interface PrContentGenerationInput {
3537 commitSummary: string;
3638 diffSummary: string;
3739 diffPatch: string;
40 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
41 model?: string;
3842}
3943
4044export interface PrContentGenerationResult {
......@@ -46,6 +50,8 @@ export interface BranchNameGenerationInput {
4650 cwd: string;
4751 message: string;
4852 attachments?: ReadonlyArray<ChatAttachment> | undefined;
53 /** Model to use for generation. Defaults to gpt-5.4-mini if not specified. */
54 model?: string;
4955}
5056
5157export interface BranchNameGenerationResult {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+2
......@@ -1,6 +1,7 @@
11import {
22 type ChatAttachment,
33 CommandId,
4 DEFAULT_GIT_TEXT_GENERATION_MODEL,
45 EventId,
56 type OrchestrationEvent,
67 type ProviderModelOptions,
......@@ -391,6 +392,7 @@ const make = Effect.gen(function* () {
391392 cwd,
392393 message: input.messageText,
393394 ...(attachments.length > 0 ? { attachments } : {}),
395 model: DEFAULT_GIT_TEXT_GENERATION_MODEL,
394396 })
395397 .pipe(
396398 Effect.catch((error) =>
apps/web/src/appSettings.test.ts+1
......@@ -28,6 +28,7 @@ describe("getAppModelOptions", () => {
2828
2929 expect(options.map((option) => option.slug)).toEqual([
3030 "gpt-5.4",
31 "gpt-5.4-mini",
3132 "gpt-5.3-codex",
3233 "gpt-5.3-codex-spark",
3334 "gpt-5.2-codex",
apps/web/src/appSettings.ts+2-1
......@@ -1,6 +1,6 @@
11import { useCallback } from "react";
22import { Option, Schema } from "effect";
3import { type ProviderKind } from "@t3tools/contracts";
3import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts";
44import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
55import { useLocalStorage } from "./hooks/useLocalStorage";
66
......@@ -34,6 +34,7 @@ const AppSettingsSchema = Schema.Struct({
3434 customCodexModels: Schema.Array(Schema.String).pipe(
3535 Schema.withConstructorDefault(() => Option.some([])),
3636 ),
37 textGenerationModel: Schema.optional(TrimmedNonEmptyString),
3738});
3839export type AppSettings = typeof AppSettingsSchema.Type;
3940export interface AppModelOption {
apps/web/src/components/GitActionsControl.tsx+7-1
......@@ -15,6 +15,7 @@ import {
1515 resolveQuickAction,
1616 summarizeGitResult,
1717} from "./GitActionsControl.logic";
18import { useAppSettings } from "~/appSettings";
1819import { Button } from "~/components/ui/button";
1920import { Checkbox } from "~/components/ui/checkbox";
2021import {
......@@ -154,6 +155,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
154155}
155156
156157export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
158 const { settings } = useAppSettings();
157159 const threadToastData = useMemo(
158160 () => (activeThreadId ? { threadId: activeThreadId } : undefined),
159161 [activeThreadId],
......@@ -191,7 +193,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
191193 const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient }));
192194
193195 const runImmediateGitActionMutation = useMutation(
194 gitRunStackedActionMutationOptions({ cwd: gitCwd, queryClient }),
196 gitRunStackedActionMutationOptions({
197 cwd: gitCwd,
198 queryClient,
199 model: settings.textGenerationModel ?? null,
200 }),
195201 );
196202 const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient }));
197203
apps/web/src/lib/gitReactQuery.ts+2
......@@ -112,6 +112,7 @@ export function gitCheckoutMutationOptions(input: {
112112export function gitRunStackedActionMutationOptions(input: {
113113 cwd: string | null;
114114 queryClient: QueryClient;
115 model?: string | null;
115116}) {
116117 return mutationOptions({
117118 mutationKey: gitMutationKeys.runStackedAction(input.cwd),
......@@ -134,6 +135,7 @@ export function gitRunStackedActionMutationOptions(input: {
134135 ...(commitMessage ? { commitMessage } : {}),
135136 ...(featureBranch ? { featureBranch } : {}),
136137 ...(filePaths ? { filePaths } : {}),
138 ...(input.model ? { model: input.model } : {}),
137139 });
138140 },
139141 onSettled: async () => {
apps/web/src/routes/_chat.settings.tsx+72-2
......@@ -1,9 +1,9 @@
11import { createFileRoute } from "@tanstack/react-router";
22import { useQuery } from "@tanstack/react-query";
33import { useCallback, useState } from "react";
4import { type ProviderKind } from "@t3tools/contracts";
4import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts";
55import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
6import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
6import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
77import { resolveAndPersistPreferredEditor } from "../editorPreferences";
88import { isElectron } from "../env";
99import { useTheme } from "../hooks/useTheme";
......@@ -112,6 +112,17 @@ function SettingsRouteView() {
112112 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
113113 const availableEditors = serverConfigQuery.data?.availableEditors;
114114
115 const gitTextGenerationModelOptions = getAppModelOptions(
116 "codex",
117 settings.customCodexModels,
118 settings.textGenerationModel,
119 );
120 const selectedGitTextGenerationModelLabel =
121 gitTextGenerationModelOptions.find(
122 (option) =>
123 option.slug === (settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL),
124 )?.name ?? settings.textGenerationModel;
125
115126 const openKeybindingsFile = useCallback(() => {
116127 if (!keybindingsConfigPath) return;
117128 setOpenKeybindingsError(null);
......@@ -502,6 +513,65 @@ function SettingsRouteView() {
502513 </div>
503514 </section>
504515
516 <section className="rounded-2xl border border-border bg-card p-5">
517 <div className="mb-4">
518 <h2 className="text-sm font-medium text-foreground">Git</h2>
519 <p className="mt-1 text-xs text-muted-foreground">
520 Configure the model used for generating commit messages, PR titles, and branch
521 names.
522 </p>
523 </div>
524
525 <div className="flex flex-col gap-4 rounded-lg border border-border bg-background px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
526 <div className="min-w-0 flex-1">
527 <p className="text-sm font-medium text-foreground">Text generation model</p>
528 <p className="text-xs text-muted-foreground">
529 Model used for auto-generated git content.
530 </p>
531 </div>
532 <Select
533 value={settings.textGenerationModel ?? DEFAULT_GIT_TEXT_GENERATION_MODEL}
534 onValueChange={(value) => {
535 if (value) {
536 updateSettings({
537 textGenerationModel: value,
538 });
539 }
540 }}
541 >
542 <SelectTrigger
543 className="w-full shrink-0 sm:w-48"
544 aria-label="Git text generation model"
545 >
546 <SelectValue>{selectedGitTextGenerationModelLabel}</SelectValue>
547 </SelectTrigger>
548 <SelectPopup align="end">
549 {gitTextGenerationModelOptions.map((option) => (
550 <SelectItem key={option.slug} value={option.slug}>
551 {option.name}
552 </SelectItem>
553 ))}
554 </SelectPopup>
555 </Select>
556 </div>
557
558 {settings.textGenerationModel !== defaults.textGenerationModel ? (
559 <div className="mt-3 flex justify-end">
560 <Button
561 size="xs"
562 variant="outline"
563 onClick={() =>
564 updateSettings({
565 textGenerationModel: defaults.textGenerationModel,
566 })
567 }
568 >
569 Restore default
570 </Button>
571 </div>
572 ) : null}
573 </section>
574
505575 <section className="rounded-2xl border border-border bg-card p-5">
506576 <div className="mb-4">
507577 <h2 className="text-sm font-medium text-foreground">Threads</h2>
packages/contracts/src/git.ts+5-1
......@@ -1,5 +1,6 @@
1import { Schema } from "effect";
1import { Option, Schema } from "effect";
22import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3import { DEFAULT_GIT_TEXT_GENERATION_MODEL } from "./model";
34
45const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
56
......@@ -64,6 +65,9 @@ export const GitRunStackedActionInput = Schema.Struct({
6465 filePaths: Schema.optional(
6566 Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)),
6667 ),
68 textGenerationModel: Schema.optional(TrimmedNonEmptyStringSchema).pipe(
69 Schema.withConstructorDefault(() => Option.some(DEFAULT_GIT_TEXT_GENERATION_MODEL)),
70 ),
6771});
6872export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
6973
packages/contracts/src/model.ts+3
......@@ -23,6 +23,7 @@ type ModelOption = {
2323export const MODEL_OPTIONS_BY_PROVIDER = {
2424 codex: [
2525 { slug: "gpt-5.4", name: "GPT-5.4" },
26 { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
2627 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
2728 { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
2829 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
......@@ -38,6 +39,8 @@ export const DEFAULT_MODEL_BY_PROVIDER = {
3839 codex: "gpt-5.4",
3940} as const satisfies Record<ProviderKind, ModelSlug>;
4041
42export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini" as const;
43
4144export const MODEL_SLUG_ALIASES_BY_PROVIDER = {
4245 codex: {
4346 "5.4": "gpt-5.4",