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