authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-29 17:51:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-29 17:51:05-07:00
logbf9c828aef6f5901d6be782364665557c07df92e
tree52133c9a4f97109b611b25a6a9a1c23c00a08a8c
parent7b676b76de536b0a9e60fd8bc6e20404815d6294
signature Signed by PGP key B5690EEEBB952194

Make provider model lists dynamic based on auth context (#1556)

Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

16 files changed, 1305 insertions(+), 464 deletions(-)

apps/server/src/codexAppServerManager.test.ts+25-2
......@@ -310,7 +310,7 @@ describe("readCodexAccountSnapshot", () => {
310310 });
311311 });
312312
313 it("keeps spark enabled for api key accounts", () => {
313 it("disables spark for api key accounts", () => {
314314 expect(
315315 readCodexAccountSnapshot({
316316 type: "apiKey",
......@@ -318,7 +318,20 @@ describe("readCodexAccountSnapshot", () => {
318318 ).toEqual({
319319 type: "apiKey",
320320 planType: null,
321 sparkEnabled: true,
321 sparkEnabled: false,
322 });
323 });
324
325 it("disables spark for unknown chatgpt plans", () => {
326 expect(
327 readCodexAccountSnapshot({
328 type: "chatgpt",
329 email: "unknown@example.com",
330 }),
331 ).toEqual({
332 type: "chatgpt",
333 planType: "unknown",
334 sparkEnabled: false,
322335 });
323336 });
324337});
......@@ -343,6 +356,16 @@ describe("resolveCodexModelForAccount", () => {
343356 }),
344357 ).toBe("gpt-5.3-codex-spark");
345358 });
359
360 it("falls back from spark to default for api key auth", () => {
361 expect(
362 resolveCodexModelForAccount("gpt-5.3-codex-spark", {
363 type: "apiKey",
364 planType: null,
365 sparkEnabled: false,
366 }),
367 ).toBe("gpt-5.3-codex");
368 });
346369});
347370
348371describe("startSession", () => {
apps/server/src/codexAppServerManager.ts+10-94
......@@ -26,6 +26,15 @@ import {
2626 isCodexCliVersionSupported,
2727 parseCodexCliVersion,
2828} from "./provider/codexCliVersion";
29import {
30 readCodexAccountSnapshot,
31 resolveCodexModelForAccount,
32 type CodexAccountSnapshot,
33} from "./provider/codexAccount";
34import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer";
35
36export { buildCodexInitializeParams } from "./provider/codexAppServer";
37export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount";
2938
3039type PendingRequestKey = string;
3140
......@@ -96,23 +105,6 @@ interface JsonRpcNotification {
96105 params?: unknown;
97106}
98107
99type CodexPlanType =
100 | "free"
101 | "go"
102 | "plus"
103 | "pro"
104 | "team"
105 | "business"
106 | "enterprise"
107 | "edu"
108 | "unknown";
109
110interface CodexAccountSnapshot {
111 readonly type: "apiKey" | "chatgpt" | "unknown";
112 readonly planType: CodexPlanType | null;
113 readonly sparkEnabled: boolean;
114}
115
116108export interface CodexAppServerSendTurnInput {
117109 readonly threadId: ThreadId;
118110 readonly input?: string;
......@@ -162,50 +154,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
162154 "unknown thread",
163155 "does not exist",
164156];
165const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
166const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
167const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set<CodexPlanType>(["free", "go", "plus"]);
168
169function asObject(value: unknown): Record<string, unknown> | undefined {
170 if (!value || typeof value !== "object") {
171 return undefined;
172 }
173 return value as Record<string, unknown>;
174}
175
176function asString(value: unknown): string | undefined {
177 return typeof value === "string" ? value : undefined;
178}
179
180export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot {
181 const record = asObject(response);
182 const account = asObject(record?.account) ?? record;
183 const accountType = asString(account?.type);
184
185 if (accountType === "apiKey") {
186 return {
187 type: "apiKey",
188 planType: null,
189 sparkEnabled: true,
190 };
191 }
192
193 if (accountType === "chatgpt") {
194 const planType = (account?.planType as CodexPlanType | null) ?? "unknown";
195 return {
196 type: "chatgpt",
197 planType,
198 sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType),
199 };
200 }
201
202 return {
203 type: "unknown",
204 planType: null,
205 sparkEnabled: true,
206 };
207}
208
209157export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
210158
211159You 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): {
358306 };
359307}
360308
361export function resolveCodexModelForAccount(
362 model: string | undefined,
363 account: CodexAccountSnapshot,
364): string | undefined {
365 if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) {
366 return model;
367 }
368
369 return CODEX_DEFAULT_MODEL;
370}
371
372309/**
373310 * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
374311 * wrapper, leaving the actual command running. Use `taskkill /T` to kill the
375312 * entire process tree instead.
376313 */
377314function killChildTree(child: ChildProcessWithoutNullStreams): void {
378 if (process.platform === "win32" && child.pid !== undefined) {
379 try {
380 spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
381 return;
382 } catch {
383 // fallback to direct kill
384 }
385 }
386 child.kill();
315 killCodexChildProcess(child);
387316}
388317
389318export function normalizeCodexModelSlug(
......@@ -402,19 +331,6 @@ export function normalizeCodexModelSlug(
402331 return normalized;
403332}
404333
405export function buildCodexInitializeParams() {
406 return {
407 clientInfo: {
408 name: "t3code_desktop",
409 title: "T3 Code Desktop",
410 version: "0.1.0",
411 },
412 capabilities: {
413 experimentalApi: true,
414 },
415 } as const;
416}
417
418334function buildCodexCollaborationMode(input: {
419335 readonly interactionMode?: "default" | "plan";
420336 readonly model?: string;
apps/server/src/provider/Layers/ClaudeProvider.ts+433-150
......@@ -4,12 +4,14 @@ import type {
44 ModelCapabilities,
55 ServerProvider,
66 ServerProviderModel,
7 ServerProviderAuthStatus,
7 ServerProviderAuth,
88 ServerProviderState,
99} from "@t3tools/contracts";
10import { Effect, Equal, Layer, Option, Result, Stream } from "effect";
10import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
1111import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1212import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk";
1315
1416import {
1517 buildServerProvider,
......@@ -43,8 +45,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
4345 supportsFastMode: true,
4446 supportsThinkingToggle: false,
4547 contextWindowOptions: [
46 { value: "200k", label: "200k" },
47 { value: "1m", label: "1M", isDefault: true },
48 { value: "200k", label: "200k", isDefault: true },
49 { value: "1m", label: "1M" },
4850 ],
4951 promptInjectedEffortLevels: ["ultrathink"],
5052 } satisfies ModelCapabilities,
......@@ -63,8 +65,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
6365 supportsFastMode: false,
6466 supportsThinkingToggle: false,
6567 contextWindowOptions: [
66 { value: "200k", label: "200k" },
67 { value: "1m", label: "1M", isDefault: true },
68 { value: "200k", label: "200k", isDefault: true },
69 { value: "1m", label: "1M" },
6870 ],
6971 promptInjectedEffortLevels: ["ultrathink"],
7072 } satisfies ModelCapabilities,
......@@ -117,7 +119,7 @@ export function normalizeClaudeModelOptions(
117119
118120export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
119121 readonly status: Exclude<ServerProviderState, "disabled">;
120 readonly authStatus: ServerProviderAuthStatus;
122 readonly auth: Pick<ServerProviderAuth, "status">;
121123 readonly message?: string;
122124} {
123125 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
......@@ -129,7 +131,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
129131 ) {
130132 return {
131133 status: "warning",
132 authStatus: "unknown",
134 auth: { status: "unknown" },
133135 message:
134136 "Claude Agent authentication status command is unavailable in this version of Claude.",
135137 };
......@@ -144,7 +146,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
144146 ) {
145147 return {
146148 status: "error",
147 authStatus: "unauthenticated",
149 auth: { status: "unauthenticated" },
148150 message: "Claude is not authenticated. Run `claude auth login` and try again.",
149151 };
150152 }
......@@ -165,37 +167,286 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
165167 })();
166168
167169 if (parsedAuth.auth === true) {
168 return { status: "ready", authStatus: "authenticated" };
170 return { status: "ready", auth: { status: "authenticated" } };
169171 }
170172 if (parsedAuth.auth === false) {
171173 return {
172174 status: "error",
173 authStatus: "unauthenticated",
175 auth: { status: "unauthenticated" },
174176 message: "Claude is not authenticated. Run `claude auth login` and try again.",
175177 };
176178 }
177179 if (parsedAuth.attemptedJsonParse) {
178180 return {
179181 status: "warning",
180 authStatus: "unknown",
182 auth: { status: "unknown" },
181183 message:
182184 "Could not verify Claude authentication status from JSON output (missing auth marker).",
183185 };
184186 }
185187 if (result.code === 0) {
186 return { status: "ready", authStatus: "authenticated" };
188 return { status: "ready", auth: { status: "authenticated" } };
187189 }
188190
189191 const detail = detailFromResult(result);
190192 return {
191193 status: "warning",
192 authStatus: "unknown",
194 auth: { status: "unknown" },
193195 message: detail
194196 ? `Could not verify Claude authentication status. ${detail}`
195197 : "Could not verify Claude authentication status.",
196198 };
197199}
198200
201// ── Subscription type detection ─────────────────────────────────────
202//
203// The SDK probe returns typed `AccountInfo.subscriptionType` directly.
204// This walker is a best-effort fallback for the `claude auth status`
205// JSON output whose shape is not guaranteed.
206
207/** Keys that directly hold a subscription/plan identifier. */
208const SUBSCRIPTION_TYPE_KEYS = [
209 "subscriptionType",
210 "subscription_type",
211 "plan",
212 "tier",
213 "planType",
214 "plan_type",
215] as const;
216
217/** Keys whose value may be a nested object containing subscription info. */
218const SUBSCRIPTION_CONTAINER_KEYS = ["account", "subscription", "user", "billing"] as const;
219const AUTH_METHOD_KEYS = ["authMethod", "auth_method"] as const;
220const AUTH_METHOD_CONTAINER_KEYS = ["auth", "account", "session"] as const;
221
222/** Lift an unknown value into `Option<string>` if it is a non-empty string. */
223const asNonEmptyString = (v: unknown): Option.Option<string> =>
224 typeof v === "string" && v.length > 0 ? Option.some(v) : Option.none();
225
226/** Lift an unknown value into `Option<Record>` if it is a plain object. */
227const asRecord = (v: unknown): Option.Option<Record<string, unknown>> =>
228 typeof v === "object" && v !== null && !globalThis.Array.isArray(v)
229 ? Option.some(v as Record<string, unknown>)
230 : Option.none();
231
232/**
233 * Walk an unknown parsed JSON value looking for a subscription/plan
234 * identifier, returning the first match as an `Option`.
235 */
236function findSubscriptionType(value: unknown): Option.Option<string> {
237 if (globalThis.Array.isArray(value)) {
238 return Option.firstSomeOf(value.map(findSubscriptionType));
239 }
240
241 return asRecord(value).pipe(
242 Option.flatMap((record) => {
243 const direct = Option.firstSomeOf(
244 SUBSCRIPTION_TYPE_KEYS.map((key) => asNonEmptyString(record[key])),
245 );
246 if (Option.isSome(direct)) return direct;
247
248 return Option.firstSomeOf(
249 SUBSCRIPTION_CONTAINER_KEYS.map((key) =>
250 asRecord(record[key]).pipe(Option.flatMap(findSubscriptionType)),
251 ),
252 );
253 }),
254 );
255}
256
257function findAuthMethod(value: unknown): Option.Option<string> {
258 if (globalThis.Array.isArray(value)) {
259 return Option.firstSomeOf(value.map(findAuthMethod));
260 }
261
262 return asRecord(value).pipe(
263 Option.flatMap((record) => {
264 const direct = Option.firstSomeOf(
265 AUTH_METHOD_KEYS.map((key) => asNonEmptyString(record[key])),
266 );
267 if (Option.isSome(direct)) return direct;
268
269 return Option.firstSomeOf(
270 AUTH_METHOD_CONTAINER_KEYS.map((key) =>
271 asRecord(record[key]).pipe(Option.flatMap(findAuthMethod)),
272 ),
273 );
274 }),
275 );
276}
277
278/**
279 * Try to extract a subscription type from the `claude auth status` JSON
280 * output. This is a zero-cost operation on data we already have.
281 */
282const decodeUnknownJson = decodeJsonResult(Schema.Unknown);
283
284function extractSubscriptionTypeFromOutput(result: CommandResult): string | undefined {
285 const parsed = decodeUnknownJson(result.stdout.trim());
286 if (Result.isFailure(parsed)) return undefined;
287 return Option.getOrUndefined(findSubscriptionType(parsed.success));
288}
289
290function extractClaudeAuthMethodFromOutput(result: CommandResult): string | undefined {
291 const parsed = decodeUnknownJson(result.stdout.trim());
292 if (Result.isFailure(parsed)) return undefined;
293 return Option.getOrUndefined(findAuthMethod(parsed.success));
294}
295
296// ── Dynamic model capability adjustment ─────────────────────────────
297
298/** Subscription types where the 1M context window is included in the plan. */
299const PREMIUM_SUBSCRIPTION_TYPES = new Set([
300 "max",
301 "maxplan",
302 "max5",
303 "max20",
304 "enterprise",
305 "team",
306]);
307
308function toTitleCaseWords(value: string): string {
309 return value
310 .split(/[\s_-]+/g)
311 .filter(Boolean)
312 .map((part) => part[0]!.toUpperCase() + part.slice(1).toLowerCase())
313 .join(" ");
314}
315
316function claudeSubscriptionLabel(subscriptionType: string | undefined): string | undefined {
317 const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, "");
318 if (!normalized) return undefined;
319
320 switch (normalized) {
321 case "max":
322 case "maxplan":
323 case "max5":
324 case "max20":
325 return "Max";
326 case "enterprise":
327 return "Enterprise";
328 case "team":
329 return "Team";
330 case "pro":
331 return "Pro";
332 case "free":
333 return "Free";
334 default:
335 return toTitleCaseWords(subscriptionType!);
336 }
337}
338
339function normalizeClaudeAuthMethod(authMethod: string | undefined): string | undefined {
340 const normalized = authMethod?.toLowerCase().replace(/[\s_-]+/g, "");
341 if (!normalized) return undefined;
342 if (normalized === "apikey") return "apiKey";
343 return undefined;
344}
345
346function claudeAuthMetadata(input: {
347 readonly subscriptionType: string | undefined;
348 readonly authMethod: string | undefined;
349}): { readonly type: string; readonly label: string } | undefined {
350 if (normalizeClaudeAuthMethod(input.authMethod) === "apiKey") {
351 return {
352 type: "apiKey",
353 label: "Claude API Key",
354 };
355 }
356
357 if (input.subscriptionType) {
358 const subscriptionLabel = claudeSubscriptionLabel(input.subscriptionType);
359 return {
360 type: input.subscriptionType,
361 label: `Claude ${subscriptionLabel ?? toTitleCaseWords(input.subscriptionType)} Subscription`,
362 };
363 }
364
365 return undefined;
366}
367
368/**
369 * Adjust the built-in model list based on the user's detected subscription.
370 *
371 * - Premium tiers (Max, Enterprise, Team): 1M context becomes the default.
372 * - Other tiers (Pro, free, unknown): 200k context stays the default;
373 * 1M remains available as a manual option so users can still enable it.
374 */
375export function adjustModelsForSubscription(
376 baseModels: ReadonlyArray<ServerProviderModel>,
377 subscriptionType: string | undefined,
378): ReadonlyArray<ServerProviderModel> {
379 const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, "");
380 if (!normalized || !PREMIUM_SUBSCRIPTION_TYPES.has(normalized)) {
381 return baseModels;
382 }
383
384 // Flip 1M to be the default for premium users
385 return baseModels.map((model) => {
386 const caps = model.capabilities;
387 if (!caps || caps.contextWindowOptions.length === 0) return model;
388
389 return {
390 ...model,
391 capabilities: {
392 ...caps,
393 contextWindowOptions: caps.contextWindowOptions.map((opt) =>
394 opt.value === "1m"
395 ? { value: opt.value, label: opt.label, isDefault: true as const }
396 : { value: opt.value, label: opt.label },
397 ),
398 },
399 };
400 });
401}
402
403// ── SDK capability probe ────────────────────────────────────────────
404
405const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000;
406
407/**
408 * Probe account information by spawning a lightweight Claude Agent SDK
409 * session and reading the initialization result.
410 *
411 * The prompt is never sent to the Anthropic API — we abort immediately
412 * after the local initialization phase completes. This gives us the
413 * user's subscription type without incurring any token cost.
414 *
415 * This is used as a fallback when `claude auth status` does not include
416 * subscription type information.
417 */
418const probeClaudeCapabilities = (binaryPath: string) => {
419 const abort = new AbortController();
420 return Effect.tryPromise(async () => {
421 const q = claudeQuery({
422 prompt: ".",
423 options: {
424 persistSession: false,
425 pathToClaudeCodeExecutable: binaryPath,
426 abortController: abort,
427 maxTurns: 0,
428 settingSources: [],
429 allowedTools: [],
430 stderr: () => {},
431 },
432 });
433 const init = await q.initializationResult();
434 return { subscriptionType: init.account?.subscriptionType };
435 }).pipe(
436 Effect.ensuring(
437 Effect.sync(() => {
438 if (!abort.signal.aborted) abort.abort();
439 }),
440 ),
441 Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
442 Effect.result,
443 Effect.map((result) => {
444 if (Result.isFailure(result)) return undefined;
445 return Option.isSome(result.success) ? result.success.value : undefined;
446 }),
447 );
448};
449
199450const runClaudeCommand = (args: ReadonlyArray<string>) =>
200451 Effect.gen(function* () {
201452 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
......@@ -208,159 +459,182 @@ const runClaudeCommand = (args: ReadonlyArray<string>) =>
208459 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
209460 });
210461
211export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
212 function* (): Effect.fn.Return<
213 ServerProvider,
214 ServerSettingsError,
215 ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService
216 > {
217 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
218 Effect.flatMap((service) => service.getSettings),
219 Effect.map((settings) => settings.providers.claudeAgent),
220 );
221 const checkedAt = new Date().toISOString();
222 const models = providerModelsFromSettings(
223 BUILT_IN_MODELS,
224 PROVIDER,
225 claudeSettings.customModels,
226 );
462export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
463 resolveSubscriptionType?: (binaryPath: string) => Effect.Effect<string | undefined>,
464): Effect.fn.Return<
465 ServerProvider,
466 ServerSettingsError,
467 ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService
468> {
469 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
470 Effect.flatMap((service) => service.getSettings),
471 Effect.map((settings) => settings.providers.claudeAgent),
472 );
473 const checkedAt = new Date().toISOString();
474 const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels);
227475
228 if (!claudeSettings.enabled) {
229 return buildServerProvider({
230 provider: PROVIDER,
231 enabled: false,
232 checkedAt,
233 models,
234 probe: {
235 installed: false,
236 version: null,
237 status: "warning",
238 authStatus: "unknown",
239 message: "Claude is disabled in T3 Code settings.",
240 },
241 });
242 }
476 if (!claudeSettings.enabled) {
477 return buildServerProvider({
478 provider: PROVIDER,
479 enabled: false,
480 checkedAt,
481 models,
482 probe: {
483 installed: false,
484 version: null,
485 status: "warning",
486 auth: { status: "unknown" },
487 message: "Claude is disabled in T3 Code settings.",
488 },
489 });
490 }
243491
244 const versionProbe = yield* runClaudeCommand(["--version"]).pipe(
245 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
246 Effect.result,
247 );
492 const versionProbe = yield* runClaudeCommand(["--version"]).pipe(
493 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
494 Effect.result,
495 );
248496
249 if (Result.isFailure(versionProbe)) {
250 const error = versionProbe.failure;
251 return buildServerProvider({
252 provider: PROVIDER,
253 enabled: claudeSettings.enabled,
254 checkedAt,
255 models,
256 probe: {
257 installed: !isCommandMissingCause(error),
258 version: null,
259 status: "error",
260 authStatus: "unknown",
261 message: isCommandMissingCause(error)
262 ? "Claude Agent CLI (`claude`) is not installed or not on PATH."
263 : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
264 },
265 });
266 }
497 if (Result.isFailure(versionProbe)) {
498 const error = versionProbe.failure;
499 return buildServerProvider({
500 provider: PROVIDER,
501 enabled: claudeSettings.enabled,
502 checkedAt,
503 models,
504 probe: {
505 installed: !isCommandMissingCause(error),
506 version: null,
507 status: "error",
508 auth: { status: "unknown" },
509 message: isCommandMissingCause(error)
510 ? "Claude Agent CLI (`claude`) is not installed or not on PATH."
511 : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
512 },
513 });
514 }
267515
268 if (Option.isNone(versionProbe.success)) {
269 return buildServerProvider({
270 provider: PROVIDER,
271 enabled: claudeSettings.enabled,
272 checkedAt,
273 models,
274 probe: {
275 installed: true,
276 version: null,
277 status: "error",
278 authStatus: "unknown",
279 message:
280 "Claude Agent CLI is installed but failed to run. Timed out while running command.",
281 },
282 });
283 }
516 if (Option.isNone(versionProbe.success)) {
517 return buildServerProvider({
518 provider: PROVIDER,
519 enabled: claudeSettings.enabled,
520 checkedAt,
521 models,
522 probe: {
523 installed: true,
524 version: null,
525 status: "error",
526 auth: { status: "unknown" },
527 message:
528 "Claude Agent CLI is installed but failed to run. Timed out while running command.",
529 },
530 });
531 }
284532
285 const version = versionProbe.success.value;
286 const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
287 if (version.code !== 0) {
288 const detail = detailFromResult(version);
289 return buildServerProvider({
290 provider: PROVIDER,
291 enabled: claudeSettings.enabled,
292 checkedAt,
293 models,
294 probe: {
295 installed: true,
296 version: parsedVersion,
297 status: "error",
298 authStatus: "unknown",
299 message: detail
300 ? `Claude Agent CLI is installed but failed to run. ${detail}`
301 : "Claude Agent CLI is installed but failed to run.",
302 },
303 });
304 }
533 const version = versionProbe.success.value;
534 const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
535 if (version.code !== 0) {
536 const detail = detailFromResult(version);
537 return buildServerProvider({
538 provider: PROVIDER,
539 enabled: claudeSettings.enabled,
540 checkedAt,
541 models,
542 probe: {
543 installed: true,
544 version: parsedVersion,
545 status: "error",
546 auth: { status: "unknown" },
547 message: detail
548 ? `Claude Agent CLI is installed but failed to run. ${detail}`
549 : "Claude Agent CLI is installed but failed to run.",
550 },
551 });
552 }
305553
306 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
307 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
308 Effect.result,
309 );
554 // ── Auth check + subscription detection ────────────────────────────
310555
311 if (Result.isFailure(authProbe)) {
312 const error = authProbe.failure;
313 return buildServerProvider({
314 provider: PROVIDER,
315 enabled: claudeSettings.enabled,
316 checkedAt,
317 models,
318 probe: {
319 installed: true,
320 version: parsedVersion,
321 status: "warning",
322 authStatus: "unknown",
323 message:
324 error instanceof Error
325 ? `Could not verify Claude authentication status: ${error.message}.`
326 : "Could not verify Claude authentication status.",
327 },
328 });
329 }
556 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
557 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
558 Effect.result,
559 );
330560
331 if (Option.isNone(authProbe.success)) {
332 return buildServerProvider({
333 provider: PROVIDER,
334 enabled: claudeSettings.enabled,
335 checkedAt,
336 models,
337 probe: {
338 installed: true,
339 version: parsedVersion,
340 status: "warning",
341 authStatus: "unknown",
342 message:
343 "Could not verify Claude authentication status. Timed out while running command.",
344 },
345 });
346 }
561 // Determine subscription type from multiple sources (cheapest first):
562 // 1. `claude auth status` JSON output (may or may not contain it)
563 // 2. Cached SDK probe (spawns a Claude process on miss, reads
564 // `initializationResult()` for account metadata, then aborts
565 // immediately — no API tokens are consumed)
347566
348 const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value);
567 let subscriptionType: string | undefined;
568 let authMethod: string | undefined;
569
570 if (Result.isSuccess(authProbe) && Option.isSome(authProbe.success)) {
571 subscriptionType = extractSubscriptionTypeFromOutput(authProbe.success.value);
572 authMethod = extractClaudeAuthMethodFromOutput(authProbe.success.value);
573 }
574
575 if (!subscriptionType && resolveSubscriptionType) {
576 subscriptionType = yield* resolveSubscriptionType(claudeSettings.binaryPath);
577 }
578
579 const resolvedModels = adjustModelsForSubscription(models, subscriptionType);
580
581 // ── Handle auth results (same logic as before, adjusted models) ──
582
583 if (Result.isFailure(authProbe)) {
584 const error = authProbe.failure;
349585 return buildServerProvider({
350586 provider: PROVIDER,
351587 enabled: claudeSettings.enabled,
352588 checkedAt,
353 models,
589 models: resolvedModels,
354590 probe: {
355591 installed: true,
356592 version: parsedVersion,
357 status: parsed.status,
358 authStatus: parsed.authStatus,
359 ...(parsed.message ? { message: parsed.message } : {}),
593 status: "warning",
594 auth: { status: "unknown" },
595 message:
596 error instanceof Error
597 ? `Could not verify Claude authentication status: ${error.message}.`
598 : "Could not verify Claude authentication status.",
360599 },
361600 });
362 },
363);
601 }
602
603 if (Option.isNone(authProbe.success)) {
604 return buildServerProvider({
605 provider: PROVIDER,
606 enabled: claudeSettings.enabled,
607 checkedAt,
608 models: resolvedModels,
609 probe: {
610 installed: true,
611 version: parsedVersion,
612 status: "warning",
613 auth: { status: "unknown" },
614 message: "Could not verify Claude authentication status. Timed out while running command.",
615 },
616 });
617 }
618
619 const parsed = parseClaudeAuthStatusFromOutput(authProbe.success.value);
620 const authMetadata = claudeAuthMetadata({ subscriptionType, authMethod });
621 return buildServerProvider({
622 provider: PROVIDER,
623 enabled: claudeSettings.enabled,
624 checkedAt,
625 models: resolvedModels,
626 probe: {
627 installed: true,
628 version: parsedVersion,
629 status: parsed.status,
630 auth: {
631 ...parsed.auth,
632 ...(authMetadata ? authMetadata : {}),
633 },
634 ...(parsed.message ? { message: parsed.message } : {}),
635 },
636 });
637});
364638
365639export const ClaudeProviderLive = Layer.effect(
366640 ClaudeProvider,
......@@ -368,7 +642,16 @@ export const ClaudeProviderLive = Layer.effect(
368642 const serverSettings = yield* ServerSettingsService;
369643 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
370644
371 const checkProvider = checkClaudeProviderStatus().pipe(
645 const subscriptionProbeCache = yield* Cache.make({
646 capacity: 1,
647 timeToLive: Duration.minutes(5),
648 lookup: (binaryPath: string) =>
649 probeClaudeCapabilities(binaryPath).pipe(Effect.map((r) => r?.subscriptionType)),
650 });
651
652 const checkProvider = checkClaudeProviderStatus((binaryPath) =>
653 Cache.get(subscriptionProbeCache, binaryPath),
654 ).pipe(
372655 Effect.provideService(ServerSettingsService, serverSettings),
373656 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
374657 );
apps/server/src/provider/Layers/CodexProvider.ts+238-180
......@@ -5,10 +5,21 @@ import type {
55 CodexSettings,
66 ServerProvider,
77 ServerProviderModel,
8 ServerProviderAuthStatus,
8 ServerProviderAuth,
99 ServerProviderState,
1010} from "@t3tools/contracts";
11import { Effect, Equal, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
11import {
12 Cache,
13 Duration,
14 Effect,
15 Equal,
16 FileSystem,
17 Layer,
18 Option,
19 Path,
20 Result,
21 Stream,
22} from "effect";
1223import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1324import { resolveEffort } from "@t3tools/shared/model";
1425
......@@ -29,6 +40,13 @@ import {
2940 isCodexCliVersionSupported,
3041 parseCodexCliVersion,
3142} from "../codexCliVersion";
43import {
44 adjustCodexModelsForAccount,
45 codexAuthSubLabel,
46 codexAuthSubType,
47 type CodexAccountSnapshot,
48} from "../codexAccount";
49import { probeCodexAccount } from "../codexAppServer";
3250import { CodexProvider } from "../Services/CodexProvider";
3351import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
3452
......@@ -170,7 +188,7 @@ export function normalizeCodexModelOptions(
170188
171189export function parseAuthStatusFromOutput(result: CommandResult): {
172190 readonly status: Exclude<ServerProviderState, "disabled">;
173 readonly authStatus: ServerProviderAuthStatus;
191 readonly auth: Pick<ServerProviderAuth, "status">;
174192 readonly message?: string;
175193} {
176194 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
......@@ -182,7 +200,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
182200 ) {
183201 return {
184202 status: "warning",
185 authStatus: "unknown",
203 auth: { status: "unknown" },
186204 message: "Codex CLI authentication status command is unavailable in this Codex version.",
187205 };
188206 }
......@@ -196,7 +214,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
196214 ) {
197215 return {
198216 status: "error",
199 authStatus: "unauthenticated",
217 auth: { status: "unauthenticated" },
200218 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
201219 };
202220 }
......@@ -217,31 +235,31 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
217235 })();
218236
219237 if (parsedAuth.auth === true) {
220 return { status: "ready", authStatus: "authenticated" };
238 return { status: "ready", auth: { status: "authenticated" } };
221239 }
222240 if (parsedAuth.auth === false) {
223241 return {
224242 status: "error",
225 authStatus: "unauthenticated",
243 auth: { status: "unauthenticated" },
226244 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
227245 };
228246 }
229247 if (parsedAuth.attemptedJsonParse) {
230248 return {
231249 status: "warning",
232 authStatus: "unknown",
250 auth: { status: "unknown" },
233251 message:
234252 "Could not verify Codex authentication status from JSON output (missing auth marker).",
235253 };
236254 }
237255 if (result.code === 0) {
238 return { status: "ready", authStatus: "authenticated" };
256 return { status: "ready", auth: { status: "authenticated" } };
239257 }
240258
241259 const detail = detailFromResult(result);
242260 return {
243261 status: "warning",
244 authStatus: "unknown",
262 auth: { status: "unknown" },
245263 message: detail
246264 ? `Could not verify Codex authentication status. ${detail}`
247265 : "Could not verify Codex authentication status.",
......@@ -290,6 +308,21 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe(
290308 Effect.orElseSucceed(() => false),
291309);
292310
311const CAPABILITIES_PROBE_TIMEOUT_MS = 8_000;
312
313const probeCodexCapabilities = (input: {
314 readonly binaryPath: string;
315 readonly homePath?: string;
316}) =>
317 Effect.tryPromise((signal) => probeCodexAccount({ ...input, signal })).pipe(
318 Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
319 Effect.result,
320 Effect.map((result) => {
321 if (Result.isFailure(result)) return undefined;
322 return Option.isSome(result.success) ? result.success.value : undefined;
323 }),
324 );
325
293326const runCodexCommand = (args: ReadonlyArray<string>) =>
294327 Effect.gen(function* () {
295328 const settingsService = yield* ServerSettingsService;
......@@ -306,194 +339,206 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>
306339 return yield* spawnAndCollect(codexSettings.binaryPath, command);
307340 });
308341
309export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(
310 function* (): Effect.fn.Return<
311 ServerProvider,
312 ServerSettingsError,
313 | ChildProcessSpawner.ChildProcessSpawner
314 | FileSystem.FileSystem
315 | Path.Path
316 | ServerSettingsService
317 > {
318 const codexSettings = yield* Effect.service(ServerSettingsService).pipe(
319 Effect.flatMap((service) => service.getSettings),
320 Effect.map((settings) => settings.providers.codex),
321 );
322 const checkedAt = new Date().toISOString();
323 const models = providerModelsFromSettings(
324 BUILT_IN_MODELS,
325 PROVIDER,
326 codexSettings.customModels,
327 );
328
329 if (!codexSettings.enabled) {
330 return buildServerProvider({
331 provider: PROVIDER,
332 enabled: false,
333 checkedAt,
334 models,
335 probe: {
336 installed: false,
337 version: null,
338 status: "warning",
339 authStatus: "unknown",
340 message: "Codex is disabled in T3 Code settings.",
341 },
342 });
343 }
342export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
343 resolveAccount?: (input: {
344 readonly binaryPath: string;
345 readonly homePath?: string;
346 }) => Effect.Effect<CodexAccountSnapshot | undefined>,
347): Effect.fn.Return<
348 ServerProvider,
349 ServerSettingsError,
350 | ChildProcessSpawner.ChildProcessSpawner
351 | FileSystem.FileSystem
352 | Path.Path
353 | ServerSettingsService
354> {
355 const codexSettings = yield* Effect.service(ServerSettingsService).pipe(
356 Effect.flatMap((service) => service.getSettings),
357 Effect.map((settings) => settings.providers.codex),
358 );
359 const checkedAt = new Date().toISOString();
360 const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, codexSettings.customModels);
344361
345 const versionProbe = yield* runCodexCommand(["--version"]).pipe(
346 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
347 Effect.result,
348 );
362 if (!codexSettings.enabled) {
363 return buildServerProvider({
364 provider: PROVIDER,
365 enabled: false,
366 checkedAt,
367 models,
368 probe: {
369 installed: false,
370 version: null,
371 status: "warning",
372 auth: { status: "unknown" },
373 message: "Codex is disabled in T3 Code settings.",
374 },
375 });
376 }
349377
350 if (Result.isFailure(versionProbe)) {
351 const error = versionProbe.failure;
352 return buildServerProvider({
353 provider: PROVIDER,
354 enabled: codexSettings.enabled,
355 checkedAt,
356 models,
357 probe: {
358 installed: !isCommandMissingCause(error),
359 version: null,
360 status: "error",
361 authStatus: "unknown",
362 message: isCommandMissingCause(error)
363 ? "Codex CLI (`codex`) is not installed or not on PATH."
364 : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
365 },
366 });
367 }
378 const versionProbe = yield* runCodexCommand(["--version"]).pipe(
379 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
380 Effect.result,
381 );
368382
369 if (Option.isNone(versionProbe.success)) {
370 return buildServerProvider({
371 provider: PROVIDER,
372 enabled: codexSettings.enabled,
373 checkedAt,
374 models,
375 probe: {
376 installed: true,
377 version: null,
378 status: "error",
379 authStatus: "unknown",
380 message: "Codex CLI is installed but failed to run. Timed out while running command.",
381 },
382 });
383 }
383 if (Result.isFailure(versionProbe)) {
384 const error = versionProbe.failure;
385 return buildServerProvider({
386 provider: PROVIDER,
387 enabled: codexSettings.enabled,
388 checkedAt,
389 models,
390 probe: {
391 installed: !isCommandMissingCause(error),
392 version: null,
393 status: "error",
394 auth: { status: "unknown" },
395 message: isCommandMissingCause(error)
396 ? "Codex CLI (`codex`) is not installed or not on PATH."
397 : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
398 },
399 });
400 }
384401
385 const version = versionProbe.success.value;
386 const parsedVersion =
387 parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ??
388 parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
389 if (version.code !== 0) {
390 const detail = detailFromResult(version);
391 return buildServerProvider({
392 provider: PROVIDER,
393 enabled: codexSettings.enabled,
394 checkedAt,
395 models,
396 probe: {
397 installed: true,
398 version: parsedVersion,
399 status: "error",
400 authStatus: "unknown",
401 message: detail
402 ? `Codex CLI is installed but failed to run. ${detail}`
403 : "Codex CLI is installed but failed to run.",
404 },
405 });
406 }
402 if (Option.isNone(versionProbe.success)) {
403 return buildServerProvider({
404 provider: PROVIDER,
405 enabled: codexSettings.enabled,
406 checkedAt,
407 models,
408 probe: {
409 installed: true,
410 version: null,
411 status: "error",
412 auth: { status: "unknown" },
413 message: "Codex CLI is installed but failed to run. Timed out while running command.",
414 },
415 });
416 }
407417
408 if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {
409 return buildServerProvider({
410 provider: PROVIDER,
411 enabled: codexSettings.enabled,
412 checkedAt,
413 models,
414 probe: {
415 installed: true,
416 version: parsedVersion,
417 status: "error",
418 authStatus: "unknown",
419 message: formatCodexCliUpgradeMessage(parsedVersion),
420 },
421 });
422 }
418 const version = versionProbe.success.value;
419 const parsedVersion =
420 parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ??
421 parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
422 if (version.code !== 0) {
423 const detail = detailFromResult(version);
424 return buildServerProvider({
425 provider: PROVIDER,
426 enabled: codexSettings.enabled,
427 checkedAt,
428 models,
429 probe: {
430 installed: true,
431 version: parsedVersion,
432 status: "error",
433 auth: { status: "unknown" },
434 message: detail
435 ? `Codex CLI is installed but failed to run. ${detail}`
436 : "Codex CLI is installed but failed to run.",
437 },
438 });
439 }
423440
424 if (yield* hasCustomModelProvider) {
425 return buildServerProvider({
426 provider: PROVIDER,
427 enabled: codexSettings.enabled,
428 checkedAt,
429 models,
430 probe: {
431 installed: true,
432 version: parsedVersion,
433 status: "ready",
434 authStatus: "unknown",
435 message: "Using a custom Codex model provider; OpenAI login check skipped.",
436 },
437 });
438 }
441 if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {
442 return buildServerProvider({
443 provider: PROVIDER,
444 enabled: codexSettings.enabled,
445 checkedAt,
446 models,
447 probe: {
448 installed: true,
449 version: parsedVersion,
450 status: "error",
451 auth: { status: "unknown" },
452 message: formatCodexCliUpgradeMessage(parsedVersion),
453 },
454 });
455 }
439456
440 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(
441 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
442 Effect.result,
443 );
457 if (yield* hasCustomModelProvider) {
458 return buildServerProvider({
459 provider: PROVIDER,
460 enabled: codexSettings.enabled,
461 checkedAt,
462 models,
463 probe: {
464 installed: true,
465 version: parsedVersion,
466 status: "ready",
467 auth: { status: "unknown" },
468 message: "Using a custom Codex model provider; OpenAI login check skipped.",
469 },
470 });
471 }
444472
445 if (Result.isFailure(authProbe)) {
446 const error = authProbe.failure;
447 return buildServerProvider({
448 provider: PROVIDER,
449 enabled: codexSettings.enabled,
450 checkedAt,
451 models,
452 probe: {
453 installed: true,
454 version: parsedVersion,
455 status: "warning",
456 authStatus: "unknown",
457 message:
458 error instanceof Error
459 ? `Could not verify Codex authentication status: ${error.message}.`
460 : "Could not verify Codex authentication status.",
461 },
462 });
463 }
473 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(
474 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
475 Effect.result,
476 );
477 const account = resolveAccount
478 ? yield* resolveAccount({
479 binaryPath: codexSettings.binaryPath,
480 homePath: codexSettings.homePath,
481 })
482 : undefined;
483 const resolvedModels = adjustCodexModelsForAccount(models, account);
464484
465 if (Option.isNone(authProbe.success)) {
466 return buildServerProvider({
467 provider: PROVIDER,
468 enabled: codexSettings.enabled,
469 checkedAt,
470 models,
471 probe: {
472 installed: true,
473 version: parsedVersion,
474 status: "warning",
475 authStatus: "unknown",
476 message: "Could not verify Codex authentication status. Timed out while running command.",
477 },
478 });
479 }
485 if (Result.isFailure(authProbe)) {
486 const error = authProbe.failure;
487 return buildServerProvider({
488 provider: PROVIDER,
489 enabled: codexSettings.enabled,
490 checkedAt,
491 models: resolvedModels,
492 probe: {
493 installed: true,
494 version: parsedVersion,
495 status: "warning",
496 auth: { status: "unknown" },
497 message:
498 error instanceof Error
499 ? `Could not verify Codex authentication status: ${error.message}.`
500 : "Could not verify Codex authentication status.",
501 },
502 });
503 }
480504
481 const parsed = parseAuthStatusFromOutput(authProbe.success.value);
505 if (Option.isNone(authProbe.success)) {
482506 return buildServerProvider({
483507 provider: PROVIDER,
484508 enabled: codexSettings.enabled,
485509 checkedAt,
486 models,
510 models: resolvedModels,
487511 probe: {
488512 installed: true,
489513 version: parsedVersion,
490 status: parsed.status,
491 authStatus: parsed.authStatus,
492 ...(parsed.message ? { message: parsed.message } : {}),
514 status: "warning",
515 auth: { status: "unknown" },
516 message: "Could not verify Codex authentication status. Timed out while running command.",
493517 },
494518 });
495 },
496);
519 }
520
521 const parsed = parseAuthStatusFromOutput(authProbe.success.value);
522 const authType = codexAuthSubType(account);
523 const authLabel = codexAuthSubLabel(account);
524 return buildServerProvider({
525 provider: PROVIDER,
526 enabled: codexSettings.enabled,
527 checkedAt,
528 models: resolvedModels,
529 probe: {
530 installed: true,
531 version: parsedVersion,
532 status: parsed.status,
533 auth: {
534 ...parsed.auth,
535 ...(authType ? { type: authType } : {}),
536 ...(authLabel ? { label: authLabel } : {}),
537 },
538 ...(parsed.message ? { message: parsed.message } : {}),
539 },
540 });
541});
497542
498543export const CodexProviderLive = Layer.effect(
499544 CodexProvider,
......@@ -502,8 +547,21 @@ export const CodexProviderLive = Layer.effect(
502547 const fileSystem = yield* FileSystem.FileSystem;
503548 const path = yield* Path.Path;
504549 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
550 const accountProbeCache = yield* Cache.make({
551 capacity: 4,
552 timeToLive: Duration.minutes(5),
553 lookup: (key: string) => {
554 const [binaryPath, homePath] = JSON.parse(key) as [string, string | undefined];
555 return probeCodexCapabilities({
556 binaryPath,
557 ...(homePath ? { homePath } : {}),
558 });
559 },
560 });
505561
506 const checkProvider = checkCodexProviderStatus().pipe(
562 const checkProvider = checkCodexProviderStatus((input) =>
563 Cache.get(accountProbeCache, JSON.stringify([input.binaryPath, input.homePath])),
564 ).pipe(
507565 Effect.provideService(ServerSettingsService, serverSettings),
508566 Effect.provideService(FileSystem.FileSystem, fileSystem),
509567 Effect.provideService(Path.Path, path),
apps/server/src/provider/Layers/ProviderRegistry.test.ts+199-23
......@@ -173,7 +173,133 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
173173 assert.strictEqual(status.provider, "codex");
174174 assert.strictEqual(status.status, "ready");
175175 assert.strictEqual(status.installed, true);
176 assert.strictEqual(status.authStatus, "authenticated");
176 assert.strictEqual(status.auth.status, "authenticated");
177 }).pipe(
178 Effect.provide(
179 mockSpawnerLayer((args) => {
180 const joined = args.join(" ");
181 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
182 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
183 throw new Error(`Unexpected args: ${joined}`);
184 }),
185 ),
186 ),
187 );
188
189 it.effect("returns the codex plan type in auth and keeps spark for supported plans", () =>
190 Effect.gen(function* () {
191 yield* withTempCodexHome();
192 const status = yield* checkCodexProviderStatus(() =>
193 Effect.succeed({
194 type: "chatgpt" as const,
195 planType: "pro" as const,
196 sparkEnabled: true,
197 }),
198 );
199
200 assert.strictEqual(status.provider, "codex");
201 assert.strictEqual(status.status, "ready");
202 assert.strictEqual(status.auth.status, "authenticated");
203 assert.strictEqual(status.auth.type, "pro");
204 assert.strictEqual(status.auth.label, "ChatGPT Pro Subscription");
205 assert.deepStrictEqual(
206 status.models.some((model) => model.slug === "gpt-5.3-codex-spark"),
207 true,
208 );
209 }).pipe(
210 Effect.provide(
211 mockSpawnerLayer((args) => {
212 const joined = args.join(" ");
213 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
214 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
215 throw new Error(`Unexpected args: ${joined}`);
216 }),
217 ),
218 ),
219 );
220
221 it.effect("hides spark from codex models for unsupported chatgpt plans", () =>
222 Effect.gen(function* () {
223 yield* withTempCodexHome();
224 const status = yield* checkCodexProviderStatus(() =>
225 Effect.succeed({
226 type: "chatgpt" as const,
227 planType: "plus" as const,
228 sparkEnabled: false,
229 }),
230 );
231
232 assert.strictEqual(status.provider, "codex");
233 assert.strictEqual(status.status, "ready");
234 assert.strictEqual(status.auth.status, "authenticated");
235 assert.strictEqual(status.auth.type, "plus");
236 assert.strictEqual(status.auth.label, "ChatGPT Plus Subscription");
237 assert.deepStrictEqual(
238 status.models.some((model) => model.slug === "gpt-5.3-codex-spark"),
239 false,
240 );
241 }).pipe(
242 Effect.provide(
243 mockSpawnerLayer((args) => {
244 const joined = args.join(" ");
245 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
246 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
247 throw new Error(`Unexpected args: ${joined}`);
248 }),
249 ),
250 ),
251 );
252
253 it.effect("hides spark from codex models for non-pro chatgpt subscriptions", () =>
254 Effect.gen(function* () {
255 yield* withTempCodexHome();
256 const status = yield* checkCodexProviderStatus(() =>
257 Effect.succeed({
258 type: "chatgpt" as const,
259 planType: "team" as const,
260 sparkEnabled: false,
261 }),
262 );
263
264 assert.strictEqual(status.provider, "codex");
265 assert.strictEqual(status.auth.type, "team");
266 assert.strictEqual(status.auth.label, "ChatGPT Team Subscription");
267 assert.deepStrictEqual(
268 status.models.some((model) => model.slug === "gpt-5.3-codex-spark"),
269 false,
270 );
271 }).pipe(
272 Effect.provide(
273 mockSpawnerLayer((args) => {
274 const joined = args.join(" ");
275 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
276 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
277 throw new Error(`Unexpected args: ${joined}`);
278 }),
279 ),
280 ),
281 );
282
283 it.effect("returns an api key label for codex api key auth", () =>
284 Effect.gen(function* () {
285 yield* withTempCodexHome();
286 const status = yield* checkCodexProviderStatus(() =>
287 Effect.succeed({
288 type: "apiKey" as const,
289 planType: null,
290 sparkEnabled: false,
291 }),
292 );
293
294 assert.strictEqual(status.provider, "codex");
295 assert.strictEqual(status.status, "ready");
296 assert.strictEqual(status.auth.status, "authenticated");
297 assert.strictEqual(status.auth.type, "apiKey");
298 assert.strictEqual(status.auth.label, "OpenAI API Key");
299 assert.deepStrictEqual(
300 status.models.some((model) => model.slug === "gpt-5.3-codex-spark"),
301 false,
302 );
177303 }).pipe(
178304 Effect.provide(
179305 mockSpawnerLayer((args) => {
......@@ -235,7 +361,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
235361 assert.strictEqual(status.provider, "codex");
236362 assert.strictEqual(status.installed, true);
237363 assert.strictEqual(status.status, "ready");
238 assert.strictEqual(status.authStatus, "authenticated");
364 assert.strictEqual(status.auth.status, "authenticated");
239365 } finally {
240366 process.env.PATH = previousPath;
241367 }
......@@ -249,7 +375,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
249375 assert.strictEqual(status.provider, "codex");
250376 assert.strictEqual(status.status, "error");
251377 assert.strictEqual(status.installed, false);
252 assert.strictEqual(status.authStatus, "unknown");
378 assert.strictEqual(status.auth.status, "unknown");
253379 assert.strictEqual(
254380 status.message,
255381 "Codex CLI (`codex`) is not installed or not on PATH.",
......@@ -264,7 +390,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
264390 assert.strictEqual(status.provider, "codex");
265391 assert.strictEqual(status.status, "error");
266392 assert.strictEqual(status.installed, true);
267 assert.strictEqual(status.authStatus, "unknown");
393 assert.strictEqual(status.auth.status, "unknown");
268394 assert.strictEqual(
269395 status.message,
270396 "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()))(
287413 assert.strictEqual(status.provider, "codex");
288414 assert.strictEqual(status.status, "error");
289415 assert.strictEqual(status.installed, true);
290 assert.strictEqual(status.authStatus, "unauthenticated");
416 assert.strictEqual(status.auth.status, "unauthenticated");
291417 assert.strictEqual(
292418 status.message,
293419 "Codex CLI is not authenticated. Run `codex login` and try again.",
......@@ -313,7 +439,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
313439 assert.strictEqual(status.provider, "codex");
314440 assert.strictEqual(status.status, "error");
315441 assert.strictEqual(status.installed, true);
316 assert.strictEqual(status.authStatus, "unauthenticated");
442 assert.strictEqual(status.auth.status, "unauthenticated");
317443 assert.strictEqual(
318444 status.message,
319445 "Codex CLI is not authenticated. Run `codex login` and try again.",
......@@ -338,7 +464,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
338464 assert.strictEqual(status.provider, "codex");
339465 assert.strictEqual(status.status, "warning");
340466 assert.strictEqual(status.installed, true);
341 assert.strictEqual(status.authStatus, "unknown");
467 assert.strictEqual(status.auth.status, "unknown");
342468 assert.strictEqual(
343469 status.message,
344470 "Codex CLI authentication status command is unavailable in this Codex version.",
......@@ -366,7 +492,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
366492 status: "ready",
367493 enabled: true,
368494 installed: true,
369 authStatus: "authenticated",
495 auth: { status: "authenticated" },
370496 checkedAt: "2026-03-25T00:00:00.000Z",
371497 version: "1.0.0",
372498 models: [],
......@@ -376,7 +502,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
376502 status: "warning",
377503 enabled: true,
378504 installed: true,
379 authStatus: "unknown",
505 auth: { status: "unknown" },
380506 checkedAt: "2026-03-25T00:00:00.000Z",
381507 version: "1.0.0",
382508 models: [],
......@@ -494,7 +620,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
494620 assert.strictEqual(status.provider, "codex");
495621 assert.strictEqual(status.status, "ready");
496622 assert.strictEqual(status.installed, true);
497 assert.strictEqual(status.authStatus, "unknown");
623 assert.strictEqual(status.auth.status, "unknown");
498624 assert.strictEqual(
499625 status.message,
500626 "Using a custom Codex model provider; OpenAI login check skipped.",
......@@ -537,7 +663,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
537663 const status = yield* checkCodexProviderStatus();
538664 // The auth probe runs and sees "not logged in" → error
539665 assert.strictEqual(status.status, "error");
540 assert.strictEqual(status.authStatus, "unauthenticated");
666 assert.strictEqual(status.auth.status, "unauthenticated");
541667 }).pipe(
542668 Effect.provide(
543669 mockSpawnerLayer((args) => {
......@@ -558,7 +684,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
558684 it("exit code 0 with no auth markers is ready", () => {
559685 const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });
560686 assert.strictEqual(parsed.status, "ready");
561 assert.strictEqual(parsed.authStatus, "authenticated");
687 assert.strictEqual(parsed.auth.status, "authenticated");
562688 });
563689
564690 it("JSON with authenticated=false is unauthenticated", () => {
......@@ -568,7 +694,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
568694 code: 0,
569695 });
570696 assert.strictEqual(parsed.status, "error");
571 assert.strictEqual(parsed.authStatus, "unauthenticated");
697 assert.strictEqual(parsed.auth.status, "unauthenticated");
572698 });
573699
574700 it("JSON without auth marker is warning", () => {
......@@ -578,7 +704,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
578704 code: 0,
579705 });
580706 assert.strictEqual(parsed.status, "warning");
581 assert.strictEqual(parsed.authStatus, "unknown");
707 assert.strictEqual(parsed.auth.status, "unknown");
582708 });
583709 });
584710
......@@ -715,7 +841,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
715841 assert.strictEqual(status.provider, "claudeAgent");
716842 assert.strictEqual(status.status, "ready");
717843 assert.strictEqual(status.installed, true);
718 assert.strictEqual(status.authStatus, "authenticated");
844 assert.strictEqual(status.auth.status, "authenticated");
719845 }).pipe(
720846 Effect.provide(
721847 mockSpawnerLayer((args) => {
......@@ -733,13 +859,63 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
733859 ),
734860 );
735861
862 it.effect("returns a display label for claude subscription types", () =>
863 Effect.gen(function* () {
864 const status = yield* checkClaudeProviderStatus(() => Effect.succeed("maxplan"));
865 assert.strictEqual(status.provider, "claudeAgent");
866 assert.strictEqual(status.status, "ready");
867 assert.strictEqual(status.auth.status, "authenticated");
868 assert.strictEqual(status.auth.type, "maxplan");
869 assert.strictEqual(status.auth.label, "Claude Max Subscription");
870 }).pipe(
871 Effect.provide(
872 mockSpawnerLayer((args) => {
873 const joined = args.join(" ");
874 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
875 if (joined === "auth status")
876 return {
877 stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
878 stderr: "",
879 code: 0,
880 };
881 throw new Error(`Unexpected args: ${joined}`);
882 }),
883 ),
884 ),
885 );
886
887 it.effect("returns an api key label for claude api key auth", () =>
888 Effect.gen(function* () {
889 const status = yield* checkClaudeProviderStatus();
890 assert.strictEqual(status.provider, "claudeAgent");
891 assert.strictEqual(status.status, "ready");
892 assert.strictEqual(status.auth.status, "authenticated");
893 assert.strictEqual(status.auth.type, "apiKey");
894 assert.strictEqual(status.auth.label, "Claude API Key");
895 }).pipe(
896 Effect.provide(
897 mockSpawnerLayer((args) => {
898 const joined = args.join(" ");
899 if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 };
900 if (joined === "auth status")
901 return {
902 stdout: '{"loggedIn":true,"authMethod":"api-key"}\n',
903 stderr: "",
904 code: 0,
905 };
906 throw new Error(`Unexpected args: ${joined}`);
907 }),
908 ),
909 ),
910 );
911
736912 it.effect("returns unavailable when claude is missing", () =>
737913 Effect.gen(function* () {
738914 const status = yield* checkClaudeProviderStatus();
739915 assert.strictEqual(status.provider, "claudeAgent");
740916 assert.strictEqual(status.status, "error");
741917 assert.strictEqual(status.installed, false);
742 assert.strictEqual(status.authStatus, "unknown");
918 assert.strictEqual(status.auth.status, "unknown");
743919 assert.strictEqual(
744920 status.message,
745921 "Claude Agent CLI (`claude`) is not installed or not on PATH.",
......@@ -771,7 +947,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
771947 assert.strictEqual(status.provider, "claudeAgent");
772948 assert.strictEqual(status.status, "error");
773949 assert.strictEqual(status.installed, true);
774 assert.strictEqual(status.authStatus, "unauthenticated");
950 assert.strictEqual(status.auth.status, "unauthenticated");
775951 assert.strictEqual(
776952 status.message,
777953 "Claude is not authenticated. Run `claude auth login` and try again.",
......@@ -799,7 +975,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
799975 assert.strictEqual(status.provider, "claudeAgent");
800976 assert.strictEqual(status.status, "error");
801977 assert.strictEqual(status.installed, true);
802 assert.strictEqual(status.authStatus, "unauthenticated");
978 assert.strictEqual(status.auth.status, "unauthenticated");
803979 }).pipe(
804980 Effect.provide(
805981 mockSpawnerLayer((args) => {
......@@ -819,7 +995,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
819995 assert.strictEqual(status.provider, "claudeAgent");
820996 assert.strictEqual(status.status, "warning");
821997 assert.strictEqual(status.installed, true);
822 assert.strictEqual(status.authStatus, "unknown");
998 assert.strictEqual(status.auth.status, "unknown");
823999 assert.strictEqual(
8241000 status.message,
8251001 "Claude Agent authentication status command is unavailable in this version of Claude.",
......@@ -844,7 +1020,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8441020 it("exit code 0 with no auth markers is ready", () => {
8451021 const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });
8461022 assert.strictEqual(parsed.status, "ready");
847 assert.strictEqual(parsed.authStatus, "authenticated");
1023 assert.strictEqual(parsed.auth.status, "authenticated");
8481024 });
8491025
8501026 it("JSON with loggedIn=true is authenticated", () => {
......@@ -854,7 +1030,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8541030 code: 0,
8551031 });
8561032 assert.strictEqual(parsed.status, "ready");
857 assert.strictEqual(parsed.authStatus, "authenticated");
1033 assert.strictEqual(parsed.auth.status, "authenticated");
8581034 });
8591035
8601036 it("JSON with loggedIn=false is unauthenticated", () => {
......@@ -864,7 +1040,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8641040 code: 0,
8651041 });
8661042 assert.strictEqual(parsed.status, "error");
867 assert.strictEqual(parsed.authStatus, "unauthenticated");
1043 assert.strictEqual(parsed.auth.status, "unauthenticated");
8681044 });
8691045
8701046 it("JSON without auth marker is warning", () => {
......@@ -874,7 +1050,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
8741050 code: 0,
8751051 });
8761052 assert.strictEqual(parsed.status, "warning");
877 assert.strictEqual(parsed.authStatus, "unknown");
1053 assert.strictEqual(parsed.auth.status, "unknown");
8781054 });
8791055 });
8801056 },
apps/server/src/provider/codexAccount.ts created+123
......@@ -0,0 +1,123 @@
1import type { ServerProviderModel } from "@t3tools/contracts";
2
3export type CodexPlanType =
4 | "free"
5 | "go"
6 | "plus"
7 | "pro"
8 | "team"
9 | "business"
10 | "enterprise"
11 | "edu"
12 | "unknown";
13
14export interface CodexAccountSnapshot {
15 readonly type: "apiKey" | "chatgpt" | "unknown";
16 readonly planType: CodexPlanType | null;
17 readonly sparkEnabled: boolean;
18}
19
20export const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
21export const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
22const CODEX_SPARK_ENABLED_PLAN_TYPES = new Set<CodexPlanType>(["pro"]);
23
24function asObject(value: unknown): Record<string, unknown> | undefined {
25 if (!value || typeof value !== "object") {
26 return undefined;
27 }
28 return value as Record<string, unknown>;
29}
30
31function asString(value: unknown): string | undefined {
32 return typeof value === "string" ? value : undefined;
33}
34
35export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot {
36 const record = asObject(response);
37 const account = asObject(record?.account) ?? record;
38 const accountType = asString(account?.type);
39
40 if (accountType === "apiKey") {
41 return {
42 type: "apiKey",
43 planType: null,
44 sparkEnabled: false,
45 };
46 }
47
48 if (accountType === "chatgpt") {
49 const planType = (account?.planType as CodexPlanType | null) ?? "unknown";
50 return {
51 type: "chatgpt",
52 planType,
53 sparkEnabled: CODEX_SPARK_ENABLED_PLAN_TYPES.has(planType),
54 };
55 }
56
57 return {
58 type: "unknown",
59 planType: null,
60 sparkEnabled: false,
61 };
62}
63
64export function codexAuthSubType(account: CodexAccountSnapshot | undefined): string | undefined {
65 if (account?.type === "apiKey") {
66 return "apiKey";
67 }
68
69 if (account?.type !== "chatgpt") {
70 return undefined;
71 }
72
73 return account.planType && account.planType !== "unknown" ? account.planType : "chatgpt";
74}
75
76export function codexAuthSubLabel(account: CodexAccountSnapshot | undefined): string | undefined {
77 switch (codexAuthSubType(account)) {
78 case "apiKey":
79 return "OpenAI API Key";
80 case "chatgpt":
81 return "ChatGPT Subscription";
82 case "free":
83 return "ChatGPT Free Subscription";
84 case "go":
85 return "ChatGPT Go Subscription";
86 case "plus":
87 return "ChatGPT Plus Subscription";
88 case "pro":
89 return "ChatGPT Pro Subscription";
90 case "team":
91 return "ChatGPT Team Subscription";
92 case "business":
93 return "ChatGPT Business Subscription";
94 case "enterprise":
95 return "ChatGPT Enterprise Subscription";
96 case "edu":
97 return "ChatGPT Edu Subscription";
98 default:
99 return undefined;
100 }
101}
102
103export function adjustCodexModelsForAccount(
104 baseModels: ReadonlyArray<ServerProviderModel>,
105 account: CodexAccountSnapshot | undefined,
106): ReadonlyArray<ServerProviderModel> {
107 if (account?.sparkEnabled !== false) {
108 return baseModels;
109 }
110
111 return baseModels.filter((model) => model.isCustom || model.slug !== CODEX_SPARK_MODEL);
112}
113
114export function resolveCodexModelForAccount(
115 model: string | undefined,
116 account: CodexAccountSnapshot,
117): string | undefined {
118 if (model !== CODEX_SPARK_MODEL || account.sparkEnabled) {
119 return model;
120 }
121
122 return CODEX_DEFAULT_MODEL;
123}
apps/server/src/provider/codexAppServer.ts created+154
......@@ -0,0 +1,154 @@
1import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
2import readline from "node:readline";
3import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./codexAccount";
4
5interface JsonRpcProbeResponse {
6 readonly id?: unknown;
7 readonly result?: unknown;
8 readonly error?: {
9 readonly message?: unknown;
10 };
11}
12
13function readErrorMessage(response: JsonRpcProbeResponse): string | undefined {
14 return typeof response.error?.message === "string" ? response.error.message : undefined;
15}
16
17export function buildCodexInitializeParams() {
18 return {
19 clientInfo: {
20 name: "t3code_desktop",
21 title: "T3 Code Desktop",
22 version: "0.1.0",
23 },
24 capabilities: {
25 experimentalApi: true,
26 },
27 } as const;
28}
29
30export function killCodexChildProcess(child: ChildProcessWithoutNullStreams): void {
31 if (process.platform === "win32" && child.pid !== undefined) {
32 try {
33 spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
34 return;
35 } catch {
36 // Fall through to direct kill when taskkill is unavailable.
37 }
38 }
39
40 child.kill();
41}
42
43export async function probeCodexAccount(input: {
44 readonly binaryPath: string;
45 readonly homePath?: string;
46 readonly signal?: AbortSignal;
47}): Promise<CodexAccountSnapshot> {
48 return await new Promise((resolve, reject) => {
49 const child = spawn(input.binaryPath, ["app-server"], {
50 env: {
51 ...process.env,
52 ...(input.homePath ? { CODEX_HOME: input.homePath } : {}),
53 },
54 stdio: ["pipe", "pipe", "pipe"],
55 shell: process.platform === "win32",
56 });
57 const output = readline.createInterface({ input: child.stdout });
58
59 let completed = false;
60
61 const cleanup = () => {
62 output.removeAllListeners();
63 output.close();
64 child.removeAllListeners();
65 if (!child.killed) {
66 killCodexChildProcess(child);
67 }
68 };
69
70 const finish = (callback: () => void) => {
71 if (completed) return;
72 completed = true;
73 cleanup();
74 callback();
75 };
76
77 const fail = (error: unknown) =>
78 finish(() =>
79 reject(
80 error instanceof Error
81 ? error
82 : new Error(`Codex account probe failed: ${String(error)}.`),
83 ),
84 );
85
86 if (input.signal?.aborted) {
87 fail(new Error("Codex account probe aborted."));
88 return;
89 }
90 input.signal?.addEventListener("abort", () => fail(new Error("Codex account probe aborted.")));
91
92 const writeMessage = (message: unknown) => {
93 if (!child.stdin.writable) {
94 fail(new Error("Cannot write to codex app-server stdin."));
95 return;
96 }
97
98 child.stdin.write(`${JSON.stringify(message)}\n`);
99 };
100
101 output.on("line", (line) => {
102 let parsed: unknown;
103 try {
104 parsed = JSON.parse(line);
105 } catch {
106 fail(new Error("Received invalid JSON from codex app-server during account probe."));
107 return;
108 }
109
110 if (!parsed || typeof parsed !== "object") {
111 return;
112 }
113
114 const response = parsed as JsonRpcProbeResponse;
115 if (response.id === 1) {
116 const errorMessage = readErrorMessage(response);
117 if (errorMessage) {
118 fail(new Error(`initialize failed: ${errorMessage}`));
119 return;
120 }
121
122 writeMessage({ method: "initialized" });
123 writeMessage({ id: 2, method: "account/read", params: {} });
124 return;
125 }
126
127 if (response.id === 2) {
128 const errorMessage = readErrorMessage(response);
129 if (errorMessage) {
130 fail(new Error(`account/read failed: ${errorMessage}`));
131 return;
132 }
133
134 finish(() => resolve(readCodexAccountSnapshot(response.result)));
135 }
136 });
137
138 child.once("error", fail);
139 child.once("exit", (code, signal) => {
140 if (completed) return;
141 fail(
142 new Error(
143 `codex app-server exited before probe completed (code=${code ?? "null"}, signal=${signal ?? "null"}).`,
144 ),
145 );
146 });
147
148 writeMessage({
149 id: 1,
150 method: "initialize",
151 params: buildCodexInitializeParams(),
152 });
153 });
154}
apps/server/src/provider/providerSnapshot.ts+3-3
......@@ -1,6 +1,6 @@
11import type {
22 ServerProvider,
3 ServerProviderAuthStatus,
3 ServerProviderAuth,
44 ServerProviderModel,
55 ServerProviderState,
66} from "@t3tools/contracts";
......@@ -21,7 +21,7 @@ export interface ProviderProbeResult {
2121 readonly installed: boolean;
2222 readonly version: string | null;
2323 readonly status: Exclude<ServerProviderState, "disabled">;
24 readonly authStatus: ServerProviderAuthStatus;
24 readonly auth: ServerProviderAuth;
2525 readonly message?: string;
2626}
2727
......@@ -137,7 +137,7 @@ export function buildServerProvider(input: {
137137 installed: input.probe.installed,
138138 version: input.probe.version,
139139 status: input.enabled ? input.probe.status : "disabled",
140 authStatus: input.probe.authStatus,
140 auth: input.probe.auth,
141141 checkedAt: input.checkedAt,
142142 ...(input.probe.message ? { message: input.probe.message } : {}),
143143 models: input.models,
apps/server/src/wsServer.test.ts+1-1
......@@ -74,7 +74,7 @@ const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [
7474 installed: true,
7575 version: "0.116.0",
7676 status: "ready",
77 authStatus: "authenticated",
77 auth: { status: "authenticated" },
7878 checkedAt: "2026-01-01T00:00:00.000Z",
7979 models: [],
8080 },
apps/web/src/components/ChatView.browser.tsx+1-1
......@@ -117,7 +117,7 @@ function createBaseServerConfig(): ServerConfig {
117117 installed: true,
118118 version: "0.116.0",
119119 status: "ready",
120 authStatus: "authenticated",
120 auth: { status: "authenticated" },
121121 checkedAt: NOW_ISO,
122122 models: [],
123123 },
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
......@@ -50,7 +50,7 @@ function createBaseServerConfig(): ServerConfig {
5050 installed: true,
5151 version: "0.116.0",
5252 status: "ready",
53 authStatus: "authenticated",
53 auth: { status: "authenticated" },
5454 checkedAt: NOW_ISO,
5555 models: [],
5656 },
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+102-2
......@@ -22,7 +22,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
2222 installed: true,
2323 version: "0.116.0",
2424 status: "ready",
25 authStatus: "authenticated",
25 auth: { status: "authenticated" },
2626 checkedAt: new Date().toISOString(),
2727 models: [
2828 {
......@@ -57,7 +57,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
5757 installed: true,
5858 version: "1.0.0",
5959 status: "ready",
60 authStatus: "authenticated",
60 auth: { status: "authenticated" },
6161 checkedAt: new Date().toISOString(),
6262 models: [
6363 {
......@@ -110,6 +110,19 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
110110 },
111111];
112112
113function buildCodexProvider(models: ServerProvider["models"]): ServerProvider {
114 return {
115 provider: "codex",
116 enabled: true,
117 installed: true,
118 version: "0.116.0",
119 status: "ready",
120 auth: { status: "authenticated" },
121 checkedAt: new Date().toISOString(),
122 models,
123 };
124}
125
113126async function mountPicker(props: {
114127 provider: ProviderKind;
115128 model: string;
......@@ -241,6 +254,93 @@ describe("ProviderModelPicker", () => {
241254 }
242255 });
243256
257 it("only shows codex spark when the server reports it for the account", async () => {
258 const providersWithoutSpark: ReadonlyArray<ServerProvider> = [
259 buildCodexProvider([
260 {
261 slug: "gpt-5.3-codex",
262 name: "GPT-5.3 Codex",
263 isCustom: false,
264 capabilities: {
265 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
266 supportsFastMode: true,
267 supportsThinkingToggle: false,
268 contextWindowOptions: [],
269 promptInjectedEffortLevels: [],
270 },
271 },
272 ]),
273 TEST_PROVIDERS[1]!,
274 ];
275 const providersWithSpark: ReadonlyArray<ServerProvider> = [
276 buildCodexProvider([
277 {
278 slug: "gpt-5.3-codex",
279 name: "GPT-5.3 Codex",
280 isCustom: false,
281 capabilities: {
282 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
283 supportsFastMode: true,
284 supportsThinkingToggle: false,
285 contextWindowOptions: [],
286 promptInjectedEffortLevels: [],
287 },
288 },
289 {
290 slug: "gpt-5.3-codex-spark",
291 name: "GPT-5.3 Codex Spark",
292 isCustom: false,
293 capabilities: {
294 reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")],
295 supportsFastMode: true,
296 supportsThinkingToggle: false,
297 contextWindowOptions: [],
298 promptInjectedEffortLevels: [],
299 },
300 },
301 ]),
302 TEST_PROVIDERS[1]!,
303 ];
304
305 const hidden = await mountPicker({
306 provider: "claudeAgent",
307 model: "claude-opus-4-6",
308 lockedProvider: null,
309 providers: providersWithoutSpark,
310 });
311
312 try {
313 await page.getByRole("button").click();
314 await page.getByRole("menuitem", { name: "Codex" }).hover();
315
316 await vi.waitFor(() => {
317 const text = document.body.textContent ?? "";
318 expect(text).toContain("GPT-5.3 Codex");
319 expect(text).not.toContain("GPT-5.3 Codex Spark");
320 });
321 } finally {
322 await hidden.cleanup();
323 }
324
325 const visible = await mountPicker({
326 provider: "claudeAgent",
327 model: "claude-opus-4-6",
328 lockedProvider: null,
329 providers: providersWithSpark,
330 });
331
332 try {
333 await page.getByRole("button").click();
334 await page.getByRole("menuitem", { name: "Codex" }).hover();
335
336 await vi.waitFor(() => {
337 expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark");
338 });
339 } finally {
340 await visible.cleanup();
341 }
342 });
343
244344 it("dispatches the canonical slug when a model is selected", async () => {
245345 const mounted = await mountPicker({
246346 provider: "claudeAgent",
apps/web/src/components/chat/TraitsPicker.browser.tsx+2-2
......@@ -35,7 +35,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
3535 installed: true,
3636 version: "0.1.0",
3737 status: "ready",
38 authStatus: "authenticated",
38 auth: { status: "authenticated" },
3939 checkedAt: "2026-01-01T00:00:00.000Z",
4040 models: [
4141 {
......@@ -61,7 +61,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
6161 installed: true,
6262 version: "0.1.0",
6363 status: "ready",
64 authStatus: "authenticated",
64 auth: { status: "authenticated" },
6565 checkedAt: "2026-01-01T00:00:00.000Z",
6666 models: [
6767 {
apps/web/src/components/settings/SettingsPanels.tsx+4-3
......@@ -146,13 +146,14 @@ function getProviderSummary(provider: ServerProvider | undefined) {
146146 detail: provider.message ?? "CLI not detected on PATH.",
147147 };
148148 }
149 if (provider.authStatus === "authenticated") {
149 if (provider.auth.status === "authenticated") {
150 const authLabel = provider.auth.label ?? provider.auth.type;
150151 return {
151 headline: "Authenticated",
152 headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated",
152153 detail: provider.message ?? null,
153154 };
154155 }
155 if (provider.authStatus === "unauthenticated") {
156 if (provider.auth.status === "unauthenticated") {
156157 return {
157158 headline: "Not authenticated",
158159 detail: provider.message ?? null,
apps/web/src/wsNativeApi.test.ts+1-1
......@@ -99,7 +99,7 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
9999 installed: true,
100100 version: "0.116.0",
101101 status: "ready",
102 authStatus: "authenticated",
102 auth: { status: "authenticated" },
103103 checkedAt: "2026-01-01T00:00:00.000Z",
104104 models: [],
105105 },
packages/contracts/src/server.ts+8-1
......@@ -35,6 +35,13 @@ export const ServerProviderAuthStatus = Schema.Literals([
3535]);
3636export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type;
3737
38export const ServerProviderAuth = Schema.Struct({
39 status: ServerProviderAuthStatus,
40 type: Schema.optional(TrimmedNonEmptyString),
41 label: Schema.optional(TrimmedNonEmptyString),
42});
43export type ServerProviderAuth = typeof ServerProviderAuth.Type;
44
3845export const ServerProviderModel = Schema.Struct({
3946 slug: TrimmedNonEmptyString,
4047 name: TrimmedNonEmptyString,
......@@ -49,7 +56,7 @@ export const ServerProvider = Schema.Struct({
4956 installed: Schema.Boolean,
5057 version: Schema.NullOr(TrimmedNonEmptyString),
5158 status: ServerProviderState,
52 authStatus: ServerProviderAuthStatus,
59 auth: ServerProviderAuth,
5360 checkedAt: IsoDateTime,
5461 message: Schema.optional(TrimmedNonEmptyString),
5562 models: Schema.Array(ServerProviderModel),