From 2ccce39d9613f2eedfb24631eccd39055d4e7aa3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 12:44:14 -0700 Subject: [PATCH 01/12] Detect Claude subscription and adjust model defaults - Probe Claude account type when auth status lacks it - Prefer 1M context for premium tiers and keep 200k default otherwise --- .../src/provider/Layers/ClaudeProvider.ts | 466 ++++++++++++------ 1 file changed, 325 insertions(+), 141 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 75e5a46258..f30f15dbc5 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -7,9 +7,11 @@ import type { ServerProviderAuthStatus, ServerProviderState, } from "@t3tools/contracts"; -import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; +import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk"; import { buildServerProvider, @@ -43,8 +45,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ supportsFastMode: true, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, @@ -63,8 +65,8 @@ const BUILT_IN_MODELS: ReadonlyArray = [ supportsFastMode: false, supportsThinkingToggle: false, contextWindowOptions: [ - { value: "200k", label: "200k" }, - { value: "1m", label: "1M", isDefault: true }, + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, ], promptInjectedEffortLevels: ["ultrathink"], } satisfies ModelCapabilities, @@ -196,6 +198,162 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { }; } +// ── Subscription type detection ───────────────────────────────────── +// +// The SDK probe returns typed `AccountInfo.subscriptionType` directly. +// This walker is a best-effort fallback for the `claude auth status` +// JSON output whose shape is not guaranteed. + +/** Keys that directly hold a subscription/plan identifier. */ +const SUBSCRIPTION_TYPE_KEYS = [ + "subscriptionType", + "subscription_type", + "plan", + "tier", + "planType", + "plan_type", +] as const; + +/** Keys whose value may be a nested object containing subscription info. */ +const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const; + +/** Lift an unknown value into `Option` if it is a non-empty string. */ +const asNonEmptyString = (v: unknown): Option.Option => + typeof v === "string" && v.length > 0 ? Option.some(v) : Option.none(); + +/** Lift an unknown value into `Option` if it is a plain object. */ +const asRecord = (v: unknown): Option.Option> => + typeof v === "object" && v !== null && !globalThis.Array.isArray(v) + ? Option.some(v as Record) + : Option.none(); + +/** + * Walk an unknown parsed JSON value looking for a subscription/plan + * identifier, returning the first match as an `Option`. + */ +function findSubscriptionType(value: unknown): Option.Option { + if (globalThis.Array.isArray(value)) { + return Option.firstSomeOf(value.map(findSubscriptionType)); + } + + return asRecord(value).pipe( + Option.flatMap((record) => { + const direct = Option.firstSomeOf( + SUBSCRIPTION_TYPE_KEYS.map((key) => asNonEmptyString(record[key])), + ); + if (Option.isSome(direct)) return direct; + + return Option.firstSomeOf( + SUBSCRIPTION_CONTAINER_KEYS.map((key) => + asRecord(record[key]).pipe(Option.flatMap(findSubscriptionType)), + ), + ); + }), + ); +} + +/** + * Try to extract a subscription type from the `claude auth status` JSON + * output. This is a zero-cost operation on data we already have. + */ +const decodeUnknownJson = decodeJsonResult(Schema.Unknown); + +function extractSubscriptionTypeFromOutput(result: CommandResult): string | undefined { + const parsed = decodeUnknownJson(result.stdout.trim()); + if (Result.isFailure(parsed)) return undefined; + return Option.getOrUndefined(findSubscriptionType(parsed.success)); +} + +// ── Dynamic model capability adjustment ───────────────────────────── + +/** Subscription types where the 1M context window is included in the plan. */ +const PREMIUM_SUBSCRIPTION_TYPES = new Set([ + "max", + "max_plan", + "max5", + "max20", + "enterprise", + "team", +]); + +/** + * Adjust the built-in model list based on the user's detected subscription. + * + * - Premium tiers (Max, Enterprise, Team): 1M context becomes the default. + * - Other tiers (Pro, free, unknown): 200k context stays the default; + * 1M remains available as a manual option so users can still enable it. + */ +export function adjustModelsForSubscription( + baseModels: ReadonlyArray, + subscriptionType: string | undefined, +): ReadonlyArray { + const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); + if (!normalized || !PREMIUM_SUBSCRIPTION_TYPES.has(normalized)) { + return baseModels; + } + + // Flip 1M to be the default for premium users + return baseModels.map((model) => { + const caps = model.capabilities; + if (!caps || caps.contextWindowOptions.length === 0) return model; + + return { + ...model, + capabilities: { + ...caps, + contextWindowOptions: caps.contextWindowOptions.map((opt) => ({ + ...opt, + isDefault: opt.value === "1m" ? true : undefined, + })), + }, + }; + }); +} + +// ── SDK capability probe ──────────────────────────────────────────── + +const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; + +/** + * Probe account information by spawning a lightweight Claude Agent SDK + * session and reading the initialization result. + * + * The prompt is never sent to the Anthropic API — we abort immediately + * after the local initialization phase completes. This gives us the + * user's subscription type without incurring any token cost. + * + * This is used as a fallback when `claude auth status` does not include + * subscription type information. + */ +const probeClaudeCapabilities = (binaryPath: string) => + Effect.tryPromise(async () => { + const abort = new AbortController(); + try { + const q = claudeQuery({ + prompt: ".", + options: { + pathToClaudeCodeExecutable: binaryPath, + abortController: abort, + maxTurns: 0, + settingSources: [], + allowedTools: [], + stderr: () => {}, + }, + }); + const init = await q.initializationResult(); + return { subscriptionType: init.account?.subscriptionType }; + } finally { + if (!abort.signal.aborted) abort.abort(); + } + }).pipe( + Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.result, + Effect.map((result) => { + if (Result.isFailure(result)) return undefined; + return Option.isSome(result.success) ? result.success.value : undefined; + }), + ); + const runClaudeCommand = (args: ReadonlyArray) => Effect.gen(function* () { const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( @@ -208,159 +366,176 @@ const runClaudeCommand = (args: ReadonlyArray) => return yield* spawnAndCollect(claudeSettings.binaryPath, command); }); -export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( - function* (): Effect.fn.Return< - ServerProvider, - ServerSettingsError, - ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService - > { - const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.claudeAgent), - ); - const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings( - BUILT_IN_MODELS, - PROVIDER, - claudeSettings.customModels, - ); +export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* ( + resolveSubscriptionType?: (binaryPath: string) => Effect.Effect, +): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService +> { + const claudeSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.claudeAgent), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels); - if (!claudeSettings.enabled) { - return buildServerProvider({ - provider: PROVIDER, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - authStatus: "unknown", - message: "Claude is disabled in T3 Code settings.", - }, - }); - } + if (!claudeSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + authStatus: "unknown", + message: "Claude is disabled in T3 Code settings.", + }, + }); + } - const versionProbe = yield* runClaudeCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const versionProbe = yield* runClaudeCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - authStatus: "unknown", - message: isCommandMissingCause(error) - ? "Claude Agent CLI (`claude`) is not installed or not on PATH." - : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }, - }); - } + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + authStatus: "unknown", + message: isCommandMissingCause(error) + ? "Claude Agent CLI (`claude`) is not installed or not on PATH." + : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } - if (Option.isNone(versionProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "error", - authStatus: "unknown", - message: - "Claude Agent CLI is installed but failed to run. Timed out while running command.", - }, - }); - } + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + authStatus: "unknown", + message: + "Claude Agent CLI is installed but failed to run. Timed out while running command.", + }, + }); + } - const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { - const detail = detailFromResult(version); - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "error", - authStatus: "unknown", - message: detail - ? `Claude Agent CLI is installed but failed to run. ${detail}` - : "Claude Agent CLI is installed but failed to run.", - }, - }); - } + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + authStatus: "unknown", + message: detail + ? `Claude Agent CLI is installed but failed to run. ${detail}` + : "Claude Agent CLI is installed but failed to run.", + }, + }); + } - const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + // ── Auth check + subscription detection ──────────────────────────── - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - authStatus: "unknown", - message: - error instanceof Error - ? `Could not verify Claude authentication status: ${error.message}.` - : "Could not verify Claude authentication status.", - }, - }); - } + const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - if (Option.isNone(authProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: claudeSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - authStatus: "unknown", - message: - "Could not verify Claude authentication status. Timed out while running command.", - }, - }); - } + // Determine subscription type from multiple sources (cheapest first): + // 1. `claude auth status` JSON output (may or may not contain it) + // 2. Cached SDK probe (spawns a Claude process on miss, reads + // `initializationResult()` for account metadata, then aborts + // immediately — no API tokens are consumed) + + let subscriptionType: string | undefined; + + if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { + subscriptionType = extractSubscriptionTypeFromOutput(authProbe.success.value); + } - const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + if (!subscriptionType && resolveSubscriptionType) { + subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath); + } + + const resolvedModels = adjustModelsForSubscription(models, subscriptionType); + + // ── Handle auth results (same logic as before, adjusted models) ── + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; return buildServerProvider({ provider: PROVIDER, enabled: claudeSettings.enabled, checkedAt, - models, + models: resolvedModels, probe: { installed: true, version: parsedVersion, - status: parsed.status, - authStatus: parsed.authStatus, - ...(parsed.message ? { message: parsed.message } : {}), + status: "warning", + authStatus: "unknown", + message: + error instanceof Error + ? `Could not verify Claude authentication status: ${error.message}.` + : "Could not verify Claude authentication status.", }, }); - }, -); + } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models: resolvedModels, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: "Could not verify Claude authentication status. Timed out while running command.", + }, + }); + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models: resolvedModels, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + authStatus: parsed.authStatus, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); +}); export const ClaudeProviderLive = Layer.effect( ClaudeProvider, @@ -368,7 +543,16 @@ export const ClaudeProviderLive = Layer.effect( const serverSettings = yield* ServerSettingsService; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const checkProvider = checkClaudeProviderStatus().pipe( + const subscriptionProbeCache = yield* Cache.make({ + capacity: 1, + timeToLive: Duration.minutes(5), + lookup: (binaryPath: string) => + probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)), + }); + + const checkProvider = checkClaudeProviderStatus((binaryPath) => + Cache.get(subscriptionProbeCache, binaryPath), + ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); From fb69915178b7fd62964b61024c562cae6043762e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 29 Mar 2026 19:53:38 +0000 Subject: [PATCH 02/12] fix: use normalized form 'maxplan' in PREMIUM_SUBSCRIPTION_TYPES set The normalization regex strips underscores (/[\s_-]+/g), so 'max_plan' normalizes to 'maxplan' which never matched the set entry 'max_plan'. Changed the set entry to 'maxplan' to match the post-normalization form. --- apps/server/src/provider/Layers/ClaudeProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f30f15dbc5..3765d8fdbd 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -269,7 +269,7 @@ function extractSubscriptionTypeFromOutput(result: CommandResult): string | unde /** Subscription types where the 1M context window is included in the plan. */ const PREMIUM_SUBSCRIPTION_TYPES = new Set([ "max", - "max_plan", + "maxplan", "max5", "max20", "enterprise", From d9bf9598cd97cccfb78ca56d3c7cca830904755a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 12:57:44 -0700 Subject: [PATCH 03/12] Preserve Claude model option metadata - Keep context window options intact when adjusting subscription models - Mark the 1m window as default without rebuilding option objects --- apps/server/src/provider/Layers/ClaudeProvider.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f30f15dbc5..a9a0f79a58 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -301,10 +301,9 @@ export function adjustModelsForSubscription( ...model, capabilities: { ...caps, - contextWindowOptions: caps.contextWindowOptions.map((opt) => ({ - ...opt, - isDefault: opt.value === "1m" ? true : undefined, - })), + contextWindowOptions: caps.contextWindowOptions.map((opt) => + Object.assign({}, opt, { isDefault: opt.value === "1m" ? true : undefined }), + ), }, }; }); From 151d9aaf65ee8828d93dd7f90672a6934748e255 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 14:09:17 -0700 Subject: [PATCH 04/12] Refactor provider auth state to structured object - Replace flat `authStatus` with structured `auth` across server and web - Surface Claude subscription type in provider summaries - Update provider status tests and shared contracts --- .../src/provider/Layers/ClaudeProvider.ts | 32 ++++++------- .../src/provider/Layers/CodexProvider.ts | 36 +++++++-------- .../provider/Layers/ProviderRegistry.test.ts | 46 +++++++++---------- apps/server/src/provider/providerSnapshot.ts | 6 +-- apps/server/src/wsServer.test.ts | 2 +- apps/web/src/components/ChatView.browser.tsx | 2 +- .../components/KeybindingsToast.browser.tsx | 2 +- .../chat/ProviderModelPicker.browser.tsx | 4 +- .../components/chat/TraitsPicker.browser.tsx | 4 +- .../components/settings/SettingsPanels.tsx | 6 +-- apps/web/src/wsNativeApi.test.ts | 2 +- packages/contracts/src/server.ts | 8 +++- 12 files changed, 78 insertions(+), 72 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index a9a0f79a58..ab447fb487 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,7 +4,7 @@ import type { ModelCapabilities, ServerProvider, ServerProviderModel, - ServerProviderAuthStatus, + ServerProviderAuth, ServerProviderState, } from "@t3tools/contracts"; import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect"; @@ -119,7 +119,7 @@ export function normalizeClaudeModelOptions( export function parseClaudeAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; - readonly authStatus: ServerProviderAuthStatus; + readonly auth: Pick; readonly message?: string; } { const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); @@ -131,7 +131,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { ) { return { status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Claude Agent authentication status command is unavailable in this version of Claude.", }; @@ -146,7 +146,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { ) { return { status: "error", - authStatus: "unauthenticated", + auth: { status: "unauthenticated" }, message: "Claude is not authenticated. Run `claude auth login` and try again.", }; } @@ -167,31 +167,31 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): { })(); if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; + return { status: "ready", auth: { status: "authenticated" } }; } if (parsedAuth.auth === false) { return { status: "error", - authStatus: "unauthenticated", + auth: { status: "unauthenticated" }, message: "Claude is not authenticated. Run `claude auth login` and try again.", }; } if (parsedAuth.attemptedJsonParse) { return { status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Could not verify Claude authentication status from JSON output (missing auth marker).", }; } if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; + return { status: "ready", auth: { status: "authenticated" } }; } const detail = detailFromResult(result); return { status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: detail ? `Could not verify Claude authentication status. ${detail}` : "Could not verify Claude authentication status.", @@ -389,7 +389,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: false, version: null, status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Claude is disabled in T3 Code settings.", }, }); @@ -411,7 +411,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: !isCommandMissingCause(error), version: null, status: "error", - authStatus: "unknown", + auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Claude Agent CLI (`claude`) is not installed or not on PATH." : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, @@ -429,7 +429,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: true, version: null, status: "error", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Claude Agent CLI is installed but failed to run. Timed out while running command.", }, @@ -449,7 +449,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: true, version: parsedVersion, status: "error", - authStatus: "unknown", + auth: { status: "unknown" }, message: detail ? `Claude Agent CLI is installed but failed to run. ${detail}` : "Claude Agent CLI is installed but failed to run.", @@ -495,7 +495,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: true, version: parsedVersion, status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: error instanceof Error ? `Could not verify Claude authentication status: ${error.message}.` @@ -514,7 +514,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: true, version: parsedVersion, status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Could not verify Claude authentication status. Timed out while running command.", }, }); @@ -530,7 +530,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: true, version: parsedVersion, status: parsed.status, - authStatus: parsed.authStatus, + auth: { ...parsed.auth, ...(subscriptionType ? { type: subscriptionType } : {}) }, ...(parsed.message ? { message: parsed.message } : {}), }, }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3335a59dab..58cb1804b1 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -5,7 +5,7 @@ import type { CodexSettings, ServerProvider, ServerProviderModel, - ServerProviderAuthStatus, + ServerProviderAuth, ServerProviderState, } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; @@ -170,7 +170,7 @@ export function normalizeCodexModelOptions( export function parseAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; - readonly authStatus: ServerProviderAuthStatus; + readonly auth: Pick; readonly message?: string; } { const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); @@ -182,7 +182,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): { ) { return { status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Codex CLI authentication status command is unavailable in this Codex version.", }; } @@ -196,7 +196,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): { ) { return { status: "error", - authStatus: "unauthenticated", + auth: { status: "unauthenticated" }, message: "Codex CLI is not authenticated. Run `codex login` and try again.", }; } @@ -217,31 +217,31 @@ export function parseAuthStatusFromOutput(result: CommandResult): { })(); if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; + return { status: "ready", auth: { status: "authenticated" } }; } if (parsedAuth.auth === false) { return { status: "error", - authStatus: "unauthenticated", + auth: { status: "unauthenticated" }, message: "Codex CLI is not authenticated. Run `codex login` and try again.", }; } if (parsedAuth.attemptedJsonParse) { return { status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Could not verify Codex authentication status from JSON output (missing auth marker).", }; } if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; + return { status: "ready", auth: { status: "authenticated" } }; } const detail = detailFromResult(result); return { status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: detail ? `Could not verify Codex authentication status. ${detail}` : "Could not verify Codex authentication status.", @@ -336,7 +336,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: false, version: null, status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Codex is disabled in T3 Code settings.", }, }); @@ -358,7 +358,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: !isCommandMissingCause(error), version: null, status: "error", - authStatus: "unknown", + auth: { status: "unknown" }, message: isCommandMissingCause(error) ? "Codex CLI (`codex`) is not installed or not on PATH." : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, @@ -376,7 +376,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: true, version: null, status: "error", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Codex CLI is installed but failed to run. Timed out while running command.", }, }); @@ -397,7 +397,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: true, version: parsedVersion, status: "error", - authStatus: "unknown", + auth: { status: "unknown" }, message: detail ? `Codex CLI is installed but failed to run. ${detail}` : "Codex CLI is installed but failed to run.", @@ -415,7 +415,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: true, version: parsedVersion, status: "error", - authStatus: "unknown", + auth: { status: "unknown" }, message: formatCodexCliUpgradeMessage(parsedVersion), }, }); @@ -431,7 +431,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: true, version: parsedVersion, status: "ready", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Using a custom Codex model provider; OpenAI login check skipped.", }, }); @@ -453,7 +453,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: true, version: parsedVersion, status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: error instanceof Error ? `Could not verify Codex authentication status: ${error.message}.` @@ -472,7 +472,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: true, version: parsedVersion, status: "warning", - authStatus: "unknown", + auth: { status: "unknown" }, message: "Could not verify Codex authentication status. Timed out while running command.", }, }); @@ -488,7 +488,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( installed: true, version: parsedVersion, status: parsed.status, - authStatus: parsed.authStatus, + auth: parsed.auth, ...(parsed.message ? { message: parsed.message } : {}), }, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3657abeb6d..ef044b52dd 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -173,7 +173,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.auth.status, "authenticated"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -235,7 +235,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.installed, true); assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.auth.status, "authenticated"); } finally { process.env.PATH = previousPath; } @@ -249,7 +249,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); - assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, "Codex CLI (`codex`) is not installed or not on PATH.", @@ -264,7 +264,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", @@ -287,7 +287,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.auth.status, "unauthenticated"); assert.strictEqual( status.message, "Codex CLI is not authenticated. Run `codex login` and try again.", @@ -313,7 +313,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.auth.status, "unauthenticated"); assert.strictEqual( status.message, "Codex CLI is not authenticated. Run `codex login` and try again.", @@ -338,7 +338,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "warning"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, "Codex CLI authentication status command is unavailable in this Codex version.", @@ -366,7 +366,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( status: "ready", enabled: true, installed: true, - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], @@ -376,7 +376,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( status: "warning", enabled: true, installed: true, - authStatus: "unknown", + auth: { status: "unknown" }, checkedAt: "2026-03-25T00:00:00.000Z", version: "1.0.0", models: [], @@ -494,7 +494,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "codex"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, "Using a custom Codex model provider; OpenAI login check skipped.", @@ -537,7 +537,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( const status = yield* checkCodexProviderStatus(); // The auth probe runs and sees "not logged in" → error assert.strictEqual(status.status, "error"); - assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.auth.status, "unauthenticated"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -558,7 +558,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it("exit code 0 with no auth markers is ready", () => { const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.auth.status, "authenticated"); }); it("JSON with authenticated=false is unauthenticated", () => { @@ -568,7 +568,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( code: 0, }); assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); + assert.strictEqual(parsed.auth.status, "unauthenticated"); }); it("JSON without auth marker is warning", () => { @@ -578,7 +578,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( code: 0, }); assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.authStatus, "unknown"); + assert.strictEqual(parsed.auth.status, "unknown"); }); }); @@ -715,7 +715,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "claudeAgent"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "authenticated"); + assert.strictEqual(status.auth.status, "authenticated"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -739,7 +739,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "claudeAgent"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, false); - assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, "Claude Agent CLI (`claude`) is not installed or not on PATH.", @@ -771,7 +771,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "claudeAgent"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.auth.status, "unauthenticated"); assert.strictEqual( status.message, "Claude is not authenticated. Run `claude auth login` and try again.", @@ -799,7 +799,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "claudeAgent"); assert.strictEqual(status.status, "error"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual(status.auth.status, "unauthenticated"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -819,7 +819,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.provider, "claudeAgent"); assert.strictEqual(status.status, "warning"); assert.strictEqual(status.installed, true); - assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.auth.status, "unknown"); assert.strictEqual( status.message, "Claude Agent authentication status command is unavailable in this version of Claude.", @@ -844,7 +844,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( it("exit code 0 with no auth markers is ready", () => { const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.auth.status, "authenticated"); }); it("JSON with loggedIn=true is authenticated", () => { @@ -854,7 +854,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( code: 0, }); assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); + assert.strictEqual(parsed.auth.status, "authenticated"); }); it("JSON with loggedIn=false is unauthenticated", () => { @@ -864,7 +864,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( code: 0, }); assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); + assert.strictEqual(parsed.auth.status, "unauthenticated"); }); it("JSON without auth marker is warning", () => { @@ -874,7 +874,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( code: 0, }); assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.authStatus, "unknown"); + assert.strictEqual(parsed.auth.status, "unknown"); }); }); }, diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 65f820bd8e..e1243c4bd0 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,6 +1,6 @@ import type { ServerProvider, - ServerProviderAuthStatus, + ServerProviderAuth, ServerProviderModel, ServerProviderState, } from "@t3tools/contracts"; @@ -21,7 +21,7 @@ export interface ProviderProbeResult { readonly installed: boolean; readonly version: string | null; readonly status: Exclude; - readonly authStatus: ServerProviderAuthStatus; + readonly auth: ServerProviderAuth; readonly message?: string; } @@ -137,7 +137,7 @@ export function buildServerProvider(input: { installed: input.probe.installed, version: input.probe.version, status: input.enabled ? input.probe.status : "disabled", - authStatus: input.probe.authStatus, + auth: input.probe.auth, checkedAt: input.checkedAt, ...(input.probe.message ? { message: input.probe.message } : {}), models: input.models, diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 826b9ad6fd..6dcf8b38ed 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -74,7 +74,7 @@ const defaultProviderStatuses: ReadonlyArray = [ installed: true, version: "0.116.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], }, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..61c1ccf676 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -117,7 +117,7 @@ function createBaseServerConfig(): ServerConfig { installed: true, version: "0.116.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], }, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 5d6ec94a50..ba21af4b77 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -50,7 +50,7 @@ function createBaseServerConfig(): ServerConfig { installed: true, version: "0.116.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: NOW_ISO, models: [], }, diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 679cbff321..a14c3d673a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -22,7 +22,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ installed: true, version: "0.116.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), models: [ { @@ -57,7 +57,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ installed: true, version: "1.0.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: new Date().toISOString(), models: [ { diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 9dea3651ea..74c22e6431 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -35,7 +35,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ installed: true, version: "0.1.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [ { @@ -61,7 +61,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ installed: true, version: "0.1.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [ { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index f9fdb1d615..be7b840148 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -146,13 +146,13 @@ function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "CLI not detected on PATH.", }; } - if (provider.authStatus === "authenticated") { + if (provider.auth.status === "authenticated") { return { - headline: "Authenticated", + headline: provider.auth.type ? `Authenticated · ${provider.auth.type}` : "Authenticated", detail: provider.message ?? null, }; } - if (provider.authStatus === "unauthenticated") { + if (provider.auth.status === "unauthenticated") { return { headline: "Not authenticated", detail: provider.message ?? null, diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 9e612543d0..be53eefd9b 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -99,7 +99,7 @@ const defaultProviders: ReadonlyArray = [ installed: true, version: "0.116.0", status: "ready", - authStatus: "authenticated", + auth: { status: "authenticated" }, checkedAt: "2026-01-01T00:00:00.000Z", models: [], }, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 78d0879cd2..93553dc665 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -35,6 +35,12 @@ export const ServerProviderAuthStatus = Schema.Literals([ ]); export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; +export const ServerProviderAuth = Schema.Struct({ + status: ServerProviderAuthStatus, + type: Schema.optional(TrimmedNonEmptyString), +}); +export type ServerProviderAuth = typeof ServerProviderAuth.Type; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -49,7 +55,7 @@ export const ServerProvider = Schema.Struct({ installed: Schema.Boolean, version: Schema.NullOr(TrimmedNonEmptyString), status: ServerProviderState, - authStatus: ServerProviderAuthStatus, + auth: ServerProviderAuth, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), models: Schema.Array(ServerProviderModel), From fc70bf6959f09b5ff75e1f9ff2759aa7de6ab5df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 29 Mar 2026 23:27:20 +0000 Subject: [PATCH 05/12] fix: ensure Claude child process is aborted on timeout by moving AbortController outside Promise Move the AbortController creation outside the Effect.tryPromise async function and use Effect.ensuring to guarantee abort() is called when the Effect completes or is interrupted (e.g. by timeoutOption). This prevents leaked child processes when initializationResult() hangs indefinitely. Applied via @cursor push command --- .../src/provider/Layers/ClaudeProvider.ts | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ab447fb487..519ed96084 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -324,27 +324,28 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; * This is used as a fallback when `claude auth status` does not include * subscription type information. */ -const probeClaudeCapabilities = (binaryPath: string) => - Effect.tryPromise(async () => { - const abort = new AbortController(); - try { - const q = claudeQuery({ - prompt: ".", - options: { - pathToClaudeCodeExecutable: binaryPath, - abortController: abort, - maxTurns: 0, - settingSources: [], - allowedTools: [], - stderr: () => {}, - }, - }); - const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; - } finally { - if (!abort.signal.aborted) abort.abort(); - } +const probeClaudeCapabilities = (binaryPath: string) => { + const abort = new AbortController(); + return Effect.tryPromise(async () => { + const q = claudeQuery({ + prompt: ".", + options: { + pathToClaudeCodeExecutable: binaryPath, + abortController: abort, + maxTurns: 0, + settingSources: [], + allowedTools: [], + stderr: () => {}, + }, + }); + const init = await q.initializationResult(); + return { subscriptionType: init.account?.subscriptionType }; }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (!abort.signal.aborted) abort.abort(); + }), + ), Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -352,6 +353,7 @@ const probeClaudeCapabilities = (binaryPath: string) => return Option.isSome(result.success) ? result.success.value : undefined; }), ); +}; const runClaudeCommand = (args: ReadonlyArray) => Effect.gen(function* () { From 815ae5a1b01a877ef6d2fee9a8b369e12e68295b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 16:30:34 -0700 Subject: [PATCH 06/12] don't persist session --- .../src/provider/Layers/ClaudeProvider.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e67d5f973b..97cfc54a59 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -327,19 +327,24 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeClaudeCapabilities = (binaryPath: string) => { const abort = new AbortController(); return Effect.tryPromise(async () => { - const q = claudeQuery({ - prompt: ".", - options: { - pathToClaudeCodeExecutable: binaryPath, - abortController: abort, - maxTurns: 0, - settingSources: [], - allowedTools: [], - stderr: () => {}, - }, - }); - const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; + try { + const q = claudeQuery({ + prompt: ".", + options: { + persistSession: false, + pathToClaudeCodeExecutable: binaryPath, + abortController: abort, + maxTurns: 0, + settingSources: [], + allowedTools: [], + stderr: () => {}, + }, + }); + const init = await q.initializationResult(); + return { subscriptionType: init.account?.subscriptionType }; + } finally { + if (!abort.signal.aborted) abort.abort(); + } }).pipe( Effect.ensuring( Effect.sync(() => { From 82afe2ab034fa060e0f1cd687b9e5152b2d85fde Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 16:32:03 -0700 Subject: [PATCH 07/12] bad merge --- .../src/provider/Layers/ClaudeProvider.ts | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 97cfc54a59..ae61cc8319 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -327,24 +327,20 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeClaudeCapabilities = (binaryPath: string) => { const abort = new AbortController(); return Effect.tryPromise(async () => { - try { - const q = claudeQuery({ - prompt: ".", - options: { - persistSession: false, - pathToClaudeCodeExecutable: binaryPath, - abortController: abort, - maxTurns: 0, - settingSources: [], - allowedTools: [], - stderr: () => {}, - }, - }); - const init = await q.initializationResult(); - return { subscriptionType: init.account?.subscriptionType }; - } finally { - if (!abort.signal.aborted) abort.abort(); - } + const q = claudeQuery({ + prompt: ".", + options: { + persistSession: false, + pathToClaudeCodeExecutable: binaryPath, + abortController: abort, + maxTurns: 0, + settingSources: [], + allowedTools: [], + stderr: () => {}, + }, + }); + const init = await q.initializationResult(); + return { subscriptionType: init.account?.subscriptionType }; }).pipe( Effect.ensuring( Effect.sync(() => { From 860d096f15cc69d5ca51aa18a97695397e40cee2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 16:54:37 -0700 Subject: [PATCH 08/12] Show account-aware Codex and Claude auth labels - Probe Codex account data to gate Spark models and surface auth type/label - Add Claude subscription labels for provider status and settings UI - Update provider picker and contracts to carry auth labels --- apps/server/src/codexAppServerManager.ts | 104 +---- .../src/provider/Layers/ClaudeProvider.ts | 38 +- .../src/provider/Layers/CodexProvider.ts | 400 ++++++++++-------- .../provider/Layers/ProviderRegistry.test.ts | 89 ++++ apps/server/src/provider/codexAccount.ts | 116 +++++ apps/server/src/provider/codexAppServer.ts | 147 +++++++ .../chat/ProviderModelPicker.browser.tsx | 100 +++++ .../components/settings/SettingsPanels.tsx | 3 +- packages/contracts/src/server.ts | 1 + 9 files changed, 731 insertions(+), 267 deletions(-) create mode 100644 apps/server/src/provider/codexAccount.ts create mode 100644 apps/server/src/provider/codexAppServer.ts diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 1f0abd6d73..3145038647 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -26,6 +26,15 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "./provider/codexCliVersion"; +import { + readCodexAccountSnapshot, + resolveCodexModelForAccount, + type CodexAccountSnapshot, +} from "./provider/codexAccount"; +import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer"; + +export { buildCodexInitializeParams } from "./provider/codexAppServer"; +export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount"; type PendingRequestKey = string; @@ -96,23 +105,6 @@ interface JsonRpcNotification { params?: unknown; } -type CodexPlanType = - | "free" - | "go" - | "plus" - | "pro" - | "team" - | "business" - | "enterprise" - | "edu" - | "unknown"; - -interface CodexAccountSnapshot { - readonly type: "apiKey" | "chatgpt" | "unknown"; - readonly planType: CodexPlanType | null; - readonly sparkEnabled: boolean; -} - export interface CodexAppServerSendTurnInput { readonly threadId: ThreadId; readonly input?: string; @@ -162,50 +154,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "unknown thread", "does not exist", ]; -const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; -const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; -const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); - -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { - const record = asObject(response); - const account = asObject(record?.account) ?? record; - const accountType = asString(account?.type); - - if (accountType === "apiKey") { - return { - type: "apiKey", - planType: null, - sparkEnabled: true, - }; - } - - if (accountType === "chatgpt") { - const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; - return { - type: "chatgpt", - planType, - sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), - }; - } - - return { - type: "unknown", - planType: null, - sparkEnabled: true, - }; -} - export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -358,32 +306,13 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): { }; } -export function resolveCodexModelForAccount( - model: string | undefined, - account: CodexAccountSnapshot, -): string | undefined { - if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) { - return model; - } - - return CODEX_DEFAULT_MODEL; -} - /** * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe` * wrapper, leaving the actual command running. Use `taskkill /T` to kill the * entire process tree instead. */ function killChildTree(child: ChildProcessWithoutNullStreams): void { - if (process.platform === "win32" && child.pid !== undefined) { - try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); - return; - } catch { - // fallback to direct kill - } - } - child.kill(); + killCodexChildProcess(child); } export function normalizeCodexModelSlug( @@ -402,19 +331,6 @@ export function normalizeCodexModelSlug( return normalized; } -export function buildCodexInitializeParams() { - return { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - } as const; -} - function buildCodexCollaborationMode(input: { readonly interactionMode?: "default" | "plan"; readonly model?: string; diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ae61cc8319..cc403c48db 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -276,6 +276,37 @@ const PREMIUM_SUBSCRIPTION_TYPES = new Set([ "team", ]); +function toTitleCaseWords(value: string): string { + return value + .split(/[\s_-]+/g) + .filter(Boolean) + .map((part) => part[0]!.toUpperCase() + part.slice(1).toLowerCase()) + .join(" "); +} + +function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined { + const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); + if (!normalized) return undefined; + + switch (normalized) { + case "max": + case "maxplan": + case "max5": + case "max20": + return "Max"; + case "enterprise": + return "Enterprise"; + case "team": + return "Team"; + case "pro": + return "Pro"; + case "free": + return "Free"; + default: + return toTitleCaseWords(subscriptionType!); + } +} + /** * Adjust the built-in model list based on the user's detected subscription. * @@ -524,6 +555,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + const subscriptionLabel = claudeSubscriptionLabel(subscriptionType); return buildServerProvider({ provider: PROVIDER, enabled: claudeSettings.enabled, @@ -533,7 +565,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( installed: true, version: parsedVersion, status: parsed.status, - auth: { ...parsed.auth, ...(subscriptionType ? { type: subscriptionType } : {}) }, + auth: { + ...parsed.auth, + ...(subscriptionType ? { type: subscriptionType } : {}), + ...(subscriptionLabel ? { label: subscriptionLabel } : {}), + }, ...(parsed.message ? { message: parsed.message } : {}), }, }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 58cb1804b1..6972704369 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -8,7 +8,18 @@ import type { ServerProviderAuth, ServerProviderState, } from "@t3tools/contracts"; -import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; +import { + Cache, + Duration, + Effect, + Equal, + FileSystem, + Layer, + Option, + Path, + Result, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolveEffort } from "@t3tools/shared/model"; @@ -29,6 +40,13 @@ import { isCodexCliVersionSupported, parseCodexCliVersion, } from "../codexCliVersion"; +import { + adjustCodexModelsForAccount, + codexAuthSubLabel, + codexAuthSubType, + type CodexAccountSnapshot, +} from "../codexAccount"; +import { probeCodexAccount } from "../codexAppServer"; import { CodexProvider } from "../Services/CodexProvider"; import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; @@ -290,6 +308,21 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe( Effect.orElseSucceed(() => false), ); +const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; + +const probeCodexCapabilities = (input: { + readonly binaryPath: string; + readonly homePath?: string; +}) => + Effect.tryPromise(() => probeCodexAccount(input)).pipe( + Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), + Effect.result, + Effect.map((result) => { + if (Result.isFailure(result)) return undefined; + return Option.isSome(result.success) ? result.success.value : undefined; + }), + ); + const runCodexCommand = (args: ReadonlyArray) => Effect.gen(function* () { const settingsService = yield* ServerSettingsService; @@ -306,194 +339,206 @@ const runCodexCommand = (args: ReadonlyArray) => return yield* spawnAndCollect(codexSettings.binaryPath, command); }); -export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")( - function* (): Effect.fn.Return< - ServerProvider, - ServerSettingsError, - | ChildProcessSpawner.ChildProcessSpawner - | FileSystem.FileSystem - | Path.Path - | ServerSettingsService - > { - const codexSettings = yield* Effect.service(ServerSettingsService).pipe( - Effect.flatMap((service) => service.getSettings), - Effect.map((settings) => settings.providers.codex), - ); - const checkedAt = new Date().toISOString(); - const models = providerModelsFromSettings( - BUILT_IN_MODELS, - PROVIDER, - codexSettings.customModels, - ); - - if (!codexSettings.enabled) { - return buildServerProvider({ - provider: PROVIDER, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Codex is disabled in T3 Code settings.", - }, - }); - } +export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( + resolveAccount?: (input: { + readonly binaryPath: string; + readonly homePath?: string; + }) => Effect.Effect, +): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ServerSettingsService +> { + const codexSettings = yield* Effect.service(ServerSettingsService).pipe( + Effect.flatMap((service) => service.getSettings), + Effect.map((settings) => settings.providers.codex), + ); + const checkedAt = new Date().toISOString(); + const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, codexSettings.customModels); - const versionProbe = yield* runCodexCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + if (!codexSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Codex is disabled in T3 Code settings.", + }, + }); + } - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Codex CLI (`codex`) is not installed or not on PATH." - : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }, - }); - } + const versionProbe = yield* runCodexCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); - if (Option.isNone(versionProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: "Codex CLI is installed but failed to run. Timed out while running command.", - }, - }); - } + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Codex CLI (`codex`) is not installed or not on PATH." + : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } - const version = versionProbe.success.value; - const parsedVersion = - parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ?? - parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { - const detail = detailFromResult(version); - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "error", - auth: { status: "unknown" }, - message: detail - ? `Codex CLI is installed but failed to run. ${detail}` - : "Codex CLI is installed but failed to run.", - }, - }); - } + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Codex CLI is installed but failed to run. Timed out while running command.", + }, + }); + } - if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "error", - auth: { status: "unknown" }, - message: formatCodexCliUpgradeMessage(parsedVersion), - }, - }); - } + const version = versionProbe.success.value; + const parsedVersion = + parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ?? + parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: detail + ? `Codex CLI is installed but failed to run. ${detail}` + : "Codex CLI is installed but failed to run.", + }, + }); + } - if (yield* hasCustomModelProvider) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "ready", - auth: { status: "unknown" }, - message: "Using a custom Codex model provider; OpenAI login check skipped.", - }, - }); - } + if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "error", + auth: { status: "unknown" }, + message: formatCodexCliUpgradeMessage(parsedVersion), + }, + }); + } - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + if (yield* hasCustomModelProvider) { + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "ready", + auth: { status: "unknown" }, + message: "Using a custom Codex model provider; OpenAI login check skipped.", + }, + }); + } - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - auth: { status: "unknown" }, - message: - error instanceof Error - ? `Could not verify Codex authentication status: ${error.message}.` - : "Could not verify Codex authentication status.", - }, - }); - } + const authProbe = yield* runCodexCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + const account = resolveAccount + ? yield* resolveAccount({ + binaryPath: codexSettings.binaryPath, + homePath: codexSettings.homePath, + }) + : undefined; + const resolvedModels = adjustCodexModelsForAccount(models, account); - if (Option.isNone(authProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: codexSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - auth: { status: "unknown" }, - message: "Could not verify Codex authentication status. Timed out while running command.", - }, - }); - } + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models: resolvedModels, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + auth: { status: "unknown" }, + message: + error instanceof Error + ? `Could not verify Codex authentication status: ${error.message}.` + : "Could not verify Codex authentication status.", + }, + }); + } - const parsed = parseAuthStatusFromOutput(authProbe.success.value); + if (Option.isNone(authProbe.success)) { return buildServerProvider({ provider: PROVIDER, enabled: codexSettings.enabled, checkedAt, - models, + models: resolvedModels, probe: { installed: true, version: parsedVersion, - status: parsed.status, - auth: parsed.auth, - ...(parsed.message ? { message: parsed.message } : {}), + status: "warning", + auth: { status: "unknown" }, + message: "Could not verify Codex authentication status. Timed out while running command.", }, }); - }, -); + } + + const parsed = parseAuthStatusFromOutput(authProbe.success.value); + const authType = codexAuthSubType(account); + const authLabel = codexAuthSubLabel(account); + return buildServerProvider({ + provider: PROVIDER, + enabled: codexSettings.enabled, + checkedAt, + models: resolvedModels, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + auth: { + ...parsed.auth, + ...(authType ? { type: authType } : {}), + ...(authLabel ? { label: authLabel } : {}), + }, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); +}); export const CodexProviderLive = Layer.effect( CodexProvider, @@ -502,8 +547,21 @@ export const CodexProviderLive = Layer.effect( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const accountProbeCache = yield* Cache.make({ + capacity: 4, + timeToLive: Duration.minutes(5), + lookup: (key: string) => { + const [binaryPath, homePath] = JSON.parse(key) as [string, string | undefined]; + return probeCodexCapabilities({ + binaryPath, + ...(homePath ? { homePath } : {}), + }); + }, + }); - const checkProvider = checkCodexProviderStatus().pipe( + const checkProvider = checkCodexProviderStatus((input) => + Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])), + ).pipe( Effect.provideService(ServerSettingsService, serverSettings), Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ef044b52dd..3ad6798fbb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -186,6 +186,70 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("returns the codex plan type in auth and keeps spark for supported plans", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(() => + Effect.succeed({ + type: "chatgpt" as const, + planType: "pro" as const, + sparkEnabled: true, + }), + ); + + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "pro"); + assert.strictEqual(status.auth.label, "Pro"); + assert.deepStrictEqual( + status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), + true, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides spark from codex models for unsupported chatgpt plans", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(() => + Effect.succeed({ + type: "chatgpt" as const, + planType: "plus" as const, + sparkEnabled: false, + }), + ); + + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "plus"); + assert.strictEqual(status.auth.label, "Plus"); + assert.deepStrictEqual( + status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), + false, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect.skipIf(process.platform === "win32")( "inherits PATH when launching the codex probe with a CODEX_HOME override", () => @@ -733,6 +797,31 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("returns a display label for claude subscription types", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan")); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "maxplan"); + assert.strictEqual(status.auth.label, "Max"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unavailable when claude is missing", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/codexAccount.ts b/apps/server/src/provider/codexAccount.ts new file mode 100644 index 0000000000..8ff4993d25 --- /dev/null +++ b/apps/server/src/provider/codexAccount.ts @@ -0,0 +1,116 @@ +import type { ServerProviderModel } from "@t3tools/contracts"; + +export type CodexPlanType = + | "free" + | "go" + | "plus" + | "pro" + | "team" + | "business" + | "enterprise" + | "edu" + | "unknown"; + +export interface CodexAccountSnapshot { + readonly type: "apiKey" | "chatgpt" | "unknown"; + readonly planType: CodexPlanType | null; + readonly sparkEnabled: boolean; +} + +export const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; +export const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; + +const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot { + const record = asObject(response); + const account = asObject(record?.account) ?? record; + const accountType = asString(account?.type); + + if (accountType === "apiKey") { + return { + type: "apiKey", + planType: null, + sparkEnabled: true, + }; + } + + if (accountType === "chatgpt") { + const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; + return { + type: "chatgpt", + planType, + sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), + }; + } + + return { + type: "unknown", + planType: null, + sparkEnabled: true, + }; +} + +export function codexAuthSubType(account: CodexAccountSnapshot | undefined): string | undefined { + if (account?.type !== "chatgpt") { + return undefined; + } + + return account.planType && account.planType !== "unknown" ? account.planType : undefined; +} + +export function codexAuthSubLabel(account: CodexAccountSnapshot | undefined): string | undefined { + switch (codexAuthSubType(account)) { + case "free": + return "Free"; + case "go": + return "Go"; + case "plus": + return "Plus"; + case "pro": + return "Pro"; + case "team": + return "Team"; + case "business": + return "Business"; + case "enterprise": + return "Enterprise"; + case "edu": + return "Edu"; + default: + return undefined; + } +} + +export function adjustCodexModelsForAccount( + baseModels: ReadonlyArray, + account: CodexAccountSnapshot | undefined, +): ReadonlyArray { + if (account?.sparkEnabled !== false) { + return baseModels; + } + + return baseModels.filter((model) => model.isCustom || model.slug !== CODEX_SPARK_MODEL); +} + +export function resolveCodexModelForAccount( + model: string | undefined, + account: CodexAccountSnapshot, +): string | undefined { + if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) { + return model; + } + + return CODEX_DEFAULT_MODEL; +} diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts new file mode 100644 index 0000000000..549e1320c8 --- /dev/null +++ b/apps/server/src/provider/codexAppServer.ts @@ -0,0 +1,147 @@ +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import readline from "node:readline"; +import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount"; + +interface JsonRpcProbeResponse { + readonly id?: unknown; + readonly result?: unknown; + readonly error?: { + readonly message?: unknown; + }; +} + +function readErrorMessage(response: JsonRpcProbeResponse): string | undefined { + return typeof response.error?.message === "string" ? response.error.message : undefined; +} + +export function buildCodexInitializeParams() { + return { + clientInfo: { + name: "t3code_desktop", + title: "T3 Code Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: true, + }, + } as const; +} + +export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): void { + if (process.platform === "win32" && child.pid !== undefined) { + try { + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + return; + } catch { + // Fall through to direct kill when taskkill is unavailable. + } + } + + child.kill(); +} + +export async function probeCodexAccount(input: { + readonly binaryPath: string; + readonly homePath?: string; +}): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(input.binaryPath, ["app-server"], { + env: { + ...process.env, + ...(input.homePath ? { CODEX_HOME: input.homePath } : {}), + }, + stdio: ["pipe", "pipe", "pipe"], + shell: process.platform === "win32", + }); + const output = readline.createInterface({ input: child.stdout }); + + let completed = false; + + const cleanup = () => { + output.removeAllListeners(); + output.close(); + child.removeAllListeners(); + if (!child.killed) { + killCodexChildProcess(child); + } + }; + + const finish = (callback: () => void) => { + if (completed) return; + completed = true; + cleanup(); + callback(); + }; + + const fail = (error: unknown) => + finish(() => + reject( + error instanceof Error + ? error + : new Error(`Codex account probe failed: ${String(error)}.`), + ), + ); + + const writeMessage = (message: unknown) => { + if (!child.stdin.writable) { + fail(new Error("Cannot write to codex app-server stdin.")); + return; + } + + child.stdin.write(`${JSON.stringify(message)}\n`); + }; + + output.on("line", (line) => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + fail(new Error("Received invalid JSON from codex app-server during account probe.")); + return; + } + + if (!parsed || typeof parsed !== "object") { + return; + } + + const response = parsed as JsonRpcProbeResponse; + if (response.id === 1) { + const errorMessage = readErrorMessage(response); + if (errorMessage) { + fail(new Error(`initialize failed: ${errorMessage}`)); + return; + } + + writeMessage({ method: "initialized" }); + writeMessage({ id: 2, method: "account/read", params: {} }); + return; + } + + if (response.id === 2) { + const errorMessage = readErrorMessage(response); + if (errorMessage) { + fail(new Error(`account/read failed: ${errorMessage}`)); + return; + } + + finish(() => resolve(readCodexAccountSnapshot(response.result))); + } + }); + + child.once("error", fail); + child.once("exit", (code, signal) => { + if (completed) return; + fail( + new Error( + `codex app-server exited before probe completed (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + ), + ); + }); + + writeMessage({ + id: 1, + method: "initialize", + params: buildCodexInitializeParams(), + }); + }); +} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index a14c3d673a..13fe6faba2 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -110,6 +110,19 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ]; +function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { + return { + provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: new Date().toISOString(), + models, + }; +} + async function mountPicker(props: { provider: ProviderKind; model: string; @@ -241,6 +254,93 @@ describe("ProviderModelPicker", () => { } }); + it("only shows codex spark when the server reports it for the account", async () => { + const providersWithoutSpark: ReadonlyArray = [ + buildCodexProvider([ + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + TEST_PROVIDERS[1]!, + ]; + const providersWithSpark: ReadonlyArray = [ + buildCodexProvider([ + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + isCustom: false, + capabilities: { + reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ]), + TEST_PROVIDERS[1]!, + ]; + + const hidden = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + providers: providersWithoutSpark, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitem", { name: "Codex" }).hover(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("GPT-5.3 Codex"); + expect(text).not.toContain("GPT-5.3 Codex Spark"); + }); + } finally { + await hidden.cleanup(); + } + + const visible = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: null, + providers: providersWithSpark, + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitem", { name: "Codex" }).hover(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); + }); + } finally { + await visible.cleanup(); + } + }); + it("dispatches the canonical slug when a model is selected", async () => { const mounted = await mountPicker({ provider: "claudeAgent", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index be7b840148..b7fde0c5f6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -147,8 +147,9 @@ function getProviderSummary(provider: ServerProvider | undefined) { }; } if (provider.auth.status === "authenticated") { + const authLabel = provider.auth.label ?? provider.auth.type; return { - headline: provider.auth.type ? `Authenticated · ${provider.auth.type}` : "Authenticated", + headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", detail: provider.message ?? null, }; } diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 93553dc665..08910c3ecc 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -38,6 +38,7 @@ export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type; export const ServerProviderAuth = Schema.Struct({ status: ServerProviderAuthStatus, type: Schema.optional(TrimmedNonEmptyString), + label: Schema.optional(TrimmedNonEmptyString), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; From c5f448962314f73a7357275d3758ce32e0337ba7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 30 Mar 2026 00:04:45 +0000 Subject: [PATCH 09/12] Fix Codex probe child process leak on timeout and Object.assign isDefault: undefined - Add AbortSignal support to probeCodexAccount and use Effect.ensuring in probeCodexCapabilities to kill the child process when the timeout fires, mirroring the existing pattern in probeClaudeCapabilities. - Replace Object.assign with explicit object construction in adjustModelsForSubscription to avoid setting an explicit isDefault: undefined property on non-default context window options. --- apps/server/src/provider/Layers/ClaudeProvider.ts | 4 +++- apps/server/src/provider/Layers/CodexProvider.ts | 11 +++++++++-- apps/server/src/provider/codexAppServer.ts | 7 +++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index cc403c48db..545f0a0d61 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -333,7 +333,9 @@ export function adjustModelsForSubscription( capabilities: { ...caps, contextWindowOptions: caps.contextWindowOptions.map((opt) => - Object.assign({}, opt, { isDefault: opt.value === "1m" ? true : undefined }), + opt.value === "1m" + ? { value: opt.value, label: opt.label, isDefault: true as const } + : { value: opt.value, label: opt.label }, ), }, }; diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 6972704369..b30d729040 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -313,8 +313,14 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeCodexCapabilities = (input: { readonly binaryPath: string; readonly homePath?: string; -}) => - Effect.tryPromise(() => probeCodexAccount(input)).pipe( +}) => { + const abort = new AbortController(); + return Effect.tryPromise(() => probeCodexAccount({ ...input, signal: abort.signal })).pipe( + Effect.ensuring( + Effect.sync(() => { + if (!abort.signal.aborted) abort.abort(); + }), + ), Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -322,6 +328,7 @@ const probeCodexCapabilities = (input: { return Option.isSome(result.success) ? result.success.value : undefined; }), ); +}; const runCodexCommand = (args: ReadonlyArray) => Effect.gen(function* () { diff --git a/apps/server/src/provider/codexAppServer.ts b/apps/server/src/provider/codexAppServer.ts index 549e1320c8..d25fc3533e 100644 --- a/apps/server/src/provider/codexAppServer.ts +++ b/apps/server/src/provider/codexAppServer.ts @@ -43,6 +43,7 @@ export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): vo export async function probeCodexAccount(input: { readonly binaryPath: string; readonly homePath?: string; + readonly signal?: AbortSignal; }): Promise { return await new Promise((resolve, reject) => { const child = spawn(input.binaryPath, ["app-server"], { @@ -82,6 +83,12 @@ export async function probeCodexAccount(input: { ), ); + if (input.signal?.aborted) { + fail(new Error("Codex account probe aborted.")); + return; + } + input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted."))); + const writeMessage = (message: unknown) => { if (!child.stdin.writable) { fail(new Error("Cannot write to codex app-server stdin.")); From d72719b0da30bb258f712daf88b6b0e48911d6d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 17:26:44 -0700 Subject: [PATCH 10/12] Refine Codex and Claude auth handling - Disable Spark for API key and unknown Codex accounts - Add Codex and Claude auth labels for API key and subscription states - Update provider tests for dynamic model gating --- apps/server/src/codexAppServerManager.test.ts | 27 +++++- .../src/provider/Layers/ClaudeProvider.ts | 65 ++++++++++++- .../provider/Layers/ProviderRegistry.test.ts | 93 ++++++++++++++++++- apps/server/src/provider/codexAccount.ts | 35 ++++--- 4 files changed, 198 insertions(+), 22 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index f614b92302..2f740a7407 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -310,7 +310,7 @@ describe("readCodexAccountSnapshot", () => { }); }); - it("keeps spark enabled for api key accounts", () => { + it("disables spark for api key accounts", () => { expect( readCodexAccountSnapshot({ type: "apiKey", @@ -318,7 +318,20 @@ describe("readCodexAccountSnapshot", () => { ).toEqual({ type: "apiKey", planType: null, - sparkEnabled: true, + sparkEnabled: false, + }); + }); + + it("disables spark for unknown chatgpt plans", () => { + expect( + readCodexAccountSnapshot({ + type: "chatgpt", + email: "unknown@example.com", + }), + ).toEqual({ + type: "chatgpt", + planType: "unknown", + sparkEnabled: false, }); }); }); @@ -343,6 +356,16 @@ describe("resolveCodexModelForAccount", () => { }), ).toBe("gpt-5.3-codex-spark"); }); + + it("falls back from spark to default for api key auth", () => { + expect( + resolveCodexModelForAccount("gpt-5.3-codex-spark", { + type: "apiKey", + planType: null, + sparkEnabled: false, + }), + ).toBe("gpt-5.3-codex"); + }); }); describe("startSession", () => { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index cc403c48db..9e9b7970af 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -216,6 +216,8 @@ const SUBSCRIPTION_TYPE_KEYS = [ /** Keys whose value may be a nested object containing subscription info. */ const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const; +const AUTH_METHOD_KEYS = ["authMethod", "auth_method"] as const; +const AUTH_METHOD_CONTAINER_KEYS = ["auth", "account", "session"] as const; /** Lift an unknown value into `Option` if it is a non-empty string. */ const asNonEmptyString = (v: unknown): Option.Option => @@ -252,6 +254,27 @@ function findSubscriptionType(value: unknown): Option.Option { ); } +function findAuthMethod(value: unknown): Option.Option { + if (globalThis.Array.isArray(value)) { + return Option.firstSomeOf(value.map(findAuthMethod)); + } + + return asRecord(value).pipe( + Option.flatMap((record) => { + const direct = Option.firstSomeOf( + AUTH_METHOD_KEYS.map((key) => asNonEmptyString(record[key])), + ); + if (Option.isSome(direct)) return direct; + + return Option.firstSomeOf( + AUTH_METHOD_CONTAINER_KEYS.map((key) => + asRecord(record[key]).pipe(Option.flatMap(findAuthMethod)), + ), + ); + }), + ); +} + /** * Try to extract a subscription type from the `claude auth status` JSON * output. This is a zero-cost operation on data we already have. @@ -264,6 +287,12 @@ function extractSubscriptionTypeFromOutput(result: CommandResult): string | unde return Option.getOrUndefined(findSubscriptionType(parsed.success)); } +function extractClaudeAuthMethodFromOutput(result: CommandResult): string | undefined { + const parsed = decodeUnknownJson(result.stdout.trim()); + if (Result.isFailure(parsed)) return undefined; + return Option.getOrUndefined(findAuthMethod(parsed.success)); +} + // ── Dynamic model capability adjustment ───────────────────────────── /** Subscription types where the 1M context window is included in the plan. */ @@ -307,6 +336,35 @@ function claudeSubscriptionLabel(subscriptionType: string | undefined): string | } } +function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined { + const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, ""); + if (!normalized) return undefined; + if (normalized === "apikey") return "apiKey"; + return undefined; +} + +function claudeAuthMetadata(input: { + readonly subscriptionType: string | undefined; + readonly authMethod: string | undefined; +}): { readonly type: string; readonly label: string } | undefined { + if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") { + return { + type: "apiKey", + label: "Claude API Key", + }; + } + + if (input.subscriptionType) { + const subscriptionLabel = claudeSubscriptionLabel(input.subscriptionType); + return { + type: input.subscriptionType, + label: `Claude ${subscriptionLabel ?? toTitleCaseWords(input.subscriptionType)} Subscription`, + }; + } + + return undefined; +} + /** * Adjust the built-in model list based on the user's detected subscription. * @@ -505,9 +563,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( // immediately — no API tokens are consumed) let subscriptionType: string | undefined; + let authMethod: string | undefined; if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) { subscriptionType = extractSubscriptionTypeFromOutput(authProbe.success.value); + authMethod = extractClaudeAuthMethodFromOutput(authProbe.success.value); } if (!subscriptionType && resolveSubscriptionType) { @@ -555,7 +615,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); - const subscriptionLabel = claudeSubscriptionLabel(subscriptionType); + const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); return buildServerProvider({ provider: PROVIDER, enabled: claudeSettings.enabled, @@ -567,8 +627,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( status: parsed.status, auth: { ...parsed.auth, - ...(subscriptionType ? { type: subscriptionType } : {}), - ...(subscriptionLabel ? { label: subscriptionLabel } : {}), + ...(authMetadata ? authMetadata : {}), }, ...(parsed.message ? { message: parsed.message } : {}), }, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3ad6798fbb..116c008d67 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -201,7 +201,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "pro"); - assert.strictEqual(status.auth.label, "Pro"); + assert.strictEqual(status.auth.label, "ChatGPT Pro Subscription"); assert.deepStrictEqual( status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), true, @@ -233,7 +233,69 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "plus"); - assert.strictEqual(status.auth.label, "Plus"); + assert.strictEqual(status.auth.label, "ChatGPT Plus Subscription"); + assert.deepStrictEqual( + status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), + false, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides spark from codex models for non-pro chatgpt subscriptions", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(() => + Effect.succeed({ + type: "chatgpt" as const, + planType: "team" as const, + sparkEnabled: false, + }), + ); + + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.auth.type, "team"); + assert.strictEqual(status.auth.label, "ChatGPT Team Subscription"); + assert.deepStrictEqual( + status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), + false, + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns an api key label for codex api key auth", () => + Effect.gen(function* () { + yield* withTempCodexHome(); + const status = yield* checkCodexProviderStatus(() => + Effect.succeed({ + type: "apiKey" as const, + planType: null, + sparkEnabled: false, + }), + ); + + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "OpenAI API Key"); assert.deepStrictEqual( status.models.some((model) => model.slug === "gpt-5.3-codex-spark"), false, @@ -804,7 +866,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( assert.strictEqual(status.status, "ready"); assert.strictEqual(status.auth.status, "authenticated"); assert.strictEqual(status.auth.type, "maxplan"); - assert.strictEqual(status.auth.label, "Max"); + assert.strictEqual(status.auth.label, "Claude Max Subscription"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -822,6 +884,31 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( ), ); + it.effect("returns an api key label for claude api key auth", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus(); + assert.strictEqual(status.provider, "claudeAgent"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "authenticated"); + assert.strictEqual(status.auth.type, "apiKey"); + assert.strictEqual(status.auth.label, "Claude API Key"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"api-key"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns unavailable when claude is missing", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus(); diff --git a/apps/server/src/provider/codexAccount.ts b/apps/server/src/provider/codexAccount.ts index 8ff4993d25..1db00250f6 100644 --- a/apps/server/src/provider/codexAccount.ts +++ b/apps/server/src/provider/codexAccount.ts @@ -19,8 +19,7 @@ export interface CodexAccountSnapshot { export const CODEX_DEFAULT_MODEL = "gpt-5.3-codex"; export const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark"; - -const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set(["free", "go", "plus"]); +const CODEX_SPARK_ENABLED_PLAN_TYPES = new Set(["pro"]); function asObject(value: unknown): Record | undefined { if (!value || typeof value !== "object") { @@ -42,7 +41,7 @@ export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapsho return { type: "apiKey", planType: null, - sparkEnabled: true, + sparkEnabled: false, }; } @@ -51,43 +50,51 @@ export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapsho return { type: "chatgpt", planType, - sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType), + sparkEnabled: CODEX_SPARK_ENABLED_PLAN_TYPES.has(planType), }; } return { type: "unknown", planType: null, - sparkEnabled: true, + sparkEnabled: false, }; } export function codexAuthSubType(account: CodexAccountSnapshot | undefined): string | undefined { + if (account?.type === "apiKey") { + return "apiKey"; + } + if (account?.type !== "chatgpt") { return undefined; } - return account.planType && account.planType !== "unknown" ? account.planType : undefined; + return account.planType && account.planType !== "unknown" ? account.planType : "chatgpt"; } export function codexAuthSubLabel(account: CodexAccountSnapshot | undefined): string | undefined { switch (codexAuthSubType(account)) { + case "apiKey": + return "OpenAI API Key"; + case "chatgpt": + return "ChatGPT Subscription"; case "free": - return "Free"; + return "ChatGPT Free Subscription"; case "go": - return "Go"; + return "ChatGPT Go Subscription"; case "plus": - return "Plus"; + return "ChatGPT Plus Subscription"; case "pro": - return "Pro"; + return "ChatGPT Pro Subscription"; case "team": - return "Team"; + return "ChatGPT Team Subscription"; case "business": - return "Business"; + return "ChatGPT Business Subscription"; case "enterprise": - return "Enterprise"; + return "ChatGPT Enterprise Subscription"; case "edu": - return "Edu"; + return "ChatGPT Edu Subscription"; default: return undefined; } From 15f33fa31da0d2e3c2193f8d97a559205a33387c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 17:33:59 -0700 Subject: [PATCH 11/12] use signal from effect --- apps/server/src/provider/Layers/CodexProvider.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index b30d729040..f60fa5f142 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -313,14 +313,8 @@ const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000; const probeCodexCapabilities = (input: { readonly binaryPath: string; readonly homePath?: string; -}) => { - const abort = new AbortController(); - return Effect.tryPromise(() => probeCodexAccount({ ...input, signal: abort.signal })).pipe( - Effect.ensuring( - Effect.sync(() => { - if (!abort.signal.aborted) abort.abort(); - }), - ), +}) => + Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal: signal })).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => { @@ -328,7 +322,6 @@ const probeCodexCapabilities = (input: { return Option.isSome(result.success) ? result.success.value : undefined; }), ); -}; const runCodexCommand = (args: ReadonlyArray) => Effect.gen(function* () { From 4c5408d5b0c6502ed85b2c21f11c8d414e9a2cc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 29 Mar 2026 17:34:06 -0700 Subject: [PATCH 12/12] kewl --- apps/server/src/provider/Layers/CodexProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index f60fa5f142..e2f31c13f1 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -314,7 +314,7 @@ const probeCodexCapabilities = (input: { readonly binaryPath: string; readonly homePath?: string; }) => - Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal: signal })).pipe( + Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe( Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS), Effect.result, Effect.map((result) => {