1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
4import * as Path from "effect/Path";
5import * as Schema from "effect/Schema";
6import * as Scope from "effect/Scope";
7import * as Stream from "effect/Stream";
8import { ChildProcess, ChildProcessSpawner } from "effect/process";
9
10import {
11 type CodexSettings,
12 DEFAULT_TEXT_GENERATION_REASONING_EFFORT,
13 type ServerProviderModel,
14 TextGenerationError,
15} from "@t3tools/contracts";
16import { resolveSpawnCommand } from "@t3tools/shared/shell";
17
18import { resolveAttachmentPath } from "../attachmentStore.ts";
19import * as ServerConfig from "../config.ts";
20import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
21import { codexExecLaunchArgs, resolveCodexLaunchArgs } from "../provider/codexLaunchArgs.ts";
22import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations";
23import {
24 normalizeCliError,
25 toJsonSchemaObject,
26} from "@t3tools/provider-core/server/textGenerationUtils";
27import { codexModelFamily, getModelSelectionStringOptionValue } from "@t3tools/shared/model";
28import { getCodexServiceTierOptionValue } from "../codexModelOptions.ts";
29import * as HostProcess from "@t3tools/shared/HostProcess";
30
31const CODEX_TIMEOUT_MS = 180_000;
32const CODEX_MCP_LIST_TIMEOUT_MS = 10_000;
33// Plugins and apps bring MCP servers of their own; the user's are listed and
34// turned off by name below. These follow the user's launch args, so they win
35// over a `-c features.plugins=true` there, which `--disable plugins` does not.
36const CODEX_NO_PLUGIN_ARGS = [
37 "--config",
38 "features.plugins=false",
39 "--config",
40 "features.apps=false",
41] as const;
42const CodexMcpServers = Schema.fromJsonString(
43 Schema.Array(Schema.Struct({ name: Schema.String, enabled: Schema.Boolean })),
44);
45const decodeCodexMcpServers = Schema.decodeEffect(CodexMcpServers);
46
47/**
48 * The `--config` value that turns off each MCP server Codex would start.
49 * `mcp_servers={}` merges into the user's servers rather than replacing them,
50 * so each is named; quoting the name keeps one with a dot a single key.
51 */
52const codexMcpServersOff = (names: ReadonlyArray<string>): string | null =>
53 names.length === 0
54 ? null
55 : `mcp_servers={${names.map((name) => `${JSON.stringify(name)}={enabled=false}`).join(",")}}`;
56const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
57/**
58 * Build a Codex text-generation closure bound to a specific `CodexSettings`
59 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
60 */
61export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (
62 codexConfig: CodexSettings,
63 environment?: NodeJS.ProcessEnv,
64 getModels: Effect.Effect<ReadonlyArray<ServerProviderModel>> = Effect.succeed([]),
65 resolveRuntime?: Effect.Effect<
66 import("../provider/CodexManagedRuntime.ts").CodexEffectiveRuntime,
67 import("@t3tools/contracts").ProviderSetupError,
68 Scope.Scope
69 >,
70) {
71 const fileSystem = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
74 const serverConfig = yield* Effect.service(ServerConfig.ServerConfig);
75 const resolvedEnvironment = environment ?? process.env;
76
77 const readStreamAsString = <E>(
78 operation: string,
79 stream: Stream.Stream<Uint8Array, E>,
80 ): Effect.Effect<string, TextGenerationError> =>
81 stream.pipe(
82 Stream.decodeText(),
83 Stream.runFold(
84 () => "",
85 (acc, chunk) => acc + chunk,
86 ),
87 Effect.mapError((cause) =>
88 normalizeCliError("codex", operation, cause, "Failed to collect process output"),
89 ),
90 );
91
92 const removeTempFileDir = (filePath: string): Effect.Effect<void, never> =>
93 fileSystem
94 .remove(path.dirname(filePath), { recursive: true })
95 .pipe(Effect.catch(() => Effect.void));
96
97 // Deliberately unscoped: text generation runs from background fibers whose
98 // ambient scope may already be closed (a closed scope reaps the temp
99 // directory the moment it is created). Each allocation removes its own
100 // directory on failure; success-path cleanup is explicit in runCodexJson.
101 const writeTempFile = (
102 operation: string,
103 prefix: string,
104 content: string,
105 ): Effect.Effect<string, TextGenerationError> =>
106 fileSystem
107 .makeTempFile({
108 prefix: `t3code-${prefix}-${process.pid}-`,
109 })
110 .pipe(
111 Effect.tap((filePath) =>
112 fileSystem
113 .writeFileString(filePath, content)
114 .pipe(Effect.onError(() => removeTempFileDir(filePath))),
115 ),
116 Effect.mapError(
117 (cause) =>
118 new TextGenerationError({
119 operation,
120 detail: `Failed to write temp file`,
121 cause,
122 }),
123 ),
124 );
125
126 const encodeJsonForOperation = (
127 operation: TextGenerationOperations.Operation,
128 value: unknown,
129 ): Effect.Effect<string, TextGenerationError> =>
130 encodeJsonString(value).pipe(
131 Effect.mapError(
132 (cause) =>
133 new TextGenerationError({
134 operation,
135 detail: "Failed to encode structured output schema.",
136 cause,
137 }),
138 ),
139 );
140
141 const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* (
142 attachments: TextGenerationOperations.Request<Schema.Top>["attachments"],
143 ) {
144 if (!attachments || attachments.length === 0) {
145 return [];
146 }
147
148 const imagePaths: string[] = [];
149 for (const attachment of attachments) {
150 if (attachment.type !== "image") {
151 continue;
152 }
153
154 const resolvedPath = resolveAttachmentPath({
155 attachmentsDir: serverConfig.attachmentsDir,
156 attachment,
157 });
158 if (!resolvedPath || !path.isAbsolute(resolvedPath)) {
159 continue;
160 }
161 const fileInfo = yield* fileSystem.stat(resolvedPath).pipe(Effect.orElseSucceed(() => null));
162 if (!fileInfo || fileInfo.type !== "File") {
163 continue;
164 }
165 imagePaths.push(resolvedPath);
166 }
167 return imagePaths;
168 });
169
170 /**
171 * The MCP servers Codex would start for this configuration, by name. Text
172 * generation goes ahead with them listed as none if Codex cannot list them.
173 */
174 const listEnabledMcpServers = Effect.fn("listEnabledMcpServers")(
175 function* (input: {
176 readonly binary: string;
177 readonly cwd: string;
178 readonly env: NodeJS.ProcessEnv;
179 readonly launchArgs: ReadonlyArray<string>;
180 }) {
181 const spawnCommand = yield* resolveSpawnCommand(
182 input.binary,
183 [...input.launchArgs, ...CODEX_NO_PLUGIN_ARGS, "mcp", "list", "--json"],
184 { env: input.env },
185 );
186 const child = yield* commandSpawner.spawn(
187 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
188 env: input.env,
189 cwd: input.cwd,
190 shell: spawnCommand.shell,
191 }),
192 );
193 const [stdout, , exitCode] = yield* Effect.all(
194 [
195 child.stdout.pipe(Stream.decodeText(), Stream.mkString),
196 child.stderr.pipe(Stream.runDrain),
197 child.exitCode,
198 ],
199 { concurrency: "unbounded" },
200 );
201 if (exitCode !== 0) {
202 yield* Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", {
203 reason: "exit",
204 exitCode,
205 });
206 return [];
207 }
208 const servers = yield* decodeCodexMcpServers(stdout).pipe(
209 Effect.tapError(() =>
210 Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", {
211 reason: "decode",
212 }),
213 ),
214 );
215 return servers.filter((server) => server.enabled).map((server) => server.name);
216 },
217 Effect.scoped,
218 Effect.timeoutOption(CODEX_MCP_LIST_TIMEOUT_MS),
219 Effect.flatMap(
220 Option.match({
221 onNone: () =>
222 Effect.logWarning("codex mcp list failed; text generation keeps its MCP servers", {
223 reason: "timeout",
224 }).pipe(Effect.as<ReadonlyArray<string>>([])),
225 onSome: (names) => Effect.succeed(names),
226 }),
227 ),
228 Effect.orElseSucceed((): ReadonlyArray<string> => []),
229 );
230
231 const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({
232 operation,
233 cwd,
234 prompt,
235 outputSchema: outputSchemaJson,
236 modelSelection,
237 attachments,
238 investigate,
239 }: TextGenerationOperations.Request<S>): Effect.fn.Return<
240 S["Type"],
241 TextGenerationError,
242 S["DecodingServices"]
243 > {
244 const imagePaths = yield* materializeImageAttachments(attachments);
245 const schemaJson = yield* encodeJsonForOperation(
246 operation,
247 toJsonSchemaObject(outputSchemaJson),
248 );
249 const schemaPath = yield* writeTempFile(operation, "codex-schema", schemaJson);
250 const outputPath = yield* writeTempFile(operation, "codex-output", "").pipe(
251 Effect.onError(() => removeTempFileDir(schemaPath)),
252 );
253
254 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
255 const resolved = resolveRuntime
256 ? yield* resolveRuntime.pipe(
257 Effect.mapError(
258 (cause) => new TextGenerationError({ operation, detail: cause.detail }),
259 ),
260 )
261 : undefined;
262 const effectiveConfig = resolved?.config ?? codexConfig;
263 const effectiveEnvironment = resolved?.environment ?? resolvedEnvironment;
264 const models = yield* getModels;
265 const requestedModel = modelSelection.model;
266 const model =
267 models.find((candidate) => candidate.slug === requestedModel)?.slug ??
268 models.find(
269 (candidate) => !candidate.isCustom && codexModelFamily(candidate.slug) === requestedModel,
270 )?.slug ??
271 requestedModel;
272 const launchArgs = resolveCodexLaunchArgs(effectiveConfig.launchArgs, effectiveEnvironment);
273 const reasoningEffort =
274 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
275 DEFAULT_TEXT_GENERATION_REASONING_EFFORT;
276 const serviceTier = resolved ? undefined : getCodexServiceTierOptionValue(modelSelection);
277 const binary = effectiveConfig.binaryPath || "codex";
278 const env = {
279 ...effectiveEnvironment,
280 ...(effectiveConfig.homePath
281 ? {
282 CODEX_HOME: expandHomePath(
283 effectiveConfig.homePath,
284 yield* HostProcess.HomeDirectory,
285 ),
286 }
287 : {}),
288 };
289 const execLaunchArgs = codexExecLaunchArgs(launchArgs);
290 const mcpServersOff = yield* listEnabledMcpServers({
291 binary,
292 cwd,
293 env,
294 // `codex mcp` rejects --strict-config; the overrides still apply.
295 launchArgs: execLaunchArgs.filter((arg) => arg !== "--strict-config"),
296 }).pipe(Effect.map(codexMcpServersOff));
297 const spawnCommand = yield* resolveSpawnCommand(
298 binary,
299 [
300 "exec",
301 ...execLaunchArgs,
302 // Text generation needs only the prompt. A user's MCP server would
303 // otherwise start and could act on it, as Claude's --strict-mcp-config
304 // prevents for Claude.
305 ...CODEX_NO_PLUGIN_ARGS,
306 ...(mcpServersOff ? ["--config", mcpServersOff] : []),
307 "--ephemeral",
308 "--skip-git-repo-check",
309 "-s",
310 "read-only",
311 "--model",
312 model,
313 "--config",
314 `model_reasoning_effort="${reasoningEffort}"`,
315 ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []),
316 "--output-schema",
317 schemaPath,
318 "--output-last-message",
319 outputPath,
320 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
321 "-",
322 ],
323 { env: effectiveEnvironment },
324 );
325 const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, {
326 env,
327 cwd,
328 shell: spawnCommand.shell,
329 stdin: {
330 stream: Stream.encodeText(Stream.make(prompt)),
331 },
332 });
333
334 const child = yield* commandSpawner
335 .spawn(command)
336 .pipe(
337 Effect.mapError((cause) =>
338 normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"),
339 ),
340 );
341
342 const [stdout, stderr, exitCode] = yield* Effect.all(
343 [
344 readStreamAsString(operation, child.stdout),
345 readStreamAsString(operation, child.stderr),
346 child.exitCode.pipe(
347 Effect.mapError((cause) =>
348 normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"),
349 ),
350 ),
351 ],
352 { concurrency: "unbounded" },
353 );
354
355 if (exitCode !== 0) {
356 const stderrDetail = stderr.trim();
357 const stdoutDetail = stdout.trim();
358 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
359 return yield* new TextGenerationError({
360 operation,
361 detail:
362 detail.length > 0
363 ? `Codex CLI command failed: ${detail}`
364 : `Codex CLI command failed with code ${exitCode}.`,
365 });
366 }
367 });
368
369 const cleanup = Effect.all([removeTempFileDir(schemaPath), removeTempFileDir(outputPath)], {
370 concurrency: "unbounded",
371 }).pipe(Effect.asVoid);
372
373 return yield* Effect.gen(function* () {
374 yield* runCodexCommand().pipe(
375 Effect.scoped,
376 // The read-only sandbox already holds an investigation to reading; it only needs longer.
377 Effect.timeoutOption(investigate?.timeoutMs ?? CODEX_TIMEOUT_MS),
378 Effect.flatMap(
379 Option.match({
380 onNone: () =>
381 Effect.fail(
382 new TextGenerationError({ operation, detail: "Codex CLI request timed out." }),
383 ),
384 onSome: () => Effect.void,
385 }),
386 ),
387 );
388
389 const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson));
390
391 return yield* fileSystem.readFileString(outputPath).pipe(
392 Effect.mapError(
393 (cause) =>
394 new TextGenerationError({
395 operation,
396 detail: "Failed to read Codex output file.",
397 cause,
398 }),
399 ),
400 Effect.flatMap(decodeOutput),
401 Effect.catchTags({
402 SchemaError: (cause) =>
403 Effect.fail(
404 new TextGenerationError({
405 operation,
406 detail: "Codex returned invalid structured output.",
407 cause,
408 }),
409 ),
410 }),
411 );
412 }).pipe(Effect.ensuring(cleanup));
413 });
414
415 return TextGenerationOperations.fromRunner("CodexTextGeneration", runCodexJson);
416});