1/**
2 * Text generation on an OpenCode 2 server. Both of its `generate` endpoints
3 * answer 403 on OpenCode's free tier ("free tier can only be used from within
4 * OpenCode"), so each request runs as a prompt in a temporary session, as 1.x
5 * does, and the session is removed afterwards.
6 *
7 * @module textGeneration/OpenCode2TextGeneration
8 */
9import { AbsolutePath, Location, Model, Provider, Session } from "@opencode/client/effect";
10import { TextGenerationError } from "@t3tools/contracts";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
12import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";
16
17import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
18import * as OpenCode2Server from "./OpenCode2Server.ts";
19import * as OpenCodeRuntime from "../OpenCodeRuntime.ts";
20import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
21
22const isTextGenerationError = Schema.is(TextGenerationError);
23
24/** A generation that takes this long is stuck; its session is stopped and removed. */
25const GENERATION_TIMEOUT = "3 minutes";
26
27/**
28 * Nothing in a text generation needs a tool, so every tool asks and T3 is not
29 * there to answer. Denying `shell` or `read` outright gets the whole session
30 * refused on OpenCode's free models, and an ask that is never answered would
31 * hang, so asks are rejected as they arrive.
32 */
33const GENERATION_PERMISSIONS = [{ action: "*", resource: "*", effect: "ask" }] as const;
34
35/** How a generation ended. A failure's cause is the provider's own error, kept out of `detail`. */
36type Outcome =
37 | { readonly _tag: "text"; readonly text: string }
38 | { readonly _tag: "failed"; readonly detail: string; readonly cause?: unknown };
39
40const runOnServer = (
41 connection: OpenCode2Server.OpenCode2Connection,
42 input: TextGenerationOperations.Request<Schema.Top>,
43) =>
44 Effect.gen(function* () {
45 const { resolveAttachmentPath } = yield* ProviderHost.ProviderHost;
46 const { client } = connection;
47 const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model);
48 if (parsed === null) {
49 return yield* new TextGenerationError({
50 operation: input.operation,
51 detail: "OpenCode model selection must use the 'provider/model' format.",
52 });
53 }
54 const variant = getModelSelectionStringOptionValue(input.modelSelection, "variant");
55 const outcome = yield* Deferred.make<Outcome>();
56 let sessionId: string | undefined;
57 const texts = new Map<string, string>();
58 // Subscribed first: the stream is volatile, and the reply arrives on it.
59 const events = yield* connection.events;
60 yield* events.pipe(
61 Stream.runForEach((event) => {
62 // An execution end a newer server sent in a shape this build cannot
63 // read still ends the generation, as it ends a turn in the adapter.
64 if (event.type === "unreadable.execution.ended") {
65 if (event.sessionID !== sessionId) return Effect.void;
66 return Deferred.succeed(
67 outcome,
68 event.executionType === "session.execution.succeeded"
69 ? { _tag: "text", text: [...texts.values()].join("\n").trim() }
70 : {
71 _tag: "failed",
72 detail: "OpenCode ended the generation in a way this version cannot read.",
73 cause: event,
74 },
75 );
76 }
77 if (
78 sessionId === undefined ||
79 !("data" in event) ||
80 !("sessionID" in event.data) ||
81 event.data.sessionID !== sessionId
82 ) {
83 return Effect.void;
84 }
85 switch (event.type) {
86 case "session.text.ended":
87 texts.set(`${event.data.assistantMessageID}:${event.data.ordinal}`, event.data.text);
88 return Effect.void;
89 case "permission.asked":
90 return client.permission
91 .reply({
92 sessionID: event.data.sessionID,
93 requestID: event.data.id,
94 decision: "reject",
95 message: "Tools are not available while generating text.",
96 })
97 .pipe(Effect.ignore({ log: true }));
98 case "session.execution.succeeded":
99 return Deferred.succeed(outcome, {
100 _tag: "text",
101 text: [...texts.values()].join("\n").trim(),
102 });
103 case "session.execution.failed":
104 return Deferred.succeed(outcome, {
105 _tag: "failed",
106 detail: "OpenCode could not generate the text.",
107 cause: event.data.error,
108 });
109 case "session.execution.interrupted":
110 return Deferred.succeed(outcome, {
111 _tag: "failed",
112 detail: "OpenCode stopped the generation.",
113 });
114 default:
115 return Effect.void;
116 }
117 }),
118 // The reply only arrives on this stream, so a lost stream ends the wait.
119 Effect.exit,
120 Effect.flatMap((exit) =>
121 Deferred.succeed(outcome, {
122 _tag: "failed",
123 detail: "The OpenCode event stream was lost.",
124 cause: exit,
125 }),
126 ),
127 Effect.forkScoped,
128 );
129 const session = yield* client.session.create({
130 title: `T3 Code ${input.operation}`,
131 location: Location.PublicRef.make({ directory: AbsolutePath.make(input.cwd) }),
132 model: Model.Ref.make({
133 providerID: Provider.ID.make(parsed.providerID),
134 id: Model.ID.make(parsed.modelID),
135 ...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
136 }),
137 permissions: GENERATION_PERMISSIONS,
138 });
139 sessionId = session.id;
140 yield* Effect.addFinalizer(() =>
141 client.session
142 .remove({ sessionID: session.id })
143 .pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: true })),
144 );
145 const images = (input.attachments ?? []).flatMap((attachment) => {
146 if (attachment.type !== "image") return [];
147 const path = resolveAttachmentPath(attachment);
148 return path === null ? [] : [{ uri: `file://${path}`, name: attachment.name }];
149 });
150 yield* client.session.prompt({
151 sessionID: session.id,
152 text: input.prompt,
153 ...(images.length === 0 ? {} : { files: images }),
154 });
155 const result = yield* Deferred.await(outcome).pipe(
156 Effect.timeoutOrElse({
157 duration: GENERATION_TIMEOUT,
158 orElse: () =>
159 client.session.interrupt({ sessionID: Session.ID.make(session.id) }).pipe(
160 Effect.ignore,
161 Effect.as<Outcome>({
162 _tag: "failed",
163 detail: "OpenCode did not finish generating in time.",
164 }),
165 ),
166 }),
167 );
168 if (result._tag === "failed") {
169 return yield* new TextGenerationError({
170 operation: input.operation,
171 detail: result.detail,
172 ...(result.cause === undefined ? {} : { cause: result.cause }),
173 });
174 }
175 if (result.text.length === 0) {
176 return yield* new TextGenerationError({
177 operation: input.operation,
178 detail: "OpenCode returned empty output.",
179 });
180 }
181 return result.text;
182 }).pipe(Effect.scoped);
183
184/** Text generation for an instance whose server is OpenCode 2. */
185export const make = Effect.fn("OpenCode2TextGeneration.make")(function* () {
186 const server = yield* OpenCode2Server.OpenCode2Server;
187 const host = yield* ProviderHost.ProviderHost;
188 const run: TextGenerationOperations.Runner = (input) =>
189 server
190 .withConnection((connection) => runOnServer(connection, input))
191 .pipe(
192 Effect.provideService(ProviderHost.ProviderHost, host),
193 Effect.mapError((cause) =>
194 isTextGenerationError(cause)
195 ? cause
196 : new TextGenerationError({
197 operation: input.operation,
198 detail: "OpenCode text generation failed.",
199 cause,
200 }),
201 ),
202 Effect.flatMap((raw) => TextGenerationOperations.decodeJsonReply(input, "OpenCode", raw)),
203 );
204 return TextGenerationOperations.fromRunner(
205 "OpenCode2TextGeneration",
206 TextGenerationOperations.withoutInvestigation("OpenCode", run),
207 );
208});