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", () => {...@@ -310,7 +310,7 @@ describe("readCodexAccountSnapshot", () => {
310 });310 });
311 });311 });
312312
313 it("keeps spark enabled for api key accounts", () => {313 it("disables spark for api key accounts", () => {
314 expect(314 expect(
315 readCodexAccountSnapshot({315 readCodexAccountSnapshot({
316 type: "apiKey",316 type: "apiKey",
...@@ -318,7 +318,20 @@ describe("readCodexAccountSnapshot", () => {...@@ -318,7 +318,20 @@ describe("readCodexAccountSnapshot", () => {
318 ).toEqual({318 ).toEqual({
319 type: "apiKey",319 type: "apiKey",
320 planType: null,320 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,
322 });335 });
323 });336 });
324});337});
...@@ -343,6 +356,16 @@ describe("resolveCodexModelForAccount", () => {...@@ -343,6 +356,16 @@ describe("resolveCodexModelForAccount", () => {
343 }),356 }),
344 ).toBe("gpt-5.3-codex-spark");357 ).toBe("gpt-5.3-codex-spark");
345 });358 });
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 });
346});369});
347370
348describe("startSession", () => {371describe("startSession", () => {
apps/server/src/codexAppServerManager.ts+10-94
...@@ -26,6 +26,15 @@ import {...@@ -26,6 +26,15 @@ import {
26 isCodexCliVersionSupported,26 isCodexCliVersionSupported,
27 parseCodexCliVersion,27 parseCodexCliVersion,
28} from "./provider/codexCliVersion";28} 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
30type PendingRequestKey = string;39type PendingRequestKey = string;
3140
...@@ -96,23 +105,6 @@ interface JsonRpcNotification {...@@ -96,23 +105,6 @@ interface JsonRpcNotification {
96 params?: unknown;105 params?: unknown;
97}106}
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
116export interface CodexAppServerSendTurnInput {108export interface CodexAppServerSendTurnInput {
117 readonly threadId: ThreadId;109 readonly threadId: ThreadId;
118 readonly input?: string;110 readonly input?: string;
...@@ -162,50 +154,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [...@@ -162,50 +154,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
162 "unknown thread",154 "unknown thread",
163 "does not exist",155 "does not exist",
164];156];
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
209export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)157export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
210158
211You 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.159You 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): {...@@ -358,32 +306,13 @@ function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
358 };306 };
359}307}
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
372/**309/**
373 * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`310 * On Windows with `shell: true`, `child.kill()` only terminates the `cmd.exe`
374 * wrapper, leaving the actual command running. Use `taskkill /T` to kill the311 * wrapper, leaving the actual command running. Use `taskkill /T` to kill the
375 * entire process tree instead.312 * entire process tree instead.
376 */313 */
377function killChildTree(child: ChildProcessWithoutNullStreams): void {314function killChildTree(child: ChildProcessWithoutNullStreams): void {
378 if (process.platform === "win32" && child.pid !== undefined) {315 killCodexChildProcess(child);
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();
387}316}
388317
389export function normalizeCodexModelSlug(318export function normalizeCodexModelSlug(
...@@ -402,19 +331,6 @@ export function normalizeCodexModelSlug(...@@ -402,19 +331,6 @@ export function normalizeCodexModelSlug(
402 return normalized;331 return normalized;
403}332}
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
418function buildCodexCollaborationMode(input: {334function buildCodexCollaborationMode(input: {
419 readonly interactionMode?: "default" | "plan";335 readonly interactionMode?: "default" | "plan";
420 readonly model?: string;336 readonly model?: string;
apps/server/src/provider/Layers/ClaudeProvider.ts+433-150
...@@ -4,12 +4,14 @@ import type {...@@ -4,12 +4,14 @@ import type {
4 ModelCapabilities,4 ModelCapabilities,
5 ServerProvider,5 ServerProvider,
6 ServerProviderModel,6 ServerProviderModel,
7 ServerProviderAuthStatus,7 ServerProviderAuth,
8 ServerProviderState,8 ServerProviderState,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { Effect, Equal, Layer, Option, Result, Stream } from "effect";10import { Cache, Duration, Effect, Equal, Layer, Option, Result, Schema, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";12import { resolveContextWindow, resolveEffort } from "@t3tools/shared/model";
13import { decodeJsonResult } from "@t3tools/shared/schemaJson";
14import { query as claudeQuery } from "@anthropic-ai/claude-agent-sdk";
1315
14import {16import {
15 buildServerProvider,17 buildServerProvider,
...@@ -43,8 +45,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -43,8 +45,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
43 supportsFastMode: true,45 supportsFastMode: true,
44 supportsThinkingToggle: false,46 supportsThinkingToggle: false,
45 contextWindowOptions: [47 contextWindowOptions: [
46 { value: "200k", label: "200k" },48 { value: "200k", label: "200k", isDefault: true },
47 { value: "1m", label: "1M", isDefault: true },49 { value: "1m", label: "1M" },
48 ],50 ],
49 promptInjectedEffortLevels: ["ultrathink"],51 promptInjectedEffortLevels: ["ultrathink"],
50 } satisfies ModelCapabilities,52 } satisfies ModelCapabilities,
...@@ -63,8 +65,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [...@@ -63,8 +65,8 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
63 supportsFastMode: false,65 supportsFastMode: false,
64 supportsThinkingToggle: false,66 supportsThinkingToggle: false,
65 contextWindowOptions: [67 contextWindowOptions: [
66 { value: "200k", label: "200k" },68 { value: "200k", label: "200k", isDefault: true },
67 { value: "1m", label: "1M", isDefault: true },69 { value: "1m", label: "1M" },
68 ],70 ],
69 promptInjectedEffortLevels: ["ultrathink"],71 promptInjectedEffortLevels: ["ultrathink"],
70 } satisfies ModelCapabilities,72 } satisfies ModelCapabilities,
...@@ -117,7 +119,7 @@ export function normalizeClaudeModelOptions(...@@ -117,7 +119,7 @@ export function normalizeClaudeModelOptions(
117119
118export function parseClaudeAuthStatusFromOutput(result: CommandResult): {120export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
119 readonly status: Exclude<ServerProviderState, "disabled">;121 readonly status: Exclude<ServerProviderState, "disabled">;
120 readonly authStatus: ServerProviderAuthStatus;122 readonly auth: Pick<ServerProviderAuth, "status">;
121 readonly message?: string;123 readonly message?: string;
122} {124} {
123 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();125 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
...@@ -129,7 +131,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {...@@ -129,7 +131,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
129 ) {131 ) {
130 return {132 return {
131 status: "warning",133 status: "warning",
132 authStatus: "unknown",134 auth: { status: "unknown" },
133 message:135 message:
134 "Claude Agent authentication status command is unavailable in this version of Claude.",136 "Claude Agent authentication status command is unavailable in this version of Claude.",
135 };137 };
...@@ -144,7 +146,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {...@@ -144,7 +146,7 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
144 ) {146 ) {
145 return {147 return {
146 status: "error",148 status: "error",
147 authStatus: "unauthenticated",149 auth: { status: "unauthenticated" },
148 message: "Claude is not authenticated. Run `claude auth login` and try again.",150 message: "Claude is not authenticated. Run `claude auth login` and try again.",
149 };151 };
150 }152 }
...@@ -165,37 +167,286 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {...@@ -165,37 +167,286 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
165 })();167 })();
166168
167 if (parsedAuth.auth === true) {169 if (parsedAuth.auth === true) {
168 return { status: "ready", authStatus: "authenticated" };170 return { status: "ready", auth: { status: "authenticated" } };
169 }171 }
170 if (parsedAuth.auth === false) {172 if (parsedAuth.auth === false) {
171 return {173 return {
172 status: "error",174 status: "error",
173 authStatus: "unauthenticated",175 auth: { status: "unauthenticated" },
174 message: "Claude is not authenticated. Run `claude auth login` and try again.",176 message: "Claude is not authenticated. Run `claude auth login` and try again.",
175 };177 };
176 }178 }
177 if (parsedAuth.attemptedJsonParse) {179 if (parsedAuth.attemptedJsonParse) {
178 return {180 return {
179 status: "warning",181 status: "warning",
180 authStatus: "unknown",182 auth: { status: "unknown" },
181 message:183 message:
182 "Could not verify Claude authentication status from JSON output (missing auth marker).",184 "Could not verify Claude authentication status from JSON output (missing auth marker).",
183 };185 };
184 }186 }
185 if (result.code === 0) {187 if (result.code === 0) {
186 return { status: "ready", authStatus: "authenticated" };188 return { status: "ready", auth: { status: "authenticated" } };
187 }189 }
188190
189 const detail = detailFromResult(result);191 const detail = detailFromResult(result);
190 return {192 return {
191 status: "warning",193 status: "warning",
192 authStatus: "unknown",194 auth: { status: "unknown" },
193 message: detail195 message: detail
194 ? `Could not verify Claude authentication status. ${detail}`196 ? `Could not verify Claude authentication status. ${detail}`
195 : "Could not verify Claude authentication status.",197 : "Could not verify Claude authentication status.",
196 };198 };
197}199}
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
199const runClaudeCommand = (args: ReadonlyArray<string>) =>450const runClaudeCommand = (args: ReadonlyArray<string>) =>
200 Effect.gen(function* () {451 Effect.gen(function* () {
201 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(452 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
...@@ -208,159 +459,182 @@ const runClaudeCommand = (args: ReadonlyArray<string>) =>...@@ -208,159 +459,182 @@ const runClaudeCommand = (args: ReadonlyArray<string>) =>
208 return yield* spawnAndCollect(claudeSettings.binaryPath, command);459 return yield* spawnAndCollect(claudeSettings.binaryPath, command);
209 });460 });
210461
211export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(462export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(function* (
212 function* (): Effect.fn.Return<463 resolveSubscriptionType?: (binaryPath: string) => Effect.Effect<string | undefined>,
213 ServerProvider,464): Effect.fn.Return<
214 ServerSettingsError,465 ServerProvider,
215 ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService466 ServerSettingsError,
216 > {467 ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService
217 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(468> {
218 Effect.flatMap((service) => service.getSettings),469 const claudeSettings = yield* Effect.service(ServerSettingsService).pipe(
219 Effect.map((settings) => settings.providers.claudeAgent),470 Effect.flatMap((service) => service.getSettings),
220 );471 Effect.map((settings) => settings.providers.claudeAgent),
221 const checkedAt = new Date().toISOString();472 );
222 const models = providerModelsFromSettings(473 const checkedAt = new Date().toISOString();
223 BUILT_IN_MODELS,474 const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, claudeSettings.customModels);
224 PROVIDER,
225 claudeSettings.customModels,
226 );
227475
228 if (!claudeSettings.enabled) {476 if (!claudeSettings.enabled) {
229 return buildServerProvider({477 return buildServerProvider({
230 provider: PROVIDER,478 provider: PROVIDER,
231 enabled: false,479 enabled: false,
232 checkedAt,480 checkedAt,
233 models,481 models,
234 probe: {482 probe: {
235 installed: false,483 installed: false,
236 version: null,484 version: null,
237 status: "warning",485 status: "warning",
238 authStatus: "unknown",486 auth: { status: "unknown" },
239 message: "Claude is disabled in T3 Code settings.",487 message: "Claude is disabled in T3 Code settings.",
240 },488 },
241 });489 });
242 }490 }
243491
244 const versionProbe = yield* runClaudeCommand(["--version"]).pipe(492 const versionProbe = yield* runClaudeCommand(["--version"]).pipe(
245 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),493 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
246 Effect.result,494 Effect.result,
247 );495 );
248496
249 if (Result.isFailure(versionProbe)) {497 if (Result.isFailure(versionProbe)) {
250 const error = versionProbe.failure;498 const error = versionProbe.failure;
251 return buildServerProvider({499 return buildServerProvider({
252 provider: PROVIDER,500 provider: PROVIDER,
253 enabled: claudeSettings.enabled,501 enabled: claudeSettings.enabled,
254 checkedAt,502 checkedAt,
255 models,503 models,
256 probe: {504 probe: {
257 installed: !isCommandMissingCause(error),505 installed: !isCommandMissingCause(error),
258 version: null,506 version: null,
259 status: "error",507 status: "error",
260 authStatus: "unknown",508 auth: { status: "unknown" },
261 message: isCommandMissingCause(error)509 message: isCommandMissingCause(error)
262 ? "Claude Agent CLI (`claude`) is not installed or not on PATH."510 ? "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)}.`,511 : `Failed to execute Claude Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
264 },512 },
265 });513 });
266 }514 }
267515
268 if (Option.isNone(versionProbe.success)) {516 if (Option.isNone(versionProbe.success)) {
269 return buildServerProvider({517 return buildServerProvider({
270 provider: PROVIDER,518 provider: PROVIDER,
271 enabled: claudeSettings.enabled,519 enabled: claudeSettings.enabled,
272 checkedAt,520 checkedAt,
273 models,521 models,
274 probe: {522 probe: {
275 installed: true,523 installed: true,
276 version: null,524 version: null,
277 status: "error",525 status: "error",
278 authStatus: "unknown",526 auth: { status: "unknown" },
279 message:527 message:
280 "Claude Agent CLI is installed but failed to run. Timed out while running command.",528 "Claude Agent CLI is installed but failed to run. Timed out while running command.",
281 },529 },
282 });530 });
283 }531 }
284532
285 const version = versionProbe.success.value;533 const version = versionProbe.success.value;
286 const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);534 const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
287 if (version.code !== 0) {535 if (version.code !== 0) {
288 const detail = detailFromResult(version);536 const detail = detailFromResult(version);
289 return buildServerProvider({537 return buildServerProvider({
290 provider: PROVIDER,538 provider: PROVIDER,
291 enabled: claudeSettings.enabled,539 enabled: claudeSettings.enabled,
292 checkedAt,540 checkedAt,
293 models,541 models,
294 probe: {542 probe: {
295 installed: true,543 installed: true,
296 version: parsedVersion,544 version: parsedVersion,
297 status: "error",545 status: "error",
298 authStatus: "unknown",546 auth: { status: "unknown" },
299 message: detail547 message: detail
300 ? `Claude Agent CLI is installed but failed to run. ${detail}`548 ? `Claude Agent CLI is installed but failed to run. ${detail}`
301 : "Claude Agent CLI is installed but failed to run.",549 : "Claude Agent CLI is installed but failed to run.",
302 },550 },
303 });551 });
304 }552 }
305553
306 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(554 // ── Auth check + subscription detection ────────────────────────────
307 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
308 Effect.result,
309 );
310555
311 if (Result.isFailure(authProbe)) {556 const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
312 const error = authProbe.failure;557 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
313 return buildServerProvider({558 Effect.result,
314 provider: PROVIDER,559 );
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 }
330560
331 if (Option.isNone(authProbe.success)) {561 // Determine subscription type from multiple sources (cheapest first):
332 return buildServerProvider({562 // 1. `claude auth status` JSON output (may or may not contain it)
333 provider: PROVIDER,563 // 2. Cached SDK probe (spawns a Claude process on miss, reads
334 enabled: claudeSettings.enabled,564 // `initializationResult()` for account metadata, then aborts
335 checkedAt,565 // immediately — no API tokens are consumed)
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 }
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;
349 return buildServerProvider({585 return buildServerProvider({
350 provider: PROVIDER,586 provider: PROVIDER,
351 enabled: claudeSettings.enabled,587 enabled: claudeSettings.enabled,
352 checkedAt,588 checkedAt,
353 models,589 models: resolvedModels,
354 probe: {590 probe: {
355 installed: true,591 installed: true,
356 version: parsedVersion,592 version: parsedVersion,
357 status: parsed.status,593 status: "warning",
358 authStatus: parsed.authStatus,594 auth: { status: "unknown" },
359 ...(parsed.message ? { message: parsed.message } : {}),595 message:
596 error instanceof Error
597 ? `Could not verify Claude authentication status: ${error.message}.`
598 : "Could not verify Claude authentication status.",
360 },599 },
361 });600 });
362 },601 }
363);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
365export const ClaudeProviderLive = Layer.effect(639export const ClaudeProviderLive = Layer.effect(
366 ClaudeProvider,640 ClaudeProvider,
...@@ -368,7 +642,16 @@ export const ClaudeProviderLive = Layer.effect(...@@ -368,7 +642,16 @@ export const ClaudeProviderLive = Layer.effect(
368 const serverSettings = yield* ServerSettingsService;642 const serverSettings = yield* ServerSettingsService;
369 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;643 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(
372 Effect.provideService(ServerSettingsService, serverSettings),655 Effect.provideService(ServerSettingsService, serverSettings),
373 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),656 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
374 );657 );
apps/server/src/provider/Layers/CodexProvider.ts+238-180
...@@ -5,10 +5,21 @@ import type {...@@ -5,10 +5,21 @@ import type {
5 CodexSettings,5 CodexSettings,
6 ServerProvider,6 ServerProvider,
7 ServerProviderModel,7 ServerProviderModel,
8 ServerProviderAuthStatus,8 ServerProviderAuth,
9 ServerProviderState,9 ServerProviderState,
10} from "@t3tools/contracts";10} 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";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";23import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13import { resolveEffort } from "@t3tools/shared/model";24import { resolveEffort } from "@t3tools/shared/model";
1425
...@@ -29,6 +40,13 @@ import {...@@ -29,6 +40,13 @@ import {
29 isCodexCliVersionSupported,40 isCodexCliVersionSupported,
30 parseCodexCliVersion,41 parseCodexCliVersion,
31} from "../codexCliVersion";42} from "../codexCliVersion";
43import {
44 adjustCodexModelsForAccount,
45 codexAuthSubLabel,
46 codexAuthSubType,
47 type CodexAccountSnapshot,
48} from "../codexAccount";
49import { probeCodexAccount } from "../codexAppServer";
32import { CodexProvider } from "../Services/CodexProvider";50import { CodexProvider } from "../Services/CodexProvider";
33import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";51import { ServerSettingsError, ServerSettingsService } from "../../serverSettings";
3452
...@@ -170,7 +188,7 @@ export function normalizeCodexModelOptions(...@@ -170,7 +188,7 @@ export function normalizeCodexModelOptions(
170188
171export function parseAuthStatusFromOutput(result: CommandResult): {189export function parseAuthStatusFromOutput(result: CommandResult): {
172 readonly status: Exclude<ServerProviderState, "disabled">;190 readonly status: Exclude<ServerProviderState, "disabled">;
173 readonly authStatus: ServerProviderAuthStatus;191 readonly auth: Pick<ServerProviderAuth, "status">;
174 readonly message?: string;192 readonly message?: string;
175} {193} {
176 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();194 const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase();
...@@ -182,7 +200,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): {...@@ -182,7 +200,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
182 ) {200 ) {
183 return {201 return {
184 status: "warning",202 status: "warning",
185 authStatus: "unknown",203 auth: { status: "unknown" },
186 message: "Codex CLI authentication status command is unavailable in this Codex version.",204 message: "Codex CLI authentication status command is unavailable in this Codex version.",
187 };205 };
188 }206 }
...@@ -196,7 +214,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): {...@@ -196,7 +214,7 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
196 ) {214 ) {
197 return {215 return {
198 status: "error",216 status: "error",
199 authStatus: "unauthenticated",217 auth: { status: "unauthenticated" },
200 message: "Codex CLI is not authenticated. Run `codex login` and try again.",218 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
201 };219 };
202 }220 }
...@@ -217,31 +235,31 @@ export function parseAuthStatusFromOutput(result: CommandResult): {...@@ -217,31 +235,31 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
217 })();235 })();
218236
219 if (parsedAuth.auth === true) {237 if (parsedAuth.auth === true) {
220 return { status: "ready", authStatus: "authenticated" };238 return { status: "ready", auth: { status: "authenticated" } };
221 }239 }
222 if (parsedAuth.auth === false) {240 if (parsedAuth.auth === false) {
223 return {241 return {
224 status: "error",242 status: "error",
225 authStatus: "unauthenticated",243 auth: { status: "unauthenticated" },
226 message: "Codex CLI is not authenticated. Run `codex login` and try again.",244 message: "Codex CLI is not authenticated. Run `codex login` and try again.",
227 };245 };
228 }246 }
229 if (parsedAuth.attemptedJsonParse) {247 if (parsedAuth.attemptedJsonParse) {
230 return {248 return {
231 status: "warning",249 status: "warning",
232 authStatus: "unknown",250 auth: { status: "unknown" },
233 message:251 message:
234 "Could not verify Codex authentication status from JSON output (missing auth marker).",252 "Could not verify Codex authentication status from JSON output (missing auth marker).",
235 };253 };
236 }254 }
237 if (result.code === 0) {255 if (result.code === 0) {
238 return { status: "ready", authStatus: "authenticated" };256 return { status: "ready", auth: { status: "authenticated" } };
239 }257 }
240258
241 const detail = detailFromResult(result);259 const detail = detailFromResult(result);
242 return {260 return {
243 status: "warning",261 status: "warning",
244 authStatus: "unknown",262 auth: { status: "unknown" },
245 message: detail263 message: detail
246 ? `Could not verify Codex authentication status. ${detail}`264 ? `Could not verify Codex authentication status. ${detail}`
247 : "Could not verify Codex authentication status.",265 : "Could not verify Codex authentication status.",
...@@ -290,6 +308,21 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe(...@@ -290,6 +308,21 @@ export const hasCustomModelProvider = readCodexConfigModelProvider().pipe(
290 Effect.orElseSucceed(() => false),308 Effect.orElseSucceed(() => false),
291);309);
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
293const runCodexCommand = (args: ReadonlyArray<string>) =>326const runCodexCommand = (args: ReadonlyArray<string>) =>
294 Effect.gen(function* () {327 Effect.gen(function* () {
295 const settingsService = yield* ServerSettingsService;328 const settingsService = yield* ServerSettingsService;
...@@ -306,194 +339,206 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>...@@ -306,194 +339,206 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>
306 return yield* spawnAndCollect(codexSettings.binaryPath, command);339 return yield* spawnAndCollect(codexSettings.binaryPath, command);
307 });340 });
308341
309export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(342export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* (
310 function* (): Effect.fn.Return<343 resolveAccount?: (input: {
311 ServerProvider,344 readonly binaryPath: string;
312 ServerSettingsError,345 readonly homePath?: string;
313 | ChildProcessSpawner.ChildProcessSpawner346 }) => Effect.Effect<CodexAccountSnapshot | undefined>,
314 | FileSystem.FileSystem347): Effect.fn.Return<
315 | Path.Path348 ServerProvider,
316 | ServerSettingsService349 ServerSettingsError,
317 > {350 | ChildProcessSpawner.ChildProcessSpawner
318 const codexSettings = yield* Effect.service(ServerSettingsService).pipe(351 | FileSystem.FileSystem
319 Effect.flatMap((service) => service.getSettings),352 | Path.Path
320 Effect.map((settings) => settings.providers.codex),353 | ServerSettingsService
321 );354> {
322 const checkedAt = new Date().toISOString();355 const codexSettings = yield* Effect.service(ServerSettingsService).pipe(
323 const models = providerModelsFromSettings(356 Effect.flatMap((service) => service.getSettings),
324 BUILT_IN_MODELS,357 Effect.map((settings) => settings.providers.codex),
325 PROVIDER,358 );
326 codexSettings.customModels,359 const checkedAt = new Date().toISOString();
327 );360 const models = providerModelsFromSettings(BUILT_IN_MODELS, PROVIDER, codexSettings.customModels);
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 }
344361
345 const versionProbe = yield* runCodexCommand(["--version"]).pipe(362 if (!codexSettings.enabled) {
346 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),363 return buildServerProvider({
347 Effect.result,364 provider: PROVIDER,
348 );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)) {378 const versionProbe = yield* runCodexCommand(["--version"]).pipe(
351 const error = versionProbe.failure;379 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
352 return buildServerProvider({380 Effect.result,
353 provider: PROVIDER,381 );
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 }
368382
369 if (Option.isNone(versionProbe.success)) {383 if (Result.isFailure(versionProbe)) {
370 return buildServerProvider({384 const error = versionProbe.failure;
371 provider: PROVIDER,385 return buildServerProvider({
372 enabled: codexSettings.enabled,386 provider: PROVIDER,
373 checkedAt,387 enabled: codexSettings.enabled,
374 models,388 checkedAt,
375 probe: {389 models,
376 installed: true,390 probe: {
377 version: null,391 installed: !isCommandMissingCause(error),
378 status: "error",392 version: null,
379 authStatus: "unknown",393 status: "error",
380 message: "Codex CLI is installed but failed to run. Timed out while running command.",394 auth: { status: "unknown" },
381 },395 message: isCommandMissingCause(error)
382 });396 ? "Codex CLI (`codex`) is not installed or not on PATH."
383 }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;402 if (Option.isNone(versionProbe.success)) {
386 const parsedVersion =403 return buildServerProvider({
387 parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ??404 provider: PROVIDER,
388 parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);405 enabled: codexSettings.enabled,
389 if (version.code !== 0) {406 checkedAt,
390 const detail = detailFromResult(version);407 models,
391 return buildServerProvider({408 probe: {
392 provider: PROVIDER,409 installed: true,
393 enabled: codexSettings.enabled,410 version: null,
394 checkedAt,411 status: "error",
395 models,412 auth: { status: "unknown" },
396 probe: {413 message: "Codex CLI is installed but failed to run. Timed out while running command.",
397 installed: true,414 },
398 version: parsedVersion,415 });
399 status: "error",416 }
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 }
407417
408 if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {418 const version = versionProbe.success.value;
409 return buildServerProvider({419 const parsedVersion =
410 provider: PROVIDER,420 parseCodexCliVersion(`${version.stdout}\n${version.stderr}`) ??
411 enabled: codexSettings.enabled,421 parseGenericCliVersion(`${version.stdout}\n${version.stderr}`);
412 checkedAt,422 if (version.code !== 0) {
413 models,423 const detail = detailFromResult(version);
414 probe: {424 return buildServerProvider({
415 installed: true,425 provider: PROVIDER,
416 version: parsedVersion,426 enabled: codexSettings.enabled,
417 status: "error",427 checkedAt,
418 authStatus: "unknown",428 models,
419 message: formatCodexCliUpgradeMessage(parsedVersion),429 probe: {
420 },430 installed: true,
421 });431 version: parsedVersion,
422 }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) {441 if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {
425 return buildServerProvider({442 return buildServerProvider({
426 provider: PROVIDER,443 provider: PROVIDER,
427 enabled: codexSettings.enabled,444 enabled: codexSettings.enabled,
428 checkedAt,445 checkedAt,
429 models,446 models,
430 probe: {447 probe: {
431 installed: true,448 installed: true,
432 version: parsedVersion,449 version: parsedVersion,
433 status: "ready",450 status: "error",
434 authStatus: "unknown",451 auth: { status: "unknown" },
435 message: "Using a custom Codex model provider; OpenAI login check skipped.",452 message: formatCodexCliUpgradeMessage(parsedVersion),
436 },453 },
437 });454 });
438 }455 }
439456
440 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(457 if (yield* hasCustomModelProvider) {
441 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),458 return buildServerProvider({
442 Effect.result,459 provider: PROVIDER,
443 );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)) {473 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(
446 const error = authProbe.failure;474 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
447 return buildServerProvider({475 Effect.result,
448 provider: PROVIDER,476 );
449 enabled: codexSettings.enabled,477 const account = resolveAccount
450 checkedAt,478 ? yield* resolveAccount({
451 models,479 binaryPath: codexSettings.binaryPath,
452 probe: {480 homePath: codexSettings.homePath,
453 installed: true,481 })
454 version: parsedVersion,482 : undefined;
455 status: "warning",483 const resolvedModels = adjustCodexModelsForAccount(models, account);
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 }
464484
465 if (Option.isNone(authProbe.success)) {485 if (Result.isFailure(authProbe)) {
466 return buildServerProvider({486 const error = authProbe.failure;
467 provider: PROVIDER,487 return buildServerProvider({
468 enabled: codexSettings.enabled,488 provider: PROVIDER,
469 checkedAt,489 enabled: codexSettings.enabled,
470 models,490 checkedAt,
471 probe: {491 models: resolvedModels,
472 installed: true,492 probe: {
473 version: parsedVersion,493 installed: true,
474 status: "warning",494 version: parsedVersion,
475 authStatus: "unknown",495 status: "warning",
476 message: "Could not verify Codex authentication status. Timed out while running command.",496 auth: { status: "unknown" },
477 },497 message:
478 });498 error instanceof Error
479 }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)) {
482 return buildServerProvider({506 return buildServerProvider({
483 provider: PROVIDER,507 provider: PROVIDER,
484 enabled: codexSettings.enabled,508 enabled: codexSettings.enabled,
485 checkedAt,509 checkedAt,
486 models,510 models: resolvedModels,
487 probe: {511 probe: {
488 installed: true,512 installed: true,
489 version: parsedVersion,513 version: parsedVersion,
490 status: parsed.status,514 status: "warning",
491 authStatus: parsed.authStatus,515 auth: { status: "unknown" },
492 ...(parsed.message ? { message: parsed.message } : {}),516 message: "Could not verify Codex authentication status. Timed out while running command.",
493 },517 },
494 });518 });
495 },519 }
496);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
498export const CodexProviderLive = Layer.effect(543export const CodexProviderLive = Layer.effect(
499 CodexProvider,544 CodexProvider,
...@@ -502,8 +547,21 @@ export const CodexProviderLive = Layer.effect(...@@ -502,8 +547,21 @@ export const CodexProviderLive = Layer.effect(
502 const fileSystem = yield* FileSystem.FileSystem;547 const fileSystem = yield* FileSystem.FileSystem;
503 const path = yield* Path.Path;548 const path = yield* Path.Path;
504 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;549 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(
507 Effect.provideService(ServerSettingsService, serverSettings),565 Effect.provideService(ServerSettingsService, serverSettings),
508 Effect.provideService(FileSystem.FileSystem, fileSystem),566 Effect.provideService(FileSystem.FileSystem, fileSystem),
509 Effect.provideService(Path.Path, path),567 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()))(...@@ -173,7 +173,133 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
173 assert.strictEqual(status.provider, "codex");173 assert.strictEqual(status.provider, "codex");
174 assert.strictEqual(status.status, "ready");174 assert.strictEqual(status.status, "ready");
175 assert.strictEqual(status.installed, true);175 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 );
177 }).pipe(303 }).pipe(
178 Effect.provide(304 Effect.provide(
179 mockSpawnerLayer((args) => {305 mockSpawnerLayer((args) => {
...@@ -235,7 +361,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -235,7 +361,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
235 assert.strictEqual(status.provider, "codex");361 assert.strictEqual(status.provider, "codex");
236 assert.strictEqual(status.installed, true);362 assert.strictEqual(status.installed, true);
237 assert.strictEqual(status.status, "ready");363 assert.strictEqual(status.status, "ready");
238 assert.strictEqual(status.authStatus, "authenticated");364 assert.strictEqual(status.auth.status, "authenticated");
239 } finally {365 } finally {
240 process.env.PATH = previousPath;366 process.env.PATH = previousPath;
241 }367 }
...@@ -249,7 +375,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -249,7 +375,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
249 assert.strictEqual(status.provider, "codex");375 assert.strictEqual(status.provider, "codex");
250 assert.strictEqual(status.status, "error");376 assert.strictEqual(status.status, "error");
251 assert.strictEqual(status.installed, false);377 assert.strictEqual(status.installed, false);
252 assert.strictEqual(status.authStatus, "unknown");378 assert.strictEqual(status.auth.status, "unknown");
253 assert.strictEqual(379 assert.strictEqual(
254 status.message,380 status.message,
255 "Codex CLI (`codex`) is not installed or not on PATH.",381 "Codex CLI (`codex`) is not installed or not on PATH.",
...@@ -264,7 +390,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -264,7 +390,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
264 assert.strictEqual(status.provider, "codex");390 assert.strictEqual(status.provider, "codex");
265 assert.strictEqual(status.status, "error");391 assert.strictEqual(status.status, "error");
266 assert.strictEqual(status.installed, true);392 assert.strictEqual(status.installed, true);
267 assert.strictEqual(status.authStatus, "unknown");393 assert.strictEqual(status.auth.status, "unknown");
268 assert.strictEqual(394 assert.strictEqual(
269 status.message,395 status.message,
270 "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.",396 "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()))(...@@ -287,7 +413,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
287 assert.strictEqual(status.provider, "codex");413 assert.strictEqual(status.provider, "codex");
288 assert.strictEqual(status.status, "error");414 assert.strictEqual(status.status, "error");
289 assert.strictEqual(status.installed, true);415 assert.strictEqual(status.installed, true);
290 assert.strictEqual(status.authStatus, "unauthenticated");416 assert.strictEqual(status.auth.status, "unauthenticated");
291 assert.strictEqual(417 assert.strictEqual(
292 status.message,418 status.message,
293 "Codex CLI is not authenticated. Run `codex login` and try again.",419 "Codex CLI is not authenticated. Run `codex login` and try again.",
...@@ -313,7 +439,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -313,7 +439,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
313 assert.strictEqual(status.provider, "codex");439 assert.strictEqual(status.provider, "codex");
314 assert.strictEqual(status.status, "error");440 assert.strictEqual(status.status, "error");
315 assert.strictEqual(status.installed, true);441 assert.strictEqual(status.installed, true);
316 assert.strictEqual(status.authStatus, "unauthenticated");442 assert.strictEqual(status.auth.status, "unauthenticated");
317 assert.strictEqual(443 assert.strictEqual(
318 status.message,444 status.message,
319 "Codex CLI is not authenticated. Run `codex login` and try again.",445 "Codex CLI is not authenticated. Run `codex login` and try again.",
...@@ -338,7 +464,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -338,7 +464,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
338 assert.strictEqual(status.provider, "codex");464 assert.strictEqual(status.provider, "codex");
339 assert.strictEqual(status.status, "warning");465 assert.strictEqual(status.status, "warning");
340 assert.strictEqual(status.installed, true);466 assert.strictEqual(status.installed, true);
341 assert.strictEqual(status.authStatus, "unknown");467 assert.strictEqual(status.auth.status, "unknown");
342 assert.strictEqual(468 assert.strictEqual(
343 status.message,469 status.message,
344 "Codex CLI authentication status command is unavailable in this Codex version.",470 "Codex CLI authentication status command is unavailable in this Codex version.",
...@@ -366,7 +492,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -366,7 +492,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
366 status: "ready",492 status: "ready",
367 enabled: true,493 enabled: true,
368 installed: true,494 installed: true,
369 authStatus: "authenticated",495 auth: { status: "authenticated" },
370 checkedAt: "2026-03-25T00:00:00.000Z",496 checkedAt: "2026-03-25T00:00:00.000Z",
371 version: "1.0.0",497 version: "1.0.0",
372 models: [],498 models: [],
...@@ -376,7 +502,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -376,7 +502,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
376 status: "warning",502 status: "warning",
377 enabled: true,503 enabled: true,
378 installed: true,504 installed: true,
379 authStatus: "unknown",505 auth: { status: "unknown" },
380 checkedAt: "2026-03-25T00:00:00.000Z",506 checkedAt: "2026-03-25T00:00:00.000Z",
381 version: "1.0.0",507 version: "1.0.0",
382 models: [],508 models: [],
...@@ -494,7 +620,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -494,7 +620,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
494 assert.strictEqual(status.provider, "codex");620 assert.strictEqual(status.provider, "codex");
495 assert.strictEqual(status.status, "ready");621 assert.strictEqual(status.status, "ready");
496 assert.strictEqual(status.installed, true);622 assert.strictEqual(status.installed, true);
497 assert.strictEqual(status.authStatus, "unknown");623 assert.strictEqual(status.auth.status, "unknown");
498 assert.strictEqual(624 assert.strictEqual(
499 status.message,625 status.message,
500 "Using a custom Codex model provider; OpenAI login check skipped.",626 "Using a custom Codex model provider; OpenAI login check skipped.",
...@@ -537,7 +663,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -537,7 +663,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
537 const status = yield* checkCodexProviderStatus();663 const status = yield* checkCodexProviderStatus();
538 // The auth probe runs and sees "not logged in" → error664 // The auth probe runs and sees "not logged in" → error
539 assert.strictEqual(status.status, "error");665 assert.strictEqual(status.status, "error");
540 assert.strictEqual(status.authStatus, "unauthenticated");666 assert.strictEqual(status.auth.status, "unauthenticated");
541 }).pipe(667 }).pipe(
542 Effect.provide(668 Effect.provide(
543 mockSpawnerLayer((args) => {669 mockSpawnerLayer((args) => {
...@@ -558,7 +684,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -558,7 +684,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
558 it("exit code 0 with no auth markers is ready", () => {684 it("exit code 0 with no auth markers is ready", () => {
559 const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });685 const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });
560 assert.strictEqual(parsed.status, "ready");686 assert.strictEqual(parsed.status, "ready");
561 assert.strictEqual(parsed.authStatus, "authenticated");687 assert.strictEqual(parsed.auth.status, "authenticated");
562 });688 });
563689
564 it("JSON with authenticated=false is unauthenticated", () => {690 it("JSON with authenticated=false is unauthenticated", () => {
...@@ -568,7 +694,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -568,7 +694,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
568 code: 0,694 code: 0,
569 });695 });
570 assert.strictEqual(parsed.status, "error");696 assert.strictEqual(parsed.status, "error");
571 assert.strictEqual(parsed.authStatus, "unauthenticated");697 assert.strictEqual(parsed.auth.status, "unauthenticated");
572 });698 });
573699
574 it("JSON without auth marker is warning", () => {700 it("JSON without auth marker is warning", () => {
...@@ -578,7 +704,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -578,7 +704,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
578 code: 0,704 code: 0,
579 });705 });
580 assert.strictEqual(parsed.status, "warning");706 assert.strictEqual(parsed.status, "warning");
581 assert.strictEqual(parsed.authStatus, "unknown");707 assert.strictEqual(parsed.auth.status, "unknown");
582 });708 });
583 });709 });
584710
...@@ -715,7 +841,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -715,7 +841,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
715 assert.strictEqual(status.provider, "claudeAgent");841 assert.strictEqual(status.provider, "claudeAgent");
716 assert.strictEqual(status.status, "ready");842 assert.strictEqual(status.status, "ready");
717 assert.strictEqual(status.installed, true);843 assert.strictEqual(status.installed, true);
718 assert.strictEqual(status.authStatus, "authenticated");844 assert.strictEqual(status.auth.status, "authenticated");
719 }).pipe(845 }).pipe(
720 Effect.provide(846 Effect.provide(
721 mockSpawnerLayer((args) => {847 mockSpawnerLayer((args) => {
...@@ -733,13 +859,63 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -733,13 +859,63 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
733 ),859 ),
734 );860 );
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
736 it.effect("returns unavailable when claude is missing", () =>912 it.effect("returns unavailable when claude is missing", () =>
737 Effect.gen(function* () {913 Effect.gen(function* () {
738 const status = yield* checkClaudeProviderStatus();914 const status = yield* checkClaudeProviderStatus();
739 assert.strictEqual(status.provider, "claudeAgent");915 assert.strictEqual(status.provider, "claudeAgent");
740 assert.strictEqual(status.status, "error");916 assert.strictEqual(status.status, "error");
741 assert.strictEqual(status.installed, false);917 assert.strictEqual(status.installed, false);
742 assert.strictEqual(status.authStatus, "unknown");918 assert.strictEqual(status.auth.status, "unknown");
743 assert.strictEqual(919 assert.strictEqual(
744 status.message,920 status.message,
745 "Claude Agent CLI (`claude`) is not installed or not on PATH.",921 "Claude Agent CLI (`claude`) is not installed or not on PATH.",
...@@ -771,7 +947,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -771,7 +947,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
771 assert.strictEqual(status.provider, "claudeAgent");947 assert.strictEqual(status.provider, "claudeAgent");
772 assert.strictEqual(status.status, "error");948 assert.strictEqual(status.status, "error");
773 assert.strictEqual(status.installed, true);949 assert.strictEqual(status.installed, true);
774 assert.strictEqual(status.authStatus, "unauthenticated");950 assert.strictEqual(status.auth.status, "unauthenticated");
775 assert.strictEqual(951 assert.strictEqual(
776 status.message,952 status.message,
777 "Claude is not authenticated. Run `claude auth login` and try again.",953 "Claude is not authenticated. Run `claude auth login` and try again.",
...@@ -799,7 +975,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -799,7 +975,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
799 assert.strictEqual(status.provider, "claudeAgent");975 assert.strictEqual(status.provider, "claudeAgent");
800 assert.strictEqual(status.status, "error");976 assert.strictEqual(status.status, "error");
801 assert.strictEqual(status.installed, true);977 assert.strictEqual(status.installed, true);
802 assert.strictEqual(status.authStatus, "unauthenticated");978 assert.strictEqual(status.auth.status, "unauthenticated");
803 }).pipe(979 }).pipe(
804 Effect.provide(980 Effect.provide(
805 mockSpawnerLayer((args) => {981 mockSpawnerLayer((args) => {
...@@ -819,7 +995,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -819,7 +995,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
819 assert.strictEqual(status.provider, "claudeAgent");995 assert.strictEqual(status.provider, "claudeAgent");
820 assert.strictEqual(status.status, "warning");996 assert.strictEqual(status.status, "warning");
821 assert.strictEqual(status.installed, true);997 assert.strictEqual(status.installed, true);
822 assert.strictEqual(status.authStatus, "unknown");998 assert.strictEqual(status.auth.status, "unknown");
823 assert.strictEqual(999 assert.strictEqual(
824 status.message,1000 status.message,
825 "Claude Agent authentication status command is unavailable in this version of Claude.",1001 "Claude Agent authentication status command is unavailable in this version of Claude.",
...@@ -844,7 +1020,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -844,7 +1020,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
844 it("exit code 0 with no auth markers is ready", () => {1020 it("exit code 0 with no auth markers is ready", () => {
845 const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });1021 const parsed = parseClaudeAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });
846 assert.strictEqual(parsed.status, "ready");1022 assert.strictEqual(parsed.status, "ready");
847 assert.strictEqual(parsed.authStatus, "authenticated");1023 assert.strictEqual(parsed.auth.status, "authenticated");
848 });1024 });
8491025
850 it("JSON with loggedIn=true is authenticated", () => {1026 it("JSON with loggedIn=true is authenticated", () => {
...@@ -854,7 +1030,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -854,7 +1030,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
854 code: 0,1030 code: 0,
855 });1031 });
856 assert.strictEqual(parsed.status, "ready");1032 assert.strictEqual(parsed.status, "ready");
857 assert.strictEqual(parsed.authStatus, "authenticated");1033 assert.strictEqual(parsed.auth.status, "authenticated");
858 });1034 });
8591035
860 it("JSON with loggedIn=false is unauthenticated", () => {1036 it("JSON with loggedIn=false is unauthenticated", () => {
...@@ -864,7 +1040,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -864,7 +1040,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
864 code: 0,1040 code: 0,
865 });1041 });
866 assert.strictEqual(parsed.status, "error");1042 assert.strictEqual(parsed.status, "error");
867 assert.strictEqual(parsed.authStatus, "unauthenticated");1043 assert.strictEqual(parsed.auth.status, "unauthenticated");
868 });1044 });
8691045
870 it("JSON without auth marker is warning", () => {1046 it("JSON without auth marker is warning", () => {
...@@ -874,7 +1050,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(...@@ -874,7 +1050,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))(
874 code: 0,1050 code: 0,
875 });1051 });
876 assert.strictEqual(parsed.status, "warning");1052 assert.strictEqual(parsed.status, "warning");
877 assert.strictEqual(parsed.authStatus, "unknown");1053 assert.strictEqual(parsed.auth.status, "unknown");
878 });1054 });
879 });1055 });
880 },1056 },
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 @@...@@ -1,6 +1,6 @@
1import type {1import type {
2 ServerProvider,2 ServerProvider,
3 ServerProviderAuthStatus,3 ServerProviderAuth,
4 ServerProviderModel,4 ServerProviderModel,
5 ServerProviderState,5 ServerProviderState,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
...@@ -21,7 +21,7 @@ export interface ProviderProbeResult {...@@ -21,7 +21,7 @@ export interface ProviderProbeResult {
21 readonly installed: boolean;21 readonly installed: boolean;
22 readonly version: string | null;22 readonly version: string | null;
23 readonly status: Exclude<ServerProviderState, "disabled">;23 readonly status: Exclude<ServerProviderState, "disabled">;
24 readonly authStatus: ServerProviderAuthStatus;24 readonly auth: ServerProviderAuth;
25 readonly message?: string;25 readonly message?: string;
26}26}
2727
...@@ -137,7 +137,7 @@ export function buildServerProvider(input: {...@@ -137,7 +137,7 @@ export function buildServerProvider(input: {
137 installed: input.probe.installed,137 installed: input.probe.installed,
138 version: input.probe.version,138 version: input.probe.version,
139 status: input.enabled ? input.probe.status : "disabled",139 status: input.enabled ? input.probe.status : "disabled",
140 authStatus: input.probe.authStatus,140 auth: input.probe.auth,
141 checkedAt: input.checkedAt,141 checkedAt: input.checkedAt,
142 ...(input.probe.message ? { message: input.probe.message } : {}),142 ...(input.probe.message ? { message: input.probe.message } : {}),
143 models: input.models,143 models: input.models,
apps/server/src/wsServer.test.ts+1-1
...@@ -74,7 +74,7 @@ const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [...@@ -74,7 +74,7 @@ const defaultProviderStatuses: ReadonlyArray<ServerProvider> = [
74 installed: true,74 installed: true,
75 version: "0.116.0",75 version: "0.116.0",
76 status: "ready",76 status: "ready",
77 authStatus: "authenticated",77 auth: { status: "authenticated" },
78 checkedAt: "2026-01-01T00:00:00.000Z",78 checkedAt: "2026-01-01T00:00:00.000Z",
79 models: [],79 models: [],
80 },80 },
apps/web/src/components/ChatView.browser.tsx+1-1
...@@ -117,7 +117,7 @@ function createBaseServerConfig(): ServerConfig {...@@ -117,7 +117,7 @@ function createBaseServerConfig(): ServerConfig {
117 installed: true,117 installed: true,
118 version: "0.116.0",118 version: "0.116.0",
119 status: "ready",119 status: "ready",
120 authStatus: "authenticated",120 auth: { status: "authenticated" },
121 checkedAt: NOW_ISO,121 checkedAt: NOW_ISO,
122 models: [],122 models: [],
123 },123 },
apps/web/src/components/KeybindingsToast.browser.tsx+1-1
...@@ -50,7 +50,7 @@ function createBaseServerConfig(): ServerConfig {...@@ -50,7 +50,7 @@ function createBaseServerConfig(): ServerConfig {
50 installed: true,50 installed: true,
51 version: "0.116.0",51 version: "0.116.0",
52 status: "ready",52 status: "ready",
53 authStatus: "authenticated",53 auth: { status: "authenticated" },
54 checkedAt: NOW_ISO,54 checkedAt: NOW_ISO,
55 models: [],55 models: [],
56 },56 },
apps/web/src/components/chat/ProviderModelPicker.browser.tsx+102-2
...@@ -22,7 +22,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -22,7 +22,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
22 installed: true,22 installed: true,
23 version: "0.116.0",23 version: "0.116.0",
24 status: "ready",24 status: "ready",
25 authStatus: "authenticated",25 auth: { status: "authenticated" },
26 checkedAt: new Date().toISOString(),26 checkedAt: new Date().toISOString(),
27 models: [27 models: [
28 {28 {
...@@ -57,7 +57,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -57,7 +57,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
57 installed: true,57 installed: true,
58 version: "1.0.0",58 version: "1.0.0",
59 status: "ready",59 status: "ready",
60 authStatus: "authenticated",60 auth: { status: "authenticated" },
61 checkedAt: new Date().toISOString(),61 checkedAt: new Date().toISOString(),
62 models: [62 models: [
63 {63 {
...@@ -110,6 +110,19 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -110,6 +110,19 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
110 },110 },
111];111];
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
113async function mountPicker(props: {126async function mountPicker(props: {
114 provider: ProviderKind;127 provider: ProviderKind;
115 model: string;128 model: string;
...@@ -241,6 +254,93 @@ describe("ProviderModelPicker", () => {...@@ -241,6 +254,93 @@ describe("ProviderModelPicker", () => {
241 }254 }
242 });255 });
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
244 it("dispatches the canonical slug when a model is selected", async () => {344 it("dispatches the canonical slug when a model is selected", async () => {
245 const mounted = await mountPicker({345 const mounted = await mountPicker({
246 provider: "claudeAgent",346 provider: "claudeAgent",
apps/web/src/components/chat/TraitsPicker.browser.tsx+2-2
...@@ -35,7 +35,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -35,7 +35,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
35 installed: true,35 installed: true,
36 version: "0.1.0",36 version: "0.1.0",
37 status: "ready",37 status: "ready",
38 authStatus: "authenticated",38 auth: { status: "authenticated" },
39 checkedAt: "2026-01-01T00:00:00.000Z",39 checkedAt: "2026-01-01T00:00:00.000Z",
40 models: [40 models: [
41 {41 {
...@@ -61,7 +61,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [...@@ -61,7 +61,7 @@ const TEST_PROVIDERS: ReadonlyArray<ServerProvider> = [
61 installed: true,61 installed: true,
62 version: "0.1.0",62 version: "0.1.0",
63 status: "ready",63 status: "ready",
64 authStatus: "authenticated",64 auth: { status: "authenticated" },
65 checkedAt: "2026-01-01T00:00:00.000Z",65 checkedAt: "2026-01-01T00:00:00.000Z",
66 models: [66 models: [
67 {67 {
apps/web/src/components/settings/SettingsPanels.tsx+4-3
...@@ -146,13 +146,14 @@ function getProviderSummary(provider: ServerProvider | undefined) {...@@ -146,13 +146,14 @@ function getProviderSummary(provider: ServerProvider | undefined) {
146 detail: provider.message ?? "CLI not detected on PATH.",146 detail: provider.message ?? "CLI not detected on PATH.",
147 };147 };
148 }148 }
149 if (provider.authStatus === "authenticated") {149 if (provider.auth.status === "authenticated") {
150 const authLabel = provider.auth.label ?? provider.auth.type;
150 return {151 return {
151 headline: "Authenticated",152 headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated",
152 detail: provider.message ?? null,153 detail: provider.message ?? null,
153 };154 };
154 }155 }
155 if (provider.authStatus === "unauthenticated") {156 if (provider.auth.status === "unauthenticated") {
156 return {157 return {
157 headline: "Not authenticated",158 headline: "Not authenticated",
158 detail: provider.message ?? null,159 detail: provider.message ?? null,
apps/web/src/wsNativeApi.test.ts+1-1
...@@ -99,7 +99,7 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [...@@ -99,7 +99,7 @@ const defaultProviders: ReadonlyArray<ServerProvider> = [
99 installed: true,99 installed: true,
100 version: "0.116.0",100 version: "0.116.0",
101 status: "ready",101 status: "ready",
102 authStatus: "authenticated",102 auth: { status: "authenticated" },
103 checkedAt: "2026-01-01T00:00:00.000Z",103 checkedAt: "2026-01-01T00:00:00.000Z",
104 models: [],104 models: [],
105 },105 },
packages/contracts/src/server.ts+8-1
...@@ -35,6 +35,13 @@ export const ServerProviderAuthStatus = Schema.Literals([...@@ -35,6 +35,13 @@ export const ServerProviderAuthStatus = Schema.Literals([
35]);35]);
36export type ServerProviderAuthStatus = typeof ServerProviderAuthStatus.Type;36export 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
38export const ServerProviderModel = Schema.Struct({45export const ServerProviderModel = Schema.Struct({
39 slug: TrimmedNonEmptyString,46 slug: TrimmedNonEmptyString,
40 name: TrimmedNonEmptyString,47 name: TrimmedNonEmptyString,
...@@ -49,7 +56,7 @@ export const ServerProvider = Schema.Struct({...@@ -49,7 +56,7 @@ export const ServerProvider = Schema.Struct({
49 installed: Schema.Boolean,56 installed: Schema.Boolean,
50 version: Schema.NullOr(TrimmedNonEmptyString),57 version: Schema.NullOr(TrimmedNonEmptyString),
51 status: ServerProviderState,58 status: ServerProviderState,
52 authStatus: ServerProviderAuthStatus,59 auth: ServerProviderAuth,
53 checkedAt: IsoDateTime,60 checkedAt: IsoDateTime,
54 message: Schema.optional(TrimmedNonEmptyString),61 message: Schema.optional(TrimmedNonEmptyString),
55 models: Schema.Array(ServerProviderModel),62 models: Schema.Array(ServerProviderModel),