| 1 | import { ProviderDriverKind, TextGenerationError, type CodexSettings } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Path from "effect/Path"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import { ChildProcessSpawner } from "effect/process"; |
| 8 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| 9 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 10 | import { chatGptModels } from "../CodexChatGptModels.ts"; |
| 11 | import { makeCodexManagedRuntime } from "../CodexManagedRuntime.ts"; |
| 12 | import { ProviderDriverError } from "../Errors.ts"; |
| 13 | import { createCodexAdapterV2 } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 14 | import { |
| 15 | checkCodexProviderStatus, |
| 16 | makePendingCodexProvider, |
| 17 | probeCodexSkillsForCwd, |
| 18 | } from "../CodexProvider.ts"; |
| 19 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; |
| 20 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; |
| 21 | import { |
| 22 | type ProviderDriverCreateInput, |
| 23 | type ProviderInstance, |
| 24 | } from "@t3tools/provider-core/server/driver"; |
| 25 | import { codexContinuationIdentity } from "./CodexHomeLayout.ts"; |
| 26 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; |
| 27 | import { HttpClient } from "effect/http"; |
| 28 | const DRIVER = ProviderDriverKind.make("codex"); |
| 29 | |
| 30 | export 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 | }); |