Skip to content
Source
Sign inSign up

textGeneration.ts

213 lines8.4 KB

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