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 @@
1+import { Duration, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect";
2+import * as Semaphore from "effect/Semaphore";
3+
4+import {
5+ TextGenerationError,
6+ type ChatAttachment,
7+ type OpenCodeModelSelection,
8+} from "@t3tools/contracts";
9+import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
10+
11+import { ServerConfig } from "../../config.ts";
12+import { resolveAttachmentPath } from "../../attachmentStore.ts";
13+import { ServerSettingsService } from "../../serverSettings.ts";
14+import {
15+ buildBranchNamePrompt,
16+ buildCommitMessagePrompt,
17+ buildPrContentPrompt,
18+ buildThreadTitlePrompt,
19+} from "../Prompts.ts";
20+import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
21+import {
22+ sanitizeCommitSubject,
23+ sanitizePrTitle,
24+ sanitizeThreadTitle,
25+ toJsonSchemaObject,
26+} from "../Utils.ts";
27+import {
28+ createOpenCodeSdkClient,
29+ type OpenCodeServerConnection,
30+ type OpenCodeServerProcess,
31+ parseOpenCodeModelSlug,
32+ startOpenCodeServerProcess,
33+ toOpenCodeFileParts,
34+} from "../../provider/opencodeRuntime.ts";
35+
36+const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
37+
38+interface SharedOpenCodeTextGenerationServerState {
39+ server: OpenCodeServerProcess | null;
40+ binaryPath: string | null;
41+ activeRequests: number;
42+ idleCloseFiber: Fiber.Fiber<void, never> | null;
43+}
44+
45+const 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+
422+export 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