1import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";
5import * as Stream from "effect/Stream";
6import * as Option from "effect/Option";
7import { ChildProcessSpawner } from "effect/process";
8import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
9import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
10import { chatGptModels } from "../CodexChatGptModels.ts";
11import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts";
12import { ProviderDriverError } from "../Errors.ts";
13import { createCodexAdapterV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts";
14import {
15 checkCodexProviderStatus,
16 makePendingCodexProvider,
17 probeCodexSkillsForCwd,
18} from "../CodexProvider.ts";
19import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
20import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
21import {
22 type ProviderDriverCreateInput,
23 type ProviderInstance,
24} from "@t3tools/provider-core/server/driver";
25import { codexContinuationIdentity } from "./CodexHomeLayout.ts";
26import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity";
27import { HttpClient } from "effect/http";
28const DRIVER = ProviderDriverKind.make("codex");
29
30export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(function* (
31 input: ProviderDriverCreateInput<CodexSettings>,
32) {
33 const { instanceId, enabled, displayName, accentColor, config } = input;
34 const http = yield* HttpClient.HttpClient;
35 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
36 const fileSystem = yield* FileSystem.FileSystem;
37 const path = yield* Path.Path;
38 const host = yield* ProviderHost.ProviderHost;
39 const runtime = yield* makeCodexManagedRuntime({
40 instanceId,
41 enabled,
42 config,
43 environment: yield* mergeProviderInstanceEnvironment(input.environment),
44 });
45 const continuationIdentity = codexContinuationIdentity(runtime.homeLayout);
46 const stamp = withInstanceIdentity({
47 instanceId,
48 driverKind: DRIVER,
49 displayName,
50 accentColor,
51 continuationGroupKey: continuationIdentity.continuationKey,
52 });
53 const setup = { canAuthenticate: true, canInstall: true };
54 const runtimePaths = {
55 homePath: runtime.homeLayout.sharedHomePath,
56 shadowHomePath: runtime.homeLayout.mode === "authOverlay" ? runtime.homePath : null,
57 };
58 const pending = makePendingCodexProvider({ ...config, customModels: [] }).pipe(
59 Effect.map((draft) =>
60 stamp({
61 ...draft,
62 models: [],
63 setup,
64 runtimePaths,
65 }),
66 ),
67 );
68 const check = Effect.gen(function* () {
69 const base = yield* pending;
70 if (!enabled) return base;
71 const executable = yield* runtime.installation.resolve().pipe(Effect.option);
72 if (Option.isNone(executable))
73 return {
74 ...base,
75 installed: false,
76 models: [],
77 message: "Set up Codex to get started.",
78 auth: { status: "unauthenticated" as const },
79 };
80 const saved = yield* runtime.auth.read.pipe(Effect.orElseSucceed(() => Option.none()));
81 if (Option.isSome(saved) && !saved.value.scopes.includes("chatgpt.tokens.use.direct"))
82 return {
83 ...base,
84 installed: true,
85 version: executable.value.version,
86 models: [],
87 message:
88 "Signed in with ChatGPT, but token sharing is disabled. Sign in again and enable token sharing, or use another provider.",
89 auth: {
90 status: "unauthenticated" as const,
91 label: "ChatGPT",
92 ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}),
93 },
94 };
95 if (Option.isNone(saved))
96 return {
97 ...base,
98 installed: true,
99 version: executable.value.version,
100 models: [],
101 message: "Sign in with ChatGPT to use Codex.",
102 auth: { status: "unauthenticated" as const },
103 };
104 const usageLimits = {
105 checkedAt: base.checkedAt,
106 windows: [],
107 unavailable: {
108 reason: "unsupported" as const,
109 message:
110 "ChatGPT tracks subscription usage across connected apps. Open Usage settings with the account you connected to Codex.",
111 },
112 externalUsage: { label: "ChatGPT usage", url: "https://chatgpt.com/#settings/Usage" },
113 };
114 const managedAuth = {
115 subscriptionSharing: true,
116 profileId: saved.value.clientId,
117 status: "authenticated" as const,
118 type: "chatgpt",
119 label: "ChatGPT",
120 ...(saved.value.email?.trim() ? { email: saved.value.email.trim() } : {}),
121 };
122 return yield* runtime.auth.controller.withAccess!(runtime.resolve).pipe(
123 Effect.flatMap((effective) =>
124 Effect.gen(function* () {
125 const draft = yield* checkCodexProviderStatus(
126 effective.config,
127 undefined,
128 effective.environment,
129 managedAuth,
130 );
131 const models = yield* chatGptModels(
132 effective.environment.ACCESS_TOKEN!,
133 draft.models,
134 ).pipe(Effect.provideService(HttpClient.HttpClient, http));
135 return { ...draft, models };
136 }),
137 ),
138 Effect.map((draft) =>
139 stamp({
140 ...draft,
141 auth: managedAuth,
142 version: draft.version ?? executable.value.version,
143 usageLimits,
144 models: draft.models.map((model) => ({
145 ...model,
146 ...(model.capabilities
147 ? {
148 capabilities: {
149 ...model.capabilities,
150 optionDescriptors: (model.capabilities.optionDescriptors ?? []).filter(
151 (option) => option.id !== "serviceTier",
152 ),
153 },
154 }
155 : {}),
156 })),
157 setup,
158 runtimePaths,
159 ...(draft.slashCommands
160 ? {
161 slashCommands: draft.slashCommands.filter((command) => command.name !== "feedback"),
162 }
163 : {}),
164 }),
165 ),
166 Effect.catch(() =>
167 runtime.auth.read.pipe(
168 Effect.orElseSucceed(() => Option.none()),
169 Effect.map((current) => ({
170 ...base,
171 installed: true,
172 version: executable.value.version,
173 models: [],
174 auth: {
175 status:
176 Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct")
177 ? ("authenticated" as const)
178 : ("unauthenticated" as const),
179 label: "ChatGPT",
180 ...(Option.isSome(current) &&
181 current.value.scopes.includes("chatgpt.tokens.use.direct")
182 ? { subscriptionSharing: true, profileId: current.value.clientId }
183 : {}),
184 ...(Option.isSome(current) && current.value.email?.trim()
185 ? { email: current.value.email.trim() }
186 : {}),
187 },
188 ...(Option.isSome(current) && current.value.scopes.includes("chatgpt.tokens.use.direct")
189 ? { usageLimits }
190 : {}),
191 message: "Could not check Codex right now. Retry, or reconnect in provider settings.",
192 })),
193 ),
194 ),
195 Effect.scoped,
196 );
197 }).pipe(
198 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
199 Effect.provideService(FileSystem.FileSystem, fileSystem),
200 Effect.provideService(Path.Path, path),
201 );
202 const snapshot = yield* makeManagedServerProvider({
203 resolveMaintenance: () => Effect.succeed({ provider: DRIVER, packageName: null, update: null }),
204 getSettings: host.settings.get,
205 streamSettings: host.settings.changes,
206 haveSettingsChanged: () => false,
207 initialSnapshot: () => pending,
208 checkProvider: check,
209 }).pipe(
210 Effect.mapError(
211 (cause) =>
212 new ProviderDriverError({
213 driver: DRIVER,
214 instanceId,
215 detail: "Could not prepare managed Codex.",
216 cause,
217 }),
218 ),
219 );
220 yield* runtime.auth.controller.subscribe("managed-codex-snapshot").pipe(
221 // Disconnect also publishes idle when a saved account has no active sign-in flow.
222 Stream.filter((state) => ["idle", "succeeded", "failed", "cancelled"].includes(state.phase)),
223 // Sign-out closes the scope of an in-flight probe, which fails that refresh
224 // with an interrupt. Keep listening so the sign-out's own idle still refreshes.
225 Stream.runForEach(() => snapshot.refresh.pipe(Effect.ignoreCause({ log: true }))),
226 Effect.forkScoped,
227 );
228 const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve);
229 // Launch settings resolve per session from the signed-in token. The registry
230 // already wraps openSession in withAccess, so resolve without re-entering it.
231 const orchestrationAdapter = yield* createCodexAdapterV2(input, {
232 onUsageLimits: (update) => snapshot.applyUsageLimits(update),
233 resolveRuntime: runtime.resolve,
234 }).pipe(
235 Effect.mapError(
236 (cause) =>
237 new ProviderDriverError({
238 driver: DRIVER,
239 instanceId,
240 detail: "Failed to build Codex orchestration adapter.",
241 cause,
242 }),
243 ),
244 );
245 const nativeGeneration = yield* makeCodexTextGeneration(
246 config,
247 undefined,
248 snapshot.getSnapshot.pipe(Effect.map((value) => value.models)),
249 resolveRuntime,
250 );
251 const protect = <A>(operation: string, effect: Effect.Effect<A, TextGenerationError>) =>
252 runtime.auth.controller.withAccess!(effect).pipe(
253 Effect.scoped,
254 Effect.mapError(
255 (cause) =>
256 new TextGenerationError({
257 operation,
258 detail: "detail" in cause ? cause.detail : "Codex text generation failed.",
259 }),
260 ),
261 );
262 const textGeneration: ProviderInstance["textGeneration"] = {
263 generateCommitMessage: (value) =>
264 protect("generateCommitMessage", nativeGeneration.generateCommitMessage(value)),
265 generatePrContent: (value) =>
266 protect("generatePrContent", nativeGeneration.generatePrContent(value)),
267 generateBranchName: (value) =>
268 protect("generateBranchName", nativeGeneration.generateBranchName(value)),
269 generateThreadTitle: (value) =>
270 protect("generateThreadTitle", nativeGeneration.generateThreadTitle(value)),
271 generateReviewGuide: (value) =>
272 protect("generateReviewGuide", nativeGeneration.generateReviewGuide(value)),
273 };
274 return {
275 instanceId,
276 driverKind: DRIVER,
277 continuationIdentity,
278 displayName,
279 accentColor,
280 enabled,
281 snapshot,
282 orchestrationAdapter,
283 textGeneration,
284 auth: runtime.auth.controller,
285 snapshotForCwd: (cwd: string) =>
286 enabled
287 ? resolveRuntime.pipe(
288 Effect.flatMap((effective) =>
289 probeCodexSkillsForCwd({
290 binaryPath: effective.config.binaryPath,
291 homePath: effective.config.homePath,
292 launchArgs: effective.config.launchArgs,
293 cwd,
294 environment: effective.environment,
295 }),
296 ),
297 Effect.flatMap((skills) =>
298 snapshot.getSnapshot.pipe(Effect.map((draft) => ({ ...draft, skills }))),
299 ),
300 Effect.scoped,
301 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
302 Effect.catch(() => snapshot.getSnapshot),
303 )
304 : snapshot.getSnapshot,
305 } satisfies ProviderInstance;
306});