| 1 | import { SERVER_VERSION } from "../version.ts"; |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; |
| 9 | import * as Result from "effect/Result"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as Scope from "effect/Scope"; |
| 13 | import * as Types from "effect/Types"; |
| 14 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 15 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 16 | import * as CodexClient from "effect-codex-app-server/client"; |
| 17 | import * as CodexSchema from "effect-codex-app-server/schema"; |
| 18 | import * as CodexErrors from "effect-codex-app-server/errors"; |
| 19 | |
| 20 | import type { |
| 21 | CodexSettings, |
| 22 | CustomModelSetting, |
| 23 | ServerProvider, |
| 24 | ServerProviderState, |
| 25 | ModelCapabilities, |
| 26 | ProviderOptionDescriptor, |
| 27 | ServerProviderModel, |
| 28 | ServerProviderSkill, |
| 29 | } from "@t3tools/contracts"; |
| 30 | import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; |
| 31 | |
| 32 | import { |
| 33 | codexModelFamily, |
| 34 | createModelCapabilities, |
| 35 | formatCodexModelName, |
| 36 | } from "@t3tools/shared/model"; |
| 37 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 38 | import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; |
| 39 | import { |
| 40 | AUTH_PROBE_TIMEOUT_MS, |
| 41 | buildServerProvider, |
| 42 | COMPACT_SLASH_COMMAND, |
| 43 | providerModelsFromSettings, |
| 44 | type ServerProviderDraft, |
| 45 | } from "@t3tools/provider-core/server/snapshotProbe"; |
| 46 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 47 | import { makeUnavailableUsageLimits } from "@t3tools/provider-core/server/usageLimits"; |
| 48 | import { |
| 49 | codexRateLimitsFailureMessage, |
| 50 | codexRateLimitsToLimits, |
| 51 | type CodexRateLimitSnapshot, |
| 52 | type CodexResetCreditsSummary, |
| 53 | } from "./codexUsageLimits.ts"; |
| 54 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 55 | const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); |
| 56 | const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; |
| 57 | |
| 58 | type 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 | |
| 71 | const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; |
| 72 | const CODEX_CATALOG_REFRESH_TIMEOUT = "3 seconds" as const; |
| 73 | const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const; |
| 74 | const CODEX_CLIENT_NAME = "T3 Code"; |
| 75 | |
| 76 | const CODEX_PRESENTATION = { |
| 77 | displayName: "Codex", |
| 78 | showInteractionModeToggle: true, |
| 79 | reportsContextWindow: true, |
| 80 | } as const; |
| 81 | |
| 82 | export 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 | |
| 90 | const 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 | |
| 101 | const DEFAULT_SERVICE_TIER_ID = "default"; |
| 102 | |
| 103 | /** Shorter copy for tiers whose catalog description wraps in the traits menu. */ |
| 104 | const SERVICE_TIER_DESCRIPTIONS: Readonly<Record<string, string>> = { |
| 105 | ultrafast: "Even faster, more expensive", |
| 106 | }; |
| 107 | |
| 108 | function reasoningEffortLabel(reasoningEffort: string): string { |
| 109 | return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; |
| 110 | } |
| 111 | |
| 112 | function 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. */ |
| 121 | export 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 | |
| 157 | function codexAccountEmail(account: CodexSchema.V2GetAccountResponse["account"]) { |
| 158 | if (!account || account.type !== "chatgpt") return undefined; |
| 159 | return account.email; |
| 160 | } |
| 161 | |
| 162 | export 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 | |
| 234 | function 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 | */ |
| 250 | export 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 | */ |
| 276 | function 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 | |
| 284 | function 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 | |
| 320 | const 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 | |
| 338 | const 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. */ |
| 343 | const 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 | */ |
| 369 | const 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 | */ |
| 401 | export const codexAppServerEnvironment = ( |
| 402 | environment: NodeJS.ProcessEnv | undefined, |
| 403 | ): NodeJS.ProcessEnv => ({ |
| 404 | CODEX_INTERNAL_ORIGINATOR_OVERRIDE: CODEX_CLIENT_NAME, |
| 405 | ...environment, |
| 406 | }); |
| 407 | |
| 408 | export 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 | */ |
| 427 | export 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 | |
| 479 | const 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 | |
| 551 | export 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 | |
| 563 | const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => |
| 564 | appendCustomCodexModels([], codexSettings.customModels); |
| 565 | |
| 566 | const 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 | |
| 606 | function 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 | |
| 640 | export 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`. |
| 795 | export { makePendingCodexProvider }; |