1import { SERVER_VERSION } from "../version.ts";
2import * as DateTime from "effect/DateTime";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Result from "effect/Result";
10import * as Schema from "effect/Schema";
11import * as Schedule from "effect/Schedule";
12import * as Scope from "effect/Scope";
13import * as Types from "effect/Types";
14import * as ChildProcess from "effect/process/ChildProcess";
15import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
16import * as CodexClient from "effect-codex-app-server/client";
17import * as CodexSchema from "effect-codex-app-server/schema";
18import * as CodexErrors from "effect-codex-app-server/errors";
19
20import type {
21 CodexSettings,
22 CustomModelSetting,
23 ServerProvider,
24 ServerProviderState,
25 ModelCapabilities,
26 ProviderOptionDescriptor,
27 ServerProviderModel,
28 ServerProviderSkill,
29} from "@t3tools/contracts";
30import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
31
32import {
33 codexModelFamily,
34 createModelCapabilities,
35 formatCodexModelName,
36} from "@t3tools/shared/model";
37import { resolveSpawnCommand } from "@t3tools/shared/shell";
38import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
39import {
40 AUTH_PROBE_TIMEOUT_MS,
41 buildServerProvider,
42 COMPACT_SLASH_COMMAND,
43 providerModelsFromSettings,
44 type ServerProviderDraft,
45} from "@t3tools/provider-core/server/snapshotProbe";
46import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
47import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits";
48import {
49 codexRateLimitsFailureMessage,
50 codexRateLimitsToLimits,
51 type CodexRateLimitSnapshot,
52 type CodexResetCreditsSummary,
53} from "./codexUsageLimits.ts";
54import * as HostProcess from "@t3tools/shared/HostProcess";
55const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
56const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;
57
58type CodexRateLimitsProbe =
59 | {
60 readonly snapshot: CodexRateLimitSnapshot;
61 readonly rateLimitsByLimitId?:
62 | Readonly<Record<string, CodexRateLimitSnapshot>>
63 | null
64 | undefined;
65 readonly resetCredits: CodexResetCreditsSummary | null | undefined;
66 /** The ChatGPT workspace the usage belongs to, when the backend names it. */
67 readonly accountId?: string | null | undefined;
68 }
69 | { readonly failure: string };
70
71const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
72const CODEX_CATALOG_REFRESH_TIMEOUT = "3 seconds" as const;
73const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const;
74const CODEX_CLIENT_NAME = "T3 Code";
75
76const CODEX_PRESENTATION = {
77 displayName: "Codex",
78 showInteractionModeToggle: true,
79 reportsContextWindow: true,
80} as const;
81
82export interface CodexAppServerProviderSnapshot {
83 readonly account: CodexSchema.V2GetAccountResponse;
84 readonly rateLimits?: CodexRateLimitsProbe;
85 readonly version: string | undefined;
86 readonly models: ReadonlyArray<ServerProviderModel>;
87 readonly skills: ReadonlyArray<ServerProviderSkill>;
88}
89
90const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
91 none: "None",
92 minimal: "Minimal",
93 low: "Low",
94 medium: "Medium",
95 high: "High",
96 xhigh: "Extra High",
97 max: "Max",
98 ultra: "Ultra",
99};
100
101const DEFAULT_SERVICE_TIER_ID = "default";
102
103/** Shorter copy for tiers whose catalog description wraps in the traits menu. */
104const SERVICE_TIER_DESCRIPTIONS: Readonly<Record<string, string>> = {
105 ultrafast: "Even faster, more expensive",
106};
107
108function reasoningEffortLabel(reasoningEffort: string): string {
109 return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
110}
111
112function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) {
113 if (!account) return undefined;
114 if (account.type === "apiKey") return "OpenAI API Key";
115 if (account.type === "amazonBedrock") return "Amazon Bedrock";
116 if (account.type !== "chatgpt") return undefined;
117 return codexPlanLabel(account.planType);
118}
119
120/** Shared with usage-limit sources, which report the same `planType` slugs. */
121export function codexPlanLabel(planType: string | null | undefined): string | undefined {
122 switch (planType) {
123 case "free":
124 return "ChatGPT Free Subscription";
125 case "go":
126 return "ChatGPT Go Subscription";
127 case "plus":
128 return "ChatGPT Plus Subscription";
129 case "pro":
130 return "ChatGPT Pro 20x Subscription";
131 case "prolite":
132 return "ChatGPT Pro 5x Subscription";
133 case "promax":
134 return "ChatGPT Pro Max Subscription";
135 case "team":
136 return "ChatGPT Team Subscription";
137 case "self_serve_business_prolite":
138 case "self_serve_business_usage_based":
139 case "business":
140 return "ChatGPT Business Subscription";
141 case "ent26":
142 case "enterprise_cbp_automation":
143 case "enterprise_cbp_usage_based":
144 case "enterprise":
145 return "ChatGPT Enterprise Subscription";
146 case "edu":
147 case "edu_plus":
148 case "edu_pro":
149 return "ChatGPT Edu Subscription";
150 case "unknown":
151 return "ChatGPT Subscription";
152 default:
153 return undefined;
154 }
155}
156
157function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) {
158 if (!account || account.type !== "chatgpt") return undefined;
159 return account.email;
160}
161
162export function mapCodexModelCapabilities(
163 model: CodexSchema.V2ModelListResponse__Model,
164): ModelCapabilities {
165 const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) =>
166 reasoningEffort ===
167 (codexModelFamily(model.model) === "gpt-6-astra" ? "medium" : model.defaultReasoningEffort)
168 ? {
169 id: reasoningEffort,
170 label: reasoningEffortLabel(reasoningEffort),
171 isDefault: true,
172 }
173 : {
174 id: reasoningEffort,
175 label: reasoningEffortLabel(reasoningEffort),
176 },
177 );
178 const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id;
179 const serviceTiers =
180 model.serviceTiers && model.serviceTiers.length > 0
181 ? model.serviceTiers
182 : (model.additionalSpeedTiers ?? []).map((id) => ({
183 id,
184 name: id === "fast" ? "Fast" : id,
185 description: "",
186 }));
187 const catalogDefaultServiceTier = serviceTiers.some(
188 (tier) => tier.id === model.defaultServiceTier,
189 )
190 ? model.defaultServiceTier
191 : null;
192 const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID;
193 const optionDescriptors: ProviderOptionDescriptor[] = [];
194
195 if (reasoningOptions.length > 0) {
196 optionDescriptors.push({
197 id: "reasoningEffort",
198 label: "Reasoning",
199 type: "select",
200 options: reasoningOptions,
201 ...(defaultReasoning ? { currentValue: defaultReasoning } : {}),
202 });
203 }
204 if (serviceTiers.length > 0) {
205 optionDescriptors.push({
206 id: "serviceTier",
207 label: "Service Tier",
208 type: "select",
209 options: [
210 {
211 id: DEFAULT_SERVICE_TIER_ID,
212 label: "Standard",
213 ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}),
214 },
215 ...serviceTiers.map((tier) => {
216 const description = SERVICE_TIER_DESCRIPTIONS[tier.id] ?? tier.description;
217 return {
218 id: tier.id,
219 label: tier.name,
220 ...(description ? { description } : {}),
221 ...(defaultServiceTier === tier.id ? { isDefault: true } : {}),
222 };
223 }),
224 ],
225 currentValue: defaultServiceTier,
226 });
227 }
228
229 return createModelCapabilities({
230 optionDescriptors,
231 });
232}
233
234function parseCodexModelListResponse(
235 response: CodexSchema.V2ModelListResponse,
236): ReadonlyArray<ServerProviderModel> {
237 return response.data.map((model) => ({
238 slug: model.model,
239 name: formatCodexModelName(model.displayName),
240 isCustom: false,
241 ...(model.isDefault ? { isDefault: true } : {}),
242 capabilities: mapCodexModelCapabilities(model),
243 }));
244}
245
246/**
247 * Prefer our own default-model ranking when one of the preferred slugs is in
248 * the live catalog; otherwise keep whatever Codex itself flagged as default.
249 */
250export function applyPreferredCodexDefaultModel(
251 models: ReadonlyArray<ServerProviderModel>,
252): ReadonlyArray<ServerProviderModel> {
253 const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.flatMap((slug) =>
254 models.filter((model) => !model.isCustom && codexModelFamily(model.slug) === slug),
255 )[0]?.slug;
256 if (!preferredSlug) {
257 return models;
258 }
259 return models.map((model) => {
260 if (model.slug === preferredSlug) {
261 return model.isDefault ? model : { ...model, isDefault: true };
262 }
263 if (!model.isDefault) {
264 return model;
265 }
266 const { isDefault: _isDefault, ...rest } = model;
267 return rest;
268 });
269}
270
271/**
272 * Codex has no static default capability set, so a bare custom slug borrows
273 * the first built-in's descriptors; an entry with its own capabilities keeps
274 * them.
275 */
276function appendCustomCodexModels(
277 models: ReadonlyArray<ServerProviderModel>,
278 customModels: ReadonlyArray<CustomModelSetting>,
279): ReadonlyArray<ServerProviderModel> {
280 const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null;
281 return providerModelsFromSettings(models, customModels, fallbackCapabilities);
282}
283
284function parseCodexSkillsListResponse(
285 response: CodexSchema.V2SkillsListResponse,
286 cwd: string,
287): ReadonlyArray<ServerProviderSkill> {
288 const matchingEntry = response.data.find((entry) => entry.cwd === cwd);
289 const skills = matchingEntry
290 ? matchingEntry.skills
291 : response.data.flatMap((entry) => entry.skills);
292
293 return skills.map((skill) => {
294 const shortDescription =
295 skill.shortDescription ?? skill.interface?.shortDescription ?? undefined;
296
297 const parsedSkill: Types.Mutable<ServerProviderSkill> = {
298 name: skill.name,
299 path: skill.path,
300 enabled: skill.enabled,
301 };
302
303 if (skill.description) {
304 parsedSkill.description = skill.description;
305 }
306 if (skill.scope) {
307 parsedSkill.scope = skill.scope;
308 }
309 if (skill.interface?.displayName) {
310 parsedSkill.displayName = skill.interface.displayName;
311 }
312 if (shortDescription) {
313 parsedSkill.shortDescription = shortDescription;
314 }
315
316 return parsedSkill;
317 });
318}
319
320const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
321 client: CodexClient.CodexAppServerClient["Service"],
322) {
323 const models: ServerProviderModel[] = [];
324 let cursor: string | null | undefined = undefined;
325
326 do {
327 const response: CodexSchema.V2ModelListResponse = yield* client.request(
328 "model/list",
329 cursor ? { cursor } : {},
330 );
331 models.push(...parseCodexModelListResponse(response));
332 cursor = response.nextCursor;
333 } while (cursor);
334
335 return models;
336});
337
338const decodeCodexModelsCacheStamp = Schema.decodeUnknownOption(
339 Schema.fromJsonString(Schema.Struct({ fetched_at: Schema.String })),
340);
341
342/** When Codex last wrote `models_cache.json`, in epoch millis; NaN when unreadable. */
343const readCodexModelsCacheFetchedAt = Effect.fn("readCodexModelsCacheFetchedAt")(function* (
344 codexHome: string,
345) {
346 const fileSystem = yield* FileSystem.FileSystem;
347 const path = yield* Path.Path;
348 const text = yield* fileSystem
349 .readFileString(path.join(codexHome, "models_cache.json"))
350 .pipe(Effect.option);
351 return Option.flatMap(text, decodeCodexModelsCacheStamp).pipe(
352 Option.flatMap((stamp) => DateTime.make(stamp.fetched_at)),
353 Option.match({ onNone: () => Number.NaN, onSome: DateTime.toEpochMillis }),
354 );
355});
356
357/**
358 * Lists the ChatGPT model catalog as fetched for T3 Code.
359 *
360 * `model/list` serves `CODEX_HOME/models_cache.json` while it is under five
361 * minutes old. Every Codex client on the machine shares that file, and the
362 * backend tailors the catalog to each client's originator: the plain CLI does
363 * not get Sol Ultrafast. So the first answer can be another client's catalog.
364 * App-server refreshes the catalog online at startup, so wait until a write
365 * newer than this process's spawn lands, then list again. If no refresh
366 * lands in time, keep the first answer. Another client writing in the same
367 * few hundred milliseconds can still win; the next probe corrects it.
368 */
369const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(function* (input: {
370 readonly client: CodexClient.CodexAppServerClient["Service"];
371 readonly codexHome: string;
372 readonly spawnedAtMs: number;
373}) {
374 const models = yield* requestAllCodexModels(input.client);
375 const refreshed = yield* readCodexModelsCacheFetchedAt(input.codexHome).pipe(
376 Effect.repeat({
377 schedule: Schedule.spaced(CODEX_CATALOG_REFRESH_POLL),
378 until: (fetchedAtMs) => fetchedAtMs >= input.spawnedAtMs,
379 }),
380 Effect.timeoutOption(CODEX_CATALOG_REFRESH_TIMEOUT),
381 );
382 if (Option.isNone(refreshed)) {
383 yield* Effect.logWarning(
384 "Codex did not refresh its model catalog in time; using the cached catalog.",
385 );
386 return models;
387 }
388 return yield* requestAllCodexModels(input.client);
389});
390
391/**
392 * Environment for every `codex app-server` T3 Code spawns.
393 *
394 * Codex sends its client name as the `originator` header, and the backend
395 * tailors the model catalog to it. App-server starts its catalog refresh
396 * before `initialize` names the client, so that first fetch goes out as the
397 * plain CLI, misses tiers like Sol Ultrafast, and overwrites the shared
398 * `models_cache.json`. Naming the originator at spawn closes that race.
399 * A value the user already set wins.
400 */
401export const codexAppServerEnvironment = (
402 environment: NodeJS.ProcessEnv | undefined,
403): NodeJS.ProcessEnv => ({
404 CODEX_INTERNAL_ORIGINATOR_OVERRIDE: CODEX_CLIENT_NAME,
405 ...environment,
406});
407
408export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
409 return {
410 clientInfo: {
411 name: CODEX_CLIENT_NAME,
412 title: CODEX_CLIENT_NAME,
413 version: SERVER_VERSION,
414 },
415 capabilities: {
416 experimentalApi: true,
417 },
418 };
419}
420
421/**
422 * Spawns a short-lived `codex app-server`, runs the initialize handshake, and
423 * hands the caller a connected client. Scoped: the process is killed when
424 * the caller's scope closes. Shared by the status probe, the skills probe,
425 * and account-level requests such as reset-credit redemption.
426 */
427export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: {
428 readonly binaryPath: string;
429 readonly homePath?: string | undefined;
430 readonly launchArgs?: string | undefined;
431 readonly cwd: string;
432 readonly environment?: NodeJS.ProcessEnv | undefined;
433}) {
434 // `~` is not shell-expanded when env vars are set via `child_process.spawn`,
435 // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
436 // "CODEX_HOME points to '~/.codex_work', but that path does not exist".
437 // Expand here for parity with `CodexTextGeneration`.
438 const resolvedHomePath = input.homePath
439 ? expandHomePath(input.homePath, yield* HostProcess.HomeDirectory)
440 : undefined;
441 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
442 const environment = {
443 ...codexAppServerEnvironment(input.environment),
444 ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}),
445 };
446 const spawnCommand = yield* resolveSpawnCommand(
447 input.binaryPath,
448 codexAppServerArgs(input.launchArgs),
449 { env: environment, extendEnv: true },
450 );
451 const child = yield* spawner
452 .spawn(
453 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
454 cwd: input.cwd,
455 env: environment,
456 extendEnv: true,
457 forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER,
458 shell: spawnCommand.shell,
459 }),
460 )
461 .pipe(
462 Effect.mapError(
463 (cause) =>
464 new CodexErrors.CodexAppServerSpawnError({
465 command: `${input.binaryPath} app-server`,
466 cause,
467 }),
468 ),
469 );
470 const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child));
471 const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
472 Effect.provide(clientContext),
473 );
474 const initialize = yield* client.request("initialize", buildCodexInitializeParams());
475 yield* client.notify("initialized", undefined);
476 return { client, initialize };
477});
478
479const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
480 readonly binaryPath: string;
481 readonly homePath?: string;
482 readonly launchArgs?: string;
483 readonly cwd: string;
484 readonly customModels?: ReadonlyArray<CustomModelSetting>;
485 readonly environment?: NodeJS.ProcessEnv;
486 readonly skipNativeUsage?: boolean;
487}) {
488 const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
489 const { client, initialize } = yield* withCodexAppServerClient(input);
490
491 // Extract the version string after the first '/' in userAgent, up to the next space or the end
492 const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
493 const version = versionMatch ? versionMatch[1] : undefined;
494
495 const accountResponse = yield* client.request("account/read", {});
496 if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
497 return {
498 account: accountResponse,
499 version,
500 models: appendCustomCodexModels([], input.customModels ?? []),
501 skills: [],
502 } satisfies CodexAppServerProviderSnapshot;
503 }
504
505 const [skillsResponse, models, rateLimits] = yield* Effect.all(
506 [
507 client.request("skills/list", {
508 cwds: [input.cwd],
509 }),
510 accountResponse.account?.type === "chatgpt"
511 ? requestCodexChatGptModels({ client, codexHome: initialize.codexHome, spawnedAtMs })
512 : requestAllCodexModels(client),
513 // Usage is an enrichment: a failure or a slow answer degrades to "no
514 // usage this probe" rather than costing the account and models.
515 input.skipNativeUsage
516 ? Effect.succeed(undefined)
517 : client.request("account/rateLimits/read", null).pipe(
518 Effect.map((response): CodexRateLimitsProbe => ({
519 snapshot: response.rateLimits,
520 rateLimitsByLimitId: response.rateLimitsByLimitId,
521 resetCredits: response.rateLimitResetCredits,
522 accountId: response.accountId,
523 })),
524 Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)),
525 Effect.map(
526 Option.getOrElse((): CodexRateLimitsProbe => ({
527 failure: "Codex did not answer the usage request.",
528 })),
529 ),
530 Effect.catch((error) =>
531 Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe(
532 Effect.as<CodexRateLimitsProbe>({ failure: codexRateLimitsFailureMessage(error) }),
533 ),
534 ),
535 ),
536 ],
537 { concurrency: "unbounded" },
538 );
539
540 return {
541 account: accountResponse,
542 ...(rateLimits ? { rateLimits } : {}),
543 version,
544 models: applyPreferredCodexDefaultModel(
545 appendCustomCodexModels(models, input.customModels ?? []),
546 ),
547 skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
548 } satisfies CodexAppServerProviderSnapshot;
549});
550
551export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: {
552 readonly binaryPath: string;
553 readonly homePath?: string;
554 readonly launchArgs?: string;
555 readonly cwd: string;
556 readonly environment?: NodeJS.ProcessEnv;
557}) {
558 const { client } = yield* withCodexAppServerClient(input);
559 const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
560 return parseCodexSkillsListResponse(skillsResponse, input.cwd);
561});
562
563const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
564 appendCustomCodexModels([], codexSettings.customModels);
565
566const makePendingCodexProvider = (
567 codexSettings: CodexSettings,
568): Effect.Effect<ServerProviderDraft> =>
569 Effect.gen(function* () {
570 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
571 const models = emptyCodexModelsFromSettings(codexSettings);
572
573 if (!codexSettings.enabled) {
574 return buildServerProvider({
575 presentation: CODEX_PRESENTATION,
576 enabled: false,
577 checkedAt,
578 models,
579 skills: [],
580 probe: {
581 installed: false,
582 version: null,
583 status: "warning",
584 auth: { status: "unknown" },
585 message: "Codex is disabled in T3 Code settings.",
586 },
587 });
588 }
589
590 return buildServerProvider({
591 presentation: CODEX_PRESENTATION,
592 enabled: true,
593 checkedAt,
594 models,
595 skills: [],
596 probe: {
597 installed: false,
598 version: null,
599 status: "warning",
600 auth: { status: "unknown" },
601 message: "Codex provider status has not been checked in this session yet.",
602 },
603 });
604 });
605
606function accountProbeStatus({ account, rateLimits }: CodexAppServerProviderSnapshot): {
607 readonly status: Exclude<ServerProviderState, "disabled">;
608 readonly auth: ServerProvider["auth"];
609 readonly message?: string;
610} {
611 const authLabel = codexAccountAuthLabel(account.account);
612 const authEmail = codexAccountEmail(account.account);
613 // One email can hold several workspaces, each with its own quota; the usage
614 // read names the one this login draws on.
615 const workspaceId =
616 rateLimits && "accountId" in rateLimits ? rateLimits.accountId?.trim() : undefined;
617 const auth = {
618 status: account.account ? ("authenticated" as const) : ("unknown" as const),
619 ...(account.account?.type ? { type: account.account?.type } : {}),
620 ...(authLabel ? { label: authLabel } : {}),
621 ...(authEmail ? { email: authEmail } : {}),
622 ...(workspaceId ? { workspaceId } : {}),
623 } satisfies ServerProvider["auth"];
624
625 if (account.account) {
626 return { status: "ready", auth };
627 }
628
629 if (account.requiresOpenaiAuth) {
630 return {
631 status: "error",
632 auth: { status: "unauthenticated" },
633 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
634 };
635 }
636
637 return { status: "ready", auth };
638}
639
640export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
641 codexSettings: CodexSettings,
642 probe: (input: {
643 readonly binaryPath: string;
644 readonly homePath?: string;
645 readonly launchArgs?: string;
646 readonly cwd: string;
647 readonly customModels: ReadonlyArray<CustomModelSetting>;
648 readonly environment?: NodeJS.ProcessEnv;
649 readonly skipNativeUsage?: boolean;
650 }) => Effect.Effect<
651 CodexAppServerProviderSnapshot,
652 CodexErrors.CodexAppServerError,
653 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Scope.Scope
654 > = probeCodexAppServerProvider,
655 environment?: NodeJS.ProcessEnv,
656 managedAuth?: ServerProvider["auth"],
657): Effect.fn.Return<
658 ServerProviderDraft,
659 ServerSettingsError,
660 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
661> {
662 const resolvedEnvironment = environment ?? process.env;
663 const checkedAt = DateTime.formatIso(yield* DateTime.now);
664 const emptyModels = emptyCodexModelsFromSettings(codexSettings);
665
666 if (!codexSettings.enabled) {
667 return buildServerProvider({
668 presentation: CODEX_PRESENTATION,
669 enabled: false,
670 checkedAt,
671 models: emptyModels,
672 skills: [],
673 probe: {
674 installed: false,
675 version: null,
676 status: "warning",
677 auth: { status: "unknown" },
678 message: "Codex is disabled in T3 Code settings.",
679 },
680 });
681 }
682
683 const probeResult = yield* probe({
684 binaryPath: codexSettings.binaryPath,
685 homePath: codexSettings.homePath,
686 launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment),
687 cwd: process.cwd(),
688 customModels: codexSettings.customModels,
689 environment: resolvedEnvironment,
690 ...(managedAuth ? { skipNativeUsage: true } : {}),
691 }).pipe(
692 Effect.scoped,
693 Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)),
694 Effect.result,
695 );
696
697 if (Result.isFailure(probeResult)) {
698 const error = probeResult.failure;
699 const installed = !isCodexAppServerSpawnError(error);
700 return buildServerProvider({
701 presentation: CODEX_PRESENTATION,
702 enabled: codexSettings.enabled,
703 checkedAt,
704 models: emptyModels,
705 skills: [],
706 probe: {
707 installed,
708 version: null,
709 status: "error",
710 auth: { status: "unknown" },
711 message: installed
712 ? `Codex app-server provider probe failed: ${error.message}.`
713 : `Could not start Codex CLI (\`${codexSettings.binaryPath}\`). Check Settings → Providers → Codex → Binary path on the server.` +
714 (codexSettings.binaryPath === "codex"
715 ? " Installing ChatGPT or Codex desktop may not add codex to PATH."
716 : " Make sure the configured executable exists and can be run."),
717 },
718 });
719 }
720
721 if (Option.isNone(probeResult.success)) {
722 return buildServerProvider({
723 presentation: CODEX_PRESENTATION,
724 enabled: codexSettings.enabled,
725 checkedAt,
726 models: emptyModels,
727 skills: [],
728 probe: {
729 installed: true,
730 version: null,
731 status: "error",
732 auth: { status: "unknown" },
733 message: "Timed out while checking Codex app-server provider status.",
734 },
735 });
736 }
737
738 const snapshot = probeResult.success.value;
739 const accountStatus = managedAuth
740 ? { status: "ready" as const, auth: managedAuth, message: undefined }
741 : accountProbeStatus(snapshot);
742 const usageLimits =
743 snapshot.account.account?.type === "apiKey"
744 ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" })
745 : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits
746 ? makeUnavailableUsageLimits({
747 checkedAt,
748 reason: "probeFailed",
749 ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}),
750 })
751 : codexRateLimitsToLimits({
752 snapshot: snapshot.rateLimits.snapshot,
753 rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId,
754 resetCredits: snapshot.rateLimits.resetCredits,
755 checkedAt,
756 });
757
758 return buildServerProvider({
759 presentation: CODEX_PRESENTATION,
760 enabled: codexSettings.enabled,
761 checkedAt,
762 models: snapshot.models,
763 skills: snapshot.skills,
764 slashCommands: [
765 COMPACT_SLASH_COMMAND,
766 {
767 name: "goal",
768 description: "Set a goal Codex keeps working toward until it is done",
769 input: { hint: "Objective, or pause, resume, clear" },
770 },
771 {
772 name: "feedback",
773 description: "Send this thread and Codex logs to OpenAI",
774 input: { hint: "Describe the issue (optional)" },
775 },
776 ],
777 probe: {
778 installed: true,
779 version: snapshot.version ?? null,
780 status: accountStatus.status,
781 auth: accountStatus.auth,
782 ...(accountStatus.message ? { message: accountStatus.message } : {}),
783 ...(managedAuth ? {} : { usageLimits }),
784 },
785 });
786});
787
788// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of
789// the per-instance-driver refactor. `CodexDriver.create()` builds a managed
790// snapshot per instance (each with its own `CodexSettings`) and hands the
791// resulting `ManagedServerProvider` back as `ProviderInstance.snapshot`.
792//
793// The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are
794// re-exported for use by `CodexDriver`.
795export { makePendingCodexProvider };