History

feat: add opencode provider support (#1758)

Shoubhit Dash committed 7 months ago

Commit ce94fee1 parent 39ca3ee52 files · +4727 −228Browse files

Showing the changes to apps/server/src/git/Layers/OpenCodeTextGeneration.ts (1 of 52 files). Show all

Unified · Split

apps/server/src/git/Layers/OpenCodeTextGeneration.ts+422−0
@@ -0,0 +1,422 @@
1import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
2import * as Semaphore from "effect/Semaphore";
3
4import {
5 TextGenerationError,
6 type ChatAttachment,
7 type OpenCodeModelSelection,
8} from "@t3tools/contracts";
9import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10
11import { ServerConfig } from "../../config.ts";
12import { resolveAttachmentPath } from "../../attachmentStore.ts";
13import { ServerSettingsService } from "../../serverSettings.ts";
14import {
15 buildBranchNamePrompt,
16 buildCommitMessagePrompt,
17 buildPrContentPrompt,
18 buildThreadTitlePrompt,
19} from "../Prompts.ts";
20import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
21import {
22 sanitizeCommitSubject,
23 sanitizePrTitle,
24 sanitizeThreadTitle,
25 toJsonSchemaObject,
26} from "../Utils.ts";
27import {
28 createOpenCodeSdkClient,
29 type OpenCodeServerConnection,
30 type OpenCodeServerProcess,
31 parseOpenCodeModelSlug,
32 startOpenCodeServerProcess,
33 toOpenCodeFileParts,
34} from "../../provider/opencodeRuntime.ts";
35
36const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
37
38interface SharedOpenCodeTextGenerationServerState {
39 server: OpenCodeServerProcess | null;
40 binaryPath: string | null;
41 activeRequests: number;
42 idleCloseFiber: Fiber.Fiber<void, never> | null;
43}
44
45const makeOpenCodeTextGeneration = Effect.gen(function* () {
46 const serverConfig = yield* ServerConfig;
47 const serverSettingsService = yield* ServerSettingsService;
48 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
49 Scope.close(scope, Exit.void),
50 );
51 const sharedServerMutex = yield* Semaphore.make(1);
52 const sharedServerState: SharedOpenCodeTextGenerationServerState = {
53 server: null,
54 binaryPath: null,
55 activeRequests: 0,
56 idleCloseFiber: null,
57 };
58
59 const closeSharedServer = (server: OpenCodeServerProcess) => {
60 if (sharedServerState.server === server) {
61 sharedServerState.server = null;
62 sharedServerState.binaryPath = null;
63 }
64 server.close();
65 };
66
67 const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
68 const idleCloseFiber = sharedServerState.idleCloseFiber;
69 sharedServerState.idleCloseFiber = null;
70 if (idleCloseFiber !== null) {
71 yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore);
72 }
73 });
74
75 const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (
76 server: OpenCodeServerProcess,
77 ) {
78 yield* cancelIdleCloseFiber();
79 const fiber = yield* Effect.sleep(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS)).pipe(
80 Effect.andThen(
81 sharedServerMutex.withPermit(
82 Effect.sync(() => {
83 if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
84 return;
85 }
86 sharedServerState.idleCloseFiber = null;
87 closeSharedServer(server);
88 }),
89 ),
90 ),
91 Effect.forkIn(idleFiberScope),
92 );
93 sharedServerState.idleCloseFiber = fiber;
94 });
95
96 const acquireSharedServer = (input: {
97 readonly binaryPath: string;
98 readonly operation:
99 | "generateCommitMessage"
100 | "generatePrContent"
101 | "generateBranchName"
102 | "generateThreadTitle";
103 }) =>
104 sharedServerMutex.withPermit(
105 Effect.gen(function* () {
106 yield* cancelIdleCloseFiber();
107
108 const existingServer = sharedServerState.server;
109 if (existingServer !== null) {
110 if (
111 sharedServerState.binaryPath !== input.binaryPath &&
112 sharedServerState.activeRequests === 0
113 ) {
114 closeSharedServer(existingServer);
115 } else {
116 if (sharedServerState.binaryPath !== input.binaryPath) {
117 yield* Effect.logWarning(
118 "OpenCode shared server binary path mismatch: requested " +
119 input.binaryPath +
120 " but active server uses " +
121 sharedServerState.binaryPath +
122 "; reusing existing server because there are active requests",
123 );
124 }
125 sharedServerState.activeRequests += 1;
126 return existingServer;
127 }
128 }
129
130 const server = yield* Effect.tryPromise({
131 try: () => startOpenCodeServerProcess({ binaryPath: input.binaryPath }),
132 catch: (cause) =>
133 new TextGenerationError({
134 operation: input.operation,
135 detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.",
136 cause,
137 }),
138 });
139
140 sharedServerState.server = server;
141 sharedServerState.binaryPath = input.binaryPath;
142 sharedServerState.activeRequests = 1;
143 return server;
144 }),
145 );
146
147 const releaseSharedServer = (server: OpenCodeServerProcess) =>
148 sharedServerMutex.withPermit(
149 Effect.gen(function* () {
150 if (sharedServerState.server !== server) {
151 return;
152 }
153 sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1);
154 if (sharedServerState.activeRequests === 0) {
155 yield* scheduleIdleClose(server);
156 }
157 }),
158 );
159
160 yield* Effect.addFinalizer(() =>
161 sharedServerMutex.withPermit(
162 Effect.gen(function* () {
163 yield* cancelIdleCloseFiber();
164 const server = sharedServerState.server;
165 sharedServerState.server = null;
166 sharedServerState.binaryPath = null;
167 sharedServerState.activeRequests = 0;
168 if (server !== null) {
169 server.close();
170 }
171 }),
172 ),
173 );
174
175 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: {
176 readonly operation:
177 | "generateCommitMessage"
178 | "generatePrContent"
179 | "generateBranchName"
180 | "generateThreadTitle";
181 readonly cwd: string;
182 readonly prompt: string;
183 readonly outputSchemaJson: S;
184 readonly modelSelection: OpenCodeModelSelection;
185 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
186 }) {
187 const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);
188 if (!parsedModel) {
189 return yield* new TextGenerationError({
190 operation: input.operation,
191 detail: "OpenCode model selection must use the 'provider/model' format.",
192 });
193 }
194
195 const settings = yield* serverSettingsService.getSettings.pipe(
196 Effect.map(
197 (value) =>
198 value.providers?.opencode ?? {
199 enabled: true,
200 binaryPath: "opencode",
201 serverUrl: "",
202 serverPassword: "",
203 customModels: [],
204 },
205 ),
206 Effect.orElseSucceed(() => ({
207 enabled: true,
208 binaryPath: "opencode",
209 serverUrl: "",
210 serverPassword: "",
211 customModels: [],
212 })),
213 );
214
215 const fileParts = toOpenCodeFileParts({
216 attachments: input.attachments,
217 resolveAttachmentPath: (attachment) =>
218 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
219 });
220
221 const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>
222 Effect.tryPromise({
223 try: async () => {
224 const client = createOpenCodeSdkClient({
225 baseUrl: server.url,
226 directory: input.cwd,
227 ...(settings.serverUrl.length > 0 && settings.serverPassword
228 ? { serverPassword: settings.serverPassword }
229 : {}),
230 });
231 const session = await client.session.create({
232 title: `T3 Code ${input.operation}`,
233 permission: [{ permission: "*", pattern: "*", action: "deny" }],
234 });
235 if (!session.data) {
236 throw new Error("OpenCode session.create returned no session payload.");
237 }
238
239 const result = await client.session.prompt({
240 sessionID: session.data.id,
241 model: parsedModel,
242 ...(input.modelSelection.options?.agent
243 ? { agent: input.modelSelection.options.agent }
244 : {}),
245 ...(input.modelSelection.options?.variant
246 ? { variant: input.modelSelection.options.variant }
247 : {}),
248 format: {
249 type: "json_schema",
250 schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>,
251 },
252 parts: [{ type: "text", text: input.prompt }, ...fileParts],
253 });
254 const structured = result.data?.info?.structured;
255 if (structured === undefined) {
256 throw new Error("OpenCode returned no structured output.");
257 }
258 return structured;
259 },
260 catch: (cause) =>
261 new TextGenerationError({
262 operation: input.operation,
263 detail:
264 cause instanceof Error ? cause.message : "OpenCode text generation request failed.",
265 cause,
266 }),
267 });
268
269 const structuredOutput =
270 settings.serverUrl.length > 0
271 ? yield* runAgainstServer({ url: settings.serverUrl })
272 : yield* Effect.acquireUseRelease(
273 acquireSharedServer({
274 binaryPath: settings.binaryPath,
275 operation: input.operation,
276 }),
277 runAgainstServer,
278 releaseSharedServer,
279 );
280
281 return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe(
282 Effect.catchTag("SchemaError", (cause) =>
283 Effect.fail(
284 new TextGenerationError({
285 operation: input.operation,
286 detail: "OpenCode returned invalid structured output.",
287 cause,
288 }),
289 ),
290 ),
291 );
292 });
293
294 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
295 "OpenCodeTextGeneration.generateCommitMessage",
296 )(function* (input) {
297 if (input.modelSelection.provider !== "opencode") {
298 return yield* new TextGenerationError({
299 operation: "generateCommitMessage",
300 detail: "Invalid model selection.",
301 });
302 }
303
304 const { prompt, outputSchema } = buildCommitMessagePrompt({
305 branch: input.branch,
306 stagedSummary: input.stagedSummary,
307 stagedPatch: input.stagedPatch,
308 includeBranch: input.includeBranch === true,
309 });
310 const generated = yield* runOpenCodeJson({
311 operation: "generateCommitMessage",
312 cwd: input.cwd,
313 prompt,
314 outputSchemaJson: outputSchema,
315 modelSelection: input.modelSelection,
316 });
317
318 return {
319 subject: sanitizeCommitSubject(generated.subject),
320 body: generated.body.trim(),
321 ...("branch" in generated && typeof generated.branch === "string"
322 ? { branch: sanitizeFeatureBranchName(generated.branch) }
323 : {}),
324 };
325 });
326
327 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
328 "OpenCodeTextGeneration.generatePrContent",
329 )(function* (input) {
330 if (input.modelSelection.provider !== "opencode") {
331 return yield* new TextGenerationError({
332 operation: "generatePrContent",
333 detail: "Invalid model selection.",
334 });
335 }
336
337 const { prompt, outputSchema } = buildPrContentPrompt({
338 baseBranch: input.baseBranch,
339 headBranch: input.headBranch,
340 commitSummary: input.commitSummary,
341 diffSummary: input.diffSummary,
342 diffPatch: input.diffPatch,
343 });
344 const generated = yield* runOpenCodeJson({
345 operation: "generatePrContent",
346 cwd: input.cwd,
347 prompt,
348 outputSchemaJson: outputSchema,
349 modelSelection: input.modelSelection,
350 });
351
352 return {
353 title: sanitizePrTitle(generated.title),
354 body: generated.body.trim(),
355 };
356 });
357
358 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
359 "OpenCodeTextGeneration.generateBranchName",
360 )(function* (input) {
361 if (input.modelSelection.provider !== "opencode") {
362 return yield* new TextGenerationError({
363 operation: "generateBranchName",
364 detail: "Invalid model selection.",
365 });
366 }
367
368 const { prompt, outputSchema } = buildBranchNamePrompt({
369 message: input.message,
370 attachments: input.attachments,
371 });
372 const generated = yield* runOpenCodeJson({
373 operation: "generateBranchName",
374 cwd: input.cwd,
375 prompt,
376 outputSchemaJson: outputSchema,
377 modelSelection: input.modelSelection,
378 attachments: input.attachments,
379 });
380
381 return {
382 branch: sanitizeBranchFragment(generated.branch),
383 };
384 });
385
386 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
387 "OpenCodeTextGeneration.generateThreadTitle",
388 )(function* (input) {
389 if (input.modelSelection.provider !== "opencode") {
390 return yield* new TextGenerationError({
391 operation: "generateThreadTitle",
392 detail: "Invalid model selection.",
393 });
394 }
395
396 const { prompt, outputSchema } = buildThreadTitlePrompt({
397 message: input.message,
398 attachments: input.attachments,
399 });
400 const generated = yield* runOpenCodeJson({
401 operation: "generateThreadTitle",
402 cwd: input.cwd,
403 prompt,
404 outputSchemaJson: outputSchema,
405 modelSelection: input.modelSelection,
406 attachments: input.attachments,
407 });
408
409 return {
410 title: sanitizeThreadTitle(generated.title),
411 };
412 });
413
414 return {
415 generateCommitMessage,
416 generatePrContent,
417 generateBranchName,
418 generateThreadTitle,
419 } satisfies TextGenerationShape;
420});
421
422export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration);

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t