import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Types from "effect/Types"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as CodexClient from "effect-codex-app-server/client"; import * as CodexSchema from "effect-codex-app-server/schema"; import * as CodexErrors from "effect-codex-app-server/errors"; import type { CodexSettings, CustomModelSetting, ServerProvider, ServerProviderState, ModelCapabilities, ProviderOptionDescriptor, ServerProviderModel, ServerProviderSkill, } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; import { createModelCapabilities, readCustomModelEntries } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; import { AUTH_PROBE_TIMEOUT_MS, buildServerProvider, COMPACT_SLASH_COMMAND, type ServerProviderDraft, } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; import { codexRateLimitsFailureMessage, codexRateLimitsToLimits, type CodexRateLimitSnapshot, type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; type CodexRateLimitsProbe = | { readonly snapshot: CodexRateLimitSnapshot; readonly rateLimitsByLimitId?: | Readonly> | null | undefined; readonly resetCredits: CodexResetCreditsSummary | null | undefined; } | { readonly failure: string }; const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, reportsContextWindow: true, } as const; export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; } const REASONING_EFFORT_LABELS: Readonly> = { none: "None", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra High", max: "Max", ultra: "Ultra", }; const DEFAULT_SERVICE_TIER_ID = "default"; function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } function codexAccountAuthLabel(account: CodexSchema.V2GetAccountResponse["account"]) { if (!account) return undefined; if (account.type === "apiKey") return "OpenAI API Key"; if (account.type === "amazonBedrock") return "Amazon Bedrock"; if (account.type !== "chatgpt") return undefined; return codexPlanLabel(account.planType); } /** Shared with usage-limit sources, which report the same `planType` slugs. */ export function codexPlanLabel(planType: string | null | undefined): string | undefined { switch (planType) { case "free": return "ChatGPT Free Subscription"; case "go": return "ChatGPT Go Subscription"; case "plus": return "ChatGPT Plus Subscription"; case "pro": return "ChatGPT Pro 20x Subscription"; case "prolite": return "ChatGPT Pro 5x Subscription"; case "team": return "ChatGPT Team Subscription"; case "self_serve_business_prolite": case "self_serve_business_usage_based": case "business": return "ChatGPT Business Subscription"; case "ent26": case "enterprise_cbp_automation": case "enterprise_cbp_usage_based": case "enterprise": return "ChatGPT Enterprise Subscription"; case "edu": case "edu_plus": case "edu_pro": return "ChatGPT Edu Subscription"; case "unknown": return "ChatGPT Subscription"; default: return undefined; } } function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { if (!account || account.type !== "chatgpt") return undefined; return account.email; } export function mapCodexModelCapabilities( model: CodexSchema.V2ModelListResponse__Model, ): ModelCapabilities { const reasoningOptions = model.supportedReasoningEfforts.map(({ reasoningEffort }) => reasoningEffort === model.defaultReasoningEffort ? { id: reasoningEffort, label: reasoningEffortLabel(reasoningEffort), isDefault: true, } : { id: reasoningEffort, label: reasoningEffortLabel(reasoningEffort), }, ); const defaultReasoning = reasoningOptions.find((option) => option.isDefault)?.id; const serviceTiers = model.serviceTiers && model.serviceTiers.length > 0 ? model.serviceTiers : (model.additionalSpeedTiers ?? []).map((id) => ({ id, name: id === "fast" ? "Fast" : id, description: "", })); const catalogDefaultServiceTier = serviceTiers.some( (tier) => tier.id === model.defaultServiceTier, ) ? model.defaultServiceTier : null; const defaultServiceTier = catalogDefaultServiceTier ?? DEFAULT_SERVICE_TIER_ID; const optionDescriptors: ProviderOptionDescriptor[] = []; if (reasoningOptions.length > 0) { optionDescriptors.push({ id: "reasoningEffort", label: "Reasoning", type: "select", options: reasoningOptions, ...(defaultReasoning ? { currentValue: defaultReasoning } : {}), }); } if (serviceTiers.length > 0) { optionDescriptors.push({ id: "serviceTier", label: "Service Tier", type: "select", options: [ { id: DEFAULT_SERVICE_TIER_ID, label: "Standard", ...(defaultServiceTier === DEFAULT_SERVICE_TIER_ID ? { isDefault: true } : {}), }, ...serviceTiers.map((tier) => ({ id: tier.id, label: tier.name, ...(tier.description ? { description: tier.description } : {}), ...(defaultServiceTier === tier.id ? { isDefault: true } : {}), })), ], currentValue: defaultServiceTier, }); } return createModelCapabilities({ optionDescriptors, }); } const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => { // Capitalize 'gpt' to 'GPT-' and capitalize any letter following a dash return model.displayName .replace(/^gpt/i, "GPT") // Handle start with 'gpt' or 'GPT' .replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase()); }; function parseCodexModelListResponse( response: CodexSchema.V2ModelListResponse, ): ReadonlyArray { return response.data.map((model) => ({ slug: model.model, name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } /** * Prefer our own default-model ranking when one of the preferred slugs is in * the live catalog; otherwise keep whatever Codex itself flagged as default. */ export function applyPreferredCodexDefaultModel( models: ReadonlyArray, ): ReadonlyArray { const preferredSlug = PREFERRED_DEFAULT_CODEX_MODELS.find((slug) => models.some((model) => model.slug === slug && !model.isCustom), ); if (!preferredSlug) { return models; } return models.map((model) => { if (model.slug === preferredSlug) { return model.isDefault ? model : { ...model, isDefault: true }; } if (!model.isDefault) { return model; } const { isDefault: _isDefault, ...rest } = model; return rest; }); } /** * Codex has no static default capability set, so a bare custom slug borrows * the first built-in's descriptors; an entry with its own capabilities keeps * them. */ function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, ): ReadonlyArray { if (customModels.length === 0) { return models; } const seen = new Set(models.map((model) => model.slug)); const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; const customEntries: ServerProviderModel[] = []; for (const entry of readCustomModelEntries(customModels)) { if (seen.has(entry.slug)) { continue; } seen.add(entry.slug); customEntries.push({ slug: entry.slug, name: entry.name, isCustom: true, capabilities: entry.capabilities ?? fallbackCapabilities, }); } return customEntries.length === 0 ? models : [...models, ...customEntries]; } function parseCodexSkillsListResponse( response: CodexSchema.V2SkillsListResponse, cwd: string, ): ReadonlyArray { const matchingEntry = response.data.find((entry) => entry.cwd === cwd); const skills = matchingEntry ? matchingEntry.skills : response.data.flatMap((entry) => entry.skills); return skills.map((skill) => { const shortDescription = skill.shortDescription ?? skill.interface?.shortDescription ?? undefined; const parsedSkill: Types.Mutable = { name: skill.name, path: skill.path, enabled: skill.enabled, }; if (skill.description) { parsedSkill.description = skill.description; } if (skill.scope) { parsedSkill.scope = skill.scope; } if (skill.interface?.displayName) { parsedSkill.displayName = skill.interface.displayName; } if (shortDescription) { parsedSkill.shortDescription = shortDescription; } return parsedSkill; }); } const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( client: CodexClient.CodexAppServerClient["Service"], ) { const models: ServerProviderModel[] = []; let cursor: string | null | undefined = undefined; do { const response: CodexSchema.V2ModelListResponse = yield* client.request( "model/list", cursor ? { cursor } : {}, ); models.push(...parseCodexModelListResponse(response)); cursor = response.nextCursor; } while (cursor); return models; }); export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { name: "t3code_desktop", title: "T3 Code Desktop", version: packageJson.version, }, capabilities: { experimentalApi: true, }, }; } /** * Spawns a short-lived `codex app-server`, runs the initialize handshake, and * hands the caller a connected client. Scoped: the process is killed when * the caller's scope closes. Shared by the status probe, the skills probe, * and account-level requests such as reset-credit redemption. */ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { readonly binaryPath: string; readonly homePath?: string | undefined; readonly launchArgs?: string | undefined; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv | undefined; }) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". // Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`. const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const environment = { ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; const spawnCommand = yield* resolveSpawnCommand( input.binaryPath, codexAppServerArgs(input.launchArgs), { env: environment, extendEnv: true }, ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { cwd: input.cwd, env: environment, extendEnv: true, forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, shell: spawnCommand.shell, }), ) .pipe( Effect.mapError( (cause) => new CodexErrors.CodexAppServerSpawnError({ command: `${input.binaryPath} app-server`, cause, }), ), ); const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), ); const initialize = yield* client.request("initialize", buildCodexInitializeParams()); yield* client.notify("initialized", undefined); return { client, initialize }; }); const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) { const { client, initialize } = yield* withCodexAppServerClient(input); // Extract the version string after the first '/' in userAgent, up to the next space or the end const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); const version = versionMatch ? versionMatch[1] : undefined; const accountResponse = yield* client.request("account/read", {}); if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, version, models: appendCustomCodexModels([], input.customModels ?? []), skills: [], } satisfies CodexAppServerProviderSnapshot; } const [skillsResponse, models, rateLimits] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], }), requestAllCodexModels(client), // Usage is an enrichment: a failure or a slow answer degrades to "no // usage this probe" rather than costing the account and models. client.request("account/rateLimits/read", undefined).pipe( Effect.map((response): CodexRateLimitsProbe => ({ snapshot: response.rateLimits, rateLimitsByLimitId: response.rateLimitsByLimitId, resetCredits: response.rateLimitResetCredits, })), Effect.timeoutOption(Duration.millis(RATE_LIMITS_PROBE_TIMEOUT_MS)), Effect.map( Option.getOrElse((): CodexRateLimitsProbe => ({ failure: "Codex did not answer the usage request.", })), ), Effect.catch((error) => Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( Effect.as({ failure: codexRateLimitsFailureMessage(error) }), ), ), ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, rateLimits, version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), ), skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), } satisfies CodexAppServerProviderSnapshot; }); export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: { readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; readonly environment?: NodeJS.ProcessEnv; }) { const { client } = yield* withCodexAppServerClient(input); const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] }); return parseCodexSkillsListResponse(skillsResponse, input.cwd); }); const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => appendCustomCodexModels([], codexSettings.customModels); const makePendingCodexProvider = ( codexSettings: CodexSettings, ): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); const models = emptyCodexModelsFromSettings(codexSettings); if (!codexSettings.enabled) { return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models, skills: [], probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Codex is disabled in T3 Code settings.", }, }); } return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: true, checkedAt, models, skills: [], probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Codex provider status has not been checked in this session yet.", }, }); }); function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): { readonly status: Exclude; readonly auth: ServerProvider["auth"]; readonly message?: string; } { const authLabel = codexAccountAuthLabel(account.account); const authEmail = codexAccountEmail(account.account); const auth = { status: account.account ? ("authenticated" as const) : ("unknown" as const), ...(account.account?.type ? { type: account.account?.type } : {}), ...(authLabel ? { label: authLabel } : {}), ...(authEmail ? { email: authEmail } : {}), } satisfies ServerProvider["auth"]; if (account.account) { return { status: "ready", auth }; } if (account.requiresOpenaiAuth) { return { status: "error", auth: { status: "unauthenticated" }, message: "Codex CLI is not authenticated. Run `codex login` and try again.", }; } return { status: "ready", auth }; } export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( codexSettings: CodexSettings, probe: (input: { readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect< CodexAppServerProviderSnapshot, CodexErrors.CodexAppServerError, ChildProcessSpawner.ChildProcessSpawner | Scope.Scope > = probeCodexAppServerProvider, environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, ServerSettingsError, ChildProcessSpawner.ChildProcessSpawner > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const emptyModels = emptyCodexModelsFromSettings(codexSettings); if (!codexSettings.enabled) { return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: false, checkedAt, models: emptyModels, skills: [], probe: { installed: false, version: null, status: "warning", auth: { status: "unknown" }, message: "Codex is disabled in T3 Code settings.", }, }); } const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), cwd: process.cwd(), customModels: codexSettings.customModels, environment: resolvedEnvironment, }).pipe( Effect.scoped, Effect.timeoutOption(Duration.millis(AUTH_PROBE_TIMEOUT_MS)), Effect.result, ); if (Result.isFailure(probeResult)) { const error = probeResult.failure; const installed = !isCodexAppServerSpawnError(error); return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: emptyModels, skills: [], probe: { installed, version: null, status: "error", auth: { status: "unknown" }, message: installed ? `Codex app-server provider probe failed: ${error.message}.` : "Codex CLI (`codex`) was not found on PATH.", }, }); } if (Option.isNone(probeResult.success)) { return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: emptyModels, skills: [], probe: { installed: true, version: null, status: "error", auth: { status: "unknown" }, message: "Timed out while checking Codex app-server provider status.", }, }); } const snapshot = probeResult.success.value; const accountStatus = accountProbeStatus(snapshot.account); const usageLimits = snapshot.account.account?.type === "apiKey" ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits ? makeUnavailableUsageLimits({ checkedAt, reason: "probeFailed", ...(snapshot.rateLimits ? { message: snapshot.rateLimits.failure } : {}), }) : codexRateLimitsToLimits({ snapshot: snapshot.rateLimits.snapshot, rateLimitsByLimitId: snapshot.rateLimits.rateLimitsByLimitId, resetCredits: snapshot.rateLimits.resetCredits, checkedAt, }); return buildServerProvider({ presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, models: snapshot.models, skills: snapshot.skills, slashCommands: [ COMPACT_SLASH_COMMAND, { name: "feedback", description: "Send this thread and Codex logs to OpenAI", input: { hint: "Describe the issue (optional)" }, }, ], probe: { installed: true, version: snapshot.version ?? null, status: accountStatus.status, auth: accountStatus.auth, ...(accountStatus.message ? { message: accountStatus.message } : {}), usageLimits, }, }); }); // NOTE: the singleton `CodexProviderLive` Layer has been removed as part of // the per-instance-driver refactor. `CodexDriver.create()` builds a managed // snapshot per instance (each with its own `CodexSettings`) and hands the // resulting `ServerProviderShape` back as `ProviderInstance.snapshot`. // // The `makePendingCodexProvider` and `checkCodexProviderStatus` helpers are // re-exported for use by `CodexDriver`. export { makePendingCodexProvider };