-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: add opencode provider support #1758
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
115982c
2811ba6
1e1f31c
5ebbf95
d362997
4d1cf0b
ea16d83
21450f6
b7f73ce
8a3159c
c69f377
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,263 @@ | ||
| import { Effect, Layer, Schema } from "effect"; | ||
|
|
||
| import { | ||
| TextGenerationError, | ||
| type ChatAttachment, | ||
| type OpenCodeModelSelection, | ||
| } from "@t3tools/contracts"; | ||
| import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
|
|
||
| import { ServerConfig } from "../../config.ts"; | ||
| import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| import { | ||
| buildBranchNamePrompt, | ||
| buildCommitMessagePrompt, | ||
| buildPrContentPrompt, | ||
| buildThreadTitlePrompt, | ||
| } from "../Prompts.ts"; | ||
| import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | ||
| import { | ||
| sanitizeCommitSubject, | ||
| sanitizePrTitle, | ||
| sanitizeThreadTitle, | ||
| toJsonSchemaObject, | ||
| } from "../Utils.ts"; | ||
| import { | ||
| createOpenCodeSdkClient, | ||
| parseOpenCodeModelSlug, | ||
| startOpenCodeServerProcess, | ||
| toOpenCodeFileParts, | ||
| } from "../../provider/opencodeRuntime.ts"; | ||
|
|
||
| const makeOpenCodeTextGeneration = Effect.gen(function* () { | ||
| const serverConfig = yield* ServerConfig; | ||
| const serverSettingsService = yield* ServerSettingsService; | ||
|
|
||
| const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: { | ||
| readonly operation: | ||
| | "generateCommitMessage" | ||
| | "generatePrContent" | ||
| | "generateBranchName" | ||
| | "generateThreadTitle"; | ||
| readonly cwd: string; | ||
| readonly prompt: string; | ||
| readonly outputSchemaJson: S; | ||
| readonly modelSelection: OpenCodeModelSelection; | ||
| readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| }) { | ||
| const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); | ||
| if (!parsedModel) { | ||
| return yield* new TextGenerationError({ | ||
| operation: input.operation, | ||
| detail: "OpenCode model selection must use the 'provider/model' format.", | ||
| }); | ||
| } | ||
|
|
||
| const settings = yield* serverSettingsService.getSettings.pipe( | ||
| Effect.map((value) => value.providers.opencode), | ||
| Effect.orElseSucceed(() => ({ enabled: true, binaryPath: "opencode", customModels: [] })), | ||
| ); | ||
|
|
||
| const fileParts = toOpenCodeFileParts({ | ||
| attachments: input.attachments, | ||
| resolveAttachmentPath: (attachment) => | ||
| resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | ||
| }); | ||
|
|
||
| const structuredOutput = yield* Effect.acquireUseRelease( | ||
| Effect.tryPromise({ | ||
| try: () => startOpenCodeServerProcess({ binaryPath: settings.binaryPath }), | ||
| catch: (cause) => | ||
| new TextGenerationError({ | ||
| operation: input.operation, | ||
| detail: cause instanceof Error ? cause.message : "Failed to start OpenCode server.", | ||
| cause, | ||
| }), | ||
| }), | ||
| (server) => | ||
| Effect.tryPromise({ | ||
| try: async () => { | ||
| const client = createOpenCodeSdkClient({ baseUrl: server.url, directory: input.cwd }); | ||
| const session = await client.session.create({ | ||
| title: `T3 Code ${input.operation}`, | ||
| permission: [{ permission: "*", pattern: "*", action: "deny" }], | ||
| }); | ||
| if (!session.data) { | ||
| throw new Error("OpenCode session.create returned no session payload."); | ||
| } | ||
|
|
||
| const result = await client.session.prompt({ | ||
| sessionID: session.data.id, | ||
| model: parsedModel, | ||
| ...(input.modelSelection.options?.agent | ||
| ? { agent: input.modelSelection.options.agent } | ||
| : {}), | ||
| ...(input.modelSelection.options?.variant | ||
| ? { variant: input.modelSelection.options.variant } | ||
| : {}), | ||
| format: { | ||
| type: "json_schema", | ||
| schema: toJsonSchemaObject(input.outputSchemaJson) as Record<string, unknown>, | ||
| }, | ||
| parts: [{ type: "text", text: input.prompt }, ...fileParts], | ||
| }); | ||
| const structured = result.data?.info.structured; | ||
| if (structured === undefined) { | ||
| throw new Error("OpenCode returned no structured output."); | ||
| } | ||
| return structured; | ||
| }, | ||
| catch: (cause) => | ||
| new TextGenerationError({ | ||
| operation: input.operation, | ||
| detail: | ||
| cause instanceof Error ? cause.message : "OpenCode text generation request failed.", | ||
| cause, | ||
| }), | ||
| }), | ||
| (server) => Effect.sync(() => server.close()), | ||
| ); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Each text generation spawns and tears down server processMedium Severity Every call to Reviewed by Cursor Bugbot for commit 5ebbf95. Configure here. |
||
|
|
||
| return yield* Schema.decodeUnknownEffect(input.outputSchemaJson)(structuredOutput).pipe( | ||
| Effect.catchTag("SchemaError", (cause) => | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| Effect.fail( | ||
| new TextGenerationError({ | ||
| operation: input.operation, | ||
| detail: "OpenCode returned invalid structured output.", | ||
| cause, | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
| }); | ||
|
|
||
| const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| "OpenCodeTextGeneration.generateCommitMessage", | ||
| )(function* (input) { | ||
| if (input.modelSelection.provider !== "opencode") { | ||
| return yield* new TextGenerationError({ | ||
| operation: "generateCommitMessage", | ||
| detail: "Invalid model selection.", | ||
| }); | ||
| } | ||
|
|
||
| const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| branch: input.branch, | ||
| stagedSummary: input.stagedSummary, | ||
| stagedPatch: input.stagedPatch, | ||
| includeBranch: input.includeBranch === true, | ||
| }); | ||
| const generated = yield* runOpenCodeJson({ | ||
| operation: "generateCommitMessage", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| modelSelection: input.modelSelection, | ||
| }); | ||
|
|
||
| return { | ||
| subject: sanitizeCommitSubject(generated.subject), | ||
| body: generated.body.trim(), | ||
| ...("branch" in generated && typeof generated.branch === "string" | ||
| ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| : {}), | ||
| }; | ||
| }); | ||
|
|
||
| const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| "OpenCodeTextGeneration.generatePrContent", | ||
| )(function* (input) { | ||
| if (input.modelSelection.provider !== "opencode") { | ||
| return yield* new TextGenerationError({ | ||
| operation: "generatePrContent", | ||
| detail: "Invalid model selection.", | ||
| }); | ||
| } | ||
|
|
||
| const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| baseBranch: input.baseBranch, | ||
| headBranch: input.headBranch, | ||
| commitSummary: input.commitSummary, | ||
| diffSummary: input.diffSummary, | ||
| diffPatch: input.diffPatch, | ||
| }); | ||
| const generated = yield* runOpenCodeJson({ | ||
| operation: "generatePrContent", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| modelSelection: input.modelSelection, | ||
| }); | ||
|
|
||
| return { | ||
| title: sanitizePrTitle(generated.title), | ||
| body: generated.body.trim(), | ||
| }; | ||
| }); | ||
|
|
||
| const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| "OpenCodeTextGeneration.generateBranchName", | ||
| )(function* (input) { | ||
| if (input.modelSelection.provider !== "opencode") { | ||
| return yield* new TextGenerationError({ | ||
| operation: "generateBranchName", | ||
| detail: "Invalid model selection.", | ||
| }); | ||
| } | ||
|
|
||
| const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| message: input.message, | ||
| attachments: input.attachments, | ||
| }); | ||
| const generated = yield* runOpenCodeJson({ | ||
| operation: "generateBranchName", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| modelSelection: input.modelSelection, | ||
| attachments: input.attachments, | ||
| }); | ||
|
|
||
| return { | ||
| branch: sanitizeBranchFragment(generated.branch), | ||
| }; | ||
| }); | ||
|
|
||
| const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| "OpenCodeTextGeneration.generateThreadTitle", | ||
| )(function* (input) { | ||
| if (input.modelSelection.provider !== "opencode") { | ||
| return yield* new TextGenerationError({ | ||
| operation: "generateThreadTitle", | ||
| detail: "Invalid model selection.", | ||
| }); | ||
| } | ||
|
|
||
| const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| message: input.message, | ||
| attachments: input.attachments, | ||
| }); | ||
| const generated = yield* runOpenCodeJson({ | ||
| operation: "generateThreadTitle", | ||
| cwd: input.cwd, | ||
| prompt, | ||
| outputSchemaJson: outputSchema, | ||
| modelSelection: input.modelSelection, | ||
| attachments: input.attachments, | ||
| }); | ||
|
|
||
| return { | ||
| title: sanitizeThreadTitle(generated.title), | ||
| }; | ||
| }); | ||
|
|
||
| return { | ||
| generateCommitMessage, | ||
| generatePrContent, | ||
| generateBranchName, | ||
| generateThreadTitle, | ||
| } satisfies TextGenerationShape; | ||
| }); | ||
|
|
||
| export const OpenCodeTextGenerationLive = Layer.effect(TextGeneration, makeOpenCodeTextGeneration); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ import { | |
| } from "../Services/TextGeneration.ts"; | ||
| import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | ||
| import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; | ||
| import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Internal service tags so both concrete layers can coexist. | ||
|
|
@@ -31,16 +32,21 @@ class ClaudeTextGen extends ServiceMap.Service<ClaudeTextGen, TextGenerationShap | |
| "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", | ||
| ) {} | ||
|
|
||
| class OpenCodeTextGen extends ServiceMap.Service<OpenCodeTextGen, TextGenerationShape>()( | ||
| "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", | ||
| ) {} | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Routing implementation | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| const makeRoutingTextGeneration = Effect.gen(function* () { | ||
| const codex = yield* CodexTextGen; | ||
| const claude = yield* ClaudeTextGen; | ||
| const openCode = yield* OpenCodeTextGen; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Inconsistent indentation suggests wrong scope levelLow Severity The Additional Locations (1)Reviewed by Cursor Bugbot for commit 8a3159c. Configure here. |
||
|
|
||
| const route = (provider?: TextGenerationProvider): TextGenerationShape => | ||
| provider === "claudeAgent" ? claude : codex; | ||
| provider === "claudeAgent" ? claude : provider === "opencode" ? openCode : codex; | ||
|
|
||
| return { | ||
| generateCommitMessage: (input) => | ||
|
|
@@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( | |
| }), | ||
| ).pipe(Layer.provide(ClaudeTextGenerationLive)); | ||
|
|
||
| const InternalOpenCodeLayer = Layer.effect( | ||
| OpenCodeTextGen, | ||
| Effect.gen(function* () { | ||
| const svc = yield* TextGeneration; | ||
| return svc; | ||
| }), | ||
| ).pipe(Layer.provide(OpenCodeTextGenerationLive)); | ||
|
|
||
| export const RoutingTextGenerationLive = Layer.effect( | ||
| TextGeneration, | ||
| makeRoutingTextGeneration, | ||
| ).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); | ||
| ).pipe( | ||
| Layer.provide(InternalCodexLayer), | ||
| Layer.provide(InternalClaudeLayer), | ||
| Layer.provide(InternalOpenCodeLayer), | ||
| ); | ||


Uh oh!
There was an error while loading. Please reload this page.