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/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 75e5a46258..5a11af1c5f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -4,12 +4,14 @@ import type { ModelCapabilities, ServerProvider, ServerProviderModel, - ServerProviderAuthStatus, + ServerProviderAuth, 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, @@ -117,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(); @@ -129,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.", }; @@ -144,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.", }; } @@ -165,37 +167,286 @@ 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.", }; } +// ── 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; +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 => + 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)), + ), + ); + }), + ); +} + +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. + */ +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)); +} + +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. */ +const PREMIUM_SUBSCRIPTION_TYPES = new Set([ + "max", + "maxplan", + "max5", + "max20", + "enterprise", + "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!); + } +} + +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. + * + * - 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.value === "1m" + ? { value: opt.value, label: opt.label, isDefault: true as const } + : { value: opt.value, label: opt.label }, + ), + }, + }; + }); +} + +// ── 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) => { + const abort = new AbortController(); + return Effect.tryPromise(async () => { + 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(() => { + if (!abort.signal.aborted) abort.abort(); + }), + ), + 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 +459,182 @@ 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", + auth: { status: "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", + 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)}.`, + }, + }); + } - 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", + auth: { status: "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", + auth: { status: "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) - const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + 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) { + 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", + auth: { status: "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", + auth: { status: "unknown" }, + message: "Could not verify Claude authentication status. Timed out while running command.", + }, + }); + } + + const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value); + const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod }); + return buildServerProvider({ + provider: PROVIDER, + enabled: claudeSettings.enabled, + checkedAt, + models: resolvedModels, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + auth: { + ...parsed.auth, + ...(authMetadata ? authMetadata : {}), + }, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); +}); export const ClaudeProviderLive = Layer.effect( ClaudeProvider, @@ -368,7 +642,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), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3335a59dab..e2f31c13f1 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -5,10 +5,21 @@ import type { CodexSettings, ServerProvider, ServerProviderModel, - ServerProviderAuthStatus, + 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"; @@ -170,7 +188,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 +200,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 +214,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 +235,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.", @@ -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((signal) => probeCodexAccount({ ...input, signal })).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", - authStatus: "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", - authStatus: "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", - authStatus: "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", - authStatus: "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", - authStatus: "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", - authStatus: "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", - authStatus: "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", - authStatus: "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, - authStatus: parsed.authStatus, - ...(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 3657abeb6d..116c008d67 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -173,7 +173,133 @@ 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) => { + 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 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, "ChatGPT Pro Subscription"); + 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, "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, + ); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -235,7 +361,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 +375,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 +390,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 +413,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 +439,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 +464,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 +492,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 +502,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 +620,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 +663,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 +684,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 +694,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 +704,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 +841,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) => { @@ -733,13 +859,63 @@ 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, "Claude Max Subscription"); + }).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 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(); 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 +947,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 +975,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 +995,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 +1020,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 +1030,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 +1040,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 +1050,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/codexAccount.ts b/apps/server/src/provider/codexAccount.ts new file mode 100644 index 0000000000..1db00250f6 --- /dev/null +++ b/apps/server/src/provider/codexAccount.ts @@ -0,0 +1,123 @@ +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_ENABLED_PLAN_TYPES = new Set(["pro"]); + +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: false, + }; + } + + if (accountType === "chatgpt") { + const planType = (account?.planType as CodexPlanType | null) ?? "unknown"; + return { + type: "chatgpt", + planType, + sparkEnabled: CODEX_SPARK_ENABLED_PLAN_TYPES.has(planType), + }; + } + + return { + type: "unknown", + planType: null, + 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 : "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 "ChatGPT Free Subscription"; + case "go": + return "ChatGPT Go Subscription"; + case "plus": + return "ChatGPT Plus Subscription"; + case "pro": + return "ChatGPT Pro Subscription"; + case "team": + return "ChatGPT Team Subscription"; + case "business": + return "ChatGPT Business Subscription"; + case "enterprise": + return "ChatGPT Enterprise Subscription"; + case "edu": + return "ChatGPT Edu Subscription"; + 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..d25fc3533e --- /dev/null +++ b/apps/server/src/provider/codexAppServer.ts @@ -0,0 +1,154 @@ +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; + readonly signal?: AbortSignal; +}): 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)}.`), + ), + ); + + 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.")); + 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/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 be9d5f9ac7..64c3922b57 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..13fe6faba2 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: [ { @@ -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/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..b7fde0c5f6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -146,13 +146,14 @@ function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "CLI not detected on PATH.", }; } - if (provider.authStatus === "authenticated") { + if (provider.auth.status === "authenticated") { + const authLabel = provider.auth.label ?? provider.auth.type; return { - headline: "Authenticated", + headline: authLabel ? `Authenticated · ${authLabel}` : "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..08910c3ecc 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -35,6 +35,13 @@ export const ServerProviderAuthStatus = Schema.Literals([ ]); 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; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -49,7 +56,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),