Skip to content
Source
Sign inSign up

TextGeneration.ts

234 lines9.3 KB

1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import type { ModelSelection, ProjectId, ProviderInstanceId } from "@t3tools/contracts";
5import {
6 isProviderAvailable,
7 isProviderTextGenerationCapable,
8 resolveProjectProviderInstanceEnabled,
9} from "@t3tools/contracts";
10import * as DateTime from "effect/DateTime";
11import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits";
12import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings";
13import * as ServerSettings from "../serverSettings.ts";
14import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration";
15import { TextGenerationError } from "@t3tools/contracts";
16​
17import * as ProviderInstallationGate from "../provider/ProviderInstallationGate.ts";
18import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
19import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
20import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
21import * as ThreadTitleLinks from "./ThreadTitleLinks.ts";
22import * as TextGenerators from "./TextGenerators.ts";
23import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
24​
25export type {
26 BranchNameGenerationInput,
27 BranchNameGenerationResult,
28 CommitMessageGenerationInput,
29 CommitMessageGenerationResult,
30 PrContentGenerationInput,
31 PrContentGenerationResult,
32 ReviewGuideGenerationInput,
33 ReviewGuideGenerationResult,
34 ThreadTitleGenerationInput,
35 ThreadTitleGenerationResult,
36 TranscriptionPostProcessingInput,
37 TranscriptionPostProcessingResult,
38} from "@t3tools/provider-core/server/textGeneration";
39​
40export class TextGeneration extends Context.Service<TextGeneration, ProviderTextGeneration>()(
41 "t3/textGeneration/TextGeneration",
42) {}
43​
44type TextGenerationOp =
45 | "generateCommitMessage"
46 | "generatePrContent"
47 | "generateBranchName"
48 | "generateThreadTitle"
49 | "generateReviewGuide"
50 | "generateTranscriptionPostProcessing";
51​
52const resolveInstance = (
53 registry: ProviderInstanceRegistry.ProviderInstanceRegistry["Service"],
54 operation: TextGenerationOp,
55 instanceId: ProviderInstanceId,
56): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> =>
57 registry.getInstance(instanceId).pipe(
58 Effect.flatMap((instance) =>
59 instance
60 ? Effect.succeed(instance.textGeneration)
61 : Effect.fail(
62 new TextGenerationError({
63 operation,
64 detail: `No provider instance registered for id '${instanceId}'.`,
65 }),
66 ),
67 ),
68 );
69​
70// Review guides investigate the checkout read-only; the other drivers reject that request.
71const INVESTIGATING_DRIVERS = new Set(["codex", "claudeAgent"]);
72​
73/** @public */
74export const make = Effect.gen(function* () {
75 const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
76 const sourceControl = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
77 const serverSettings = yield* ServerSettings.ServerSettingsService;
78 const installationGate = yield* ProviderInstallationGate.ProviderInstallationGate;
79 const textGenerators = yield* TextGenerators.TextGenerators;
80 const generate = Effect.fn("TextGeneration.generate")(function* <A>(
81 operation: TextGenerationOp,
82 selection: ModelSelection,
83 projectId: ProjectId | null | undefined,
84 invoke: (
85 service: ProviderTextGeneration,
86 modelSelection: ModelSelection,
87 ) => Effect.Effect<A, TextGenerationError>,
88 ) {
89 const textGenerator = yield* textGenerators.runner(selection.instanceId);
90 // A review guide reaches a text generator only through a fallback; it needs an agent instead.
91 const textGeneratorId = selection.instanceId;
92 const textGeneratorCannotRun =
93 textGenerator !== undefined && operation === "generateReviewGuide";
94 if (textGenerator !== undefined && !textGeneratorCannotRun) {
95 return yield* invoke(
96 TextGenerationOperations.fromRunner("TextGenerator", textGenerator),
97 selection,
98 );
99 }
100 const snapshots = yield* registry.listInstances.pipe(
101 Effect.flatMap((instances) =>
102 Effect.forEach(instances, (instance) => instance.snapshot.getSnapshot),
103 ),
104 );
105 const now = DateTime.toEpochMillis(yield* DateTime.now);
106 const selected = snapshots.find((provider) => provider.instanceId === selection.instanceId);
107 // Only a failed probe counts: a pending one is also not installed yet.
108 const unusable =
109 textGeneratorCannotRun ||
110 (selected?.enabled === true &&
111 (!isProviderTextGenerationCapable(selected) ||
112 selected.models.find((model) => model.slug === selection.model)
113 ?.supportsTextGeneration === false ||
114 (selected.status === "error" &&
115 (!selected.installed || selected.auth.status === "unauthenticated"))));
116 if (unusable || exhaustedUsageWindow(selected, now, selection.model)) {
117 const settings = yield* serverSettings.getSettings.pipe(
118 Effect.mapError(
119 (cause) =>
120 new TextGenerationError({
121 operation,
122 detail: "Could not read fallback model settings.",
123 cause,
124 }),
125 ),
126 );
127 selection =
128 fallbackTextGenerationModelSelection(
129 settings.providerInstances,
130 (instanceId, driver, model) => {
131 const provider = snapshots.find((candidate) => candidate.instanceId === instanceId);
132 return (
133 instanceId !== selection.instanceId &&
134 provider?.enabled === true &&
135 resolveProjectProviderInstanceEnabled(settings, projectId ?? null, instanceId) &&
136 provider.installed &&
137 provider.auth.status !== "unauthenticated" &&
138 provider.status !== "error" &&
139 isProviderAvailable(provider) &&
140 isProviderTextGenerationCapable(provider) &&
141 (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) &&
142 !exhaustedUsageWindow(provider, now, model)
143 );
144 },
145 { defaultModelSelection: settings.defaultModelSelection },
146 ) ?? selection;
147 }
148 if (textGeneratorCannotRun && selection.instanceId === textGeneratorId) {
149 return yield* new TextGenerationError({
150 operation,
151 detail:
152 "Review guides need a coding agent that can read the checkout. None is ready. Pick Codex or Claude as the review guide model in Settings → Source control.",
153 });
154 }
155 return yield* installationGate.execute(
156 selection.instanceId,
157 resolveInstance(registry, operation, selection.instanceId).pipe(
158 Effect.flatMap((service) => invoke(service, selection)),
159 ),
160 );
161 });
162 return TextGeneration.of({
163 generateCommitMessage: Effect.fn("TextGeneration.generateCommitMessage")(function (input) {
164 return generate(
165 "generateCommitMessage",
166 input.modelSelection,
167 input.projectId,
168 (service, modelSelection) => service.generateCommitMessage({ ...input, modelSelection }),
169 );
170 }),
171 generatePrContent: Effect.fn("TextGeneration.generatePrContent")(function (input) {
172 return generate(
173 "generatePrContent",
174 input.modelSelection,
175 input.projectId,
176 (service, modelSelection) => service.generatePrContent({ ...input, modelSelection }),
177 );
178 }),
179 generateBranchName: Effect.fn("TextGeneration.generateBranchName")(function (input) {
180 return generate(
181 "generateBranchName",
182 input.modelSelection,
183 input.projectId,
184 (service, modelSelection) => service.generateBranchName({ ...input, modelSelection }),
185 );
186 }),
187 generateReviewGuide: Effect.fn("TextGeneration.generateReviewGuide")(function (input) {
188 return generate(
189 "generateReviewGuide",
190 input.modelSelection,
191 input.projectId,
192 (service, modelSelection) => service.generateReviewGuide({ ...input, modelSelection }),
193 );
194 }),
195 generateTranscriptionPostProcessing: Effect.fn(
196 "TextGeneration.generateTranscriptionPostProcessing",
197 )(function (input) {
198 return generate(
199 "generateTranscriptionPostProcessing",
200 input.modelSelection,
201 null,
202 (service, modelSelection) =>
203 service.generateTranscriptionPostProcessing({ ...input, modelSelection }),
204 );
205 }),
206 generateThreadTitle: Effect.fn("TextGeneration.generateThreadTitle")(function (input) {
207 return generate(
208 "generateThreadTitle",
209 input.modelSelection,
210 input.projectId,
211 (textGeneration, modelSelection) =>
212 Effect.gen(function* () {
213 const linkedContext =
214 input.linkedContext ??
215 (yield* ThreadTitleLinks.resolveThreadTitleLinks(input).pipe(
216 Effect.provideService(
217 SourceControlProviderRegistry.SourceControlProviderRegistry,
218 sourceControl,
219 ),
220 ));
221 return yield* textGeneration.generateThreadTitle({
222 ...input,
223 linkedContext,
224 modelSelection,
225 });
226 }),
227 );
228 }),
229 });
230});
231​
232export const layer = Layer.effect(TextGeneration, make).pipe(
233 Layer.provideMerge(TextGenerators.layer),
234);