698 lines · 22.5 KB · TypeScript
RawDownload
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<Record<string, CodexRateLimitSnapshot>>        | 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<ServerProviderModel>;  readonly skills: ReadonlyArray<ServerProviderSkill>;} const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {  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<ServerProviderModel> {  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<ServerProviderModel>,): ReadonlyArray<ServerProviderModel> {  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<ServerProviderModel>,  customModels: ReadonlyArray<CustomModelSetting>,): ReadonlyArray<ServerProviderModel> {  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<ServerProviderSkill> {  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<ServerProviderSkill> = {      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<CustomModelSetting>;  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<CodexRateLimitsProbe>({ 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<ServerProviderDraft> =>  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: [],

Lines 1–500 of 698.