Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/server/src/codexAppServerManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
classifyCodexStderrLine,
isRecoverableThreadResumeError,
normalizeCodexModelSlug,
readCodexAccountSnapshot,
resolveCodexModelForAccount,
} from "./codexAppServerManager";
import { readCodexAccountSnapshot } from "./provider/codexAccount";

const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);

Expand Down Expand Up @@ -255,6 +255,7 @@ describe("readCodexAccountSnapshot", () => {
}),
).toEqual({
type: "chatgpt",
email: "plus@example.com",
planType: "plus",
sparkEnabled: false,
});
Expand All @@ -269,6 +270,7 @@ describe("readCodexAccountSnapshot", () => {
}),
).toEqual({
type: "chatgpt",
email: "pro@example.com",
planType: "pro",
sparkEnabled: true,
});
Expand Down
59 changes: 1 addition & 58 deletions apps/server/src/codexAppServerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
isCodexCliVersionSupported,
parseCodexCliVersion,
} from "./provider/codexCliVersion";
import { readCodexAccountSnapshot, type CodexAccountSnapshot } from "./provider/codexAccount";

type PendingRequestKey = string;

Expand Down Expand Up @@ -97,23 +98,6 @@ interface JsonRpcNotification {
params?: unknown;
}

type CodexPlanType =
| "free"
| "go"
| "plus"
| "pro"
| "team"
| "business"
| "enterprise"
| "edu"
| "unknown";

interface CodexAccountSnapshot {
readonly type: "apiKey" | "chatgpt" | "unknown";
readonly planType: CodexPlanType | null;
readonly sparkEnabled: boolean;
}

export interface CodexAppServerSendTurnInput {
readonly threadId: ThreadId;
readonly input?: string;
Expand Down Expand Up @@ -164,47 +148,6 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [
];
const CODEX_DEFAULT_MODEL = "gpt-5.3-codex";
const CODEX_SPARK_MODEL = "gpt-5.3-codex-spark";
const CODEX_SPARK_DISABLED_PLAN_TYPES = new Set<CodexPlanType>(["free", "go", "plus"]);

function asObject(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
return value as Record<string, unknown>;
}

function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

export function readCodexAccountSnapshot(response: unknown): CodexAccountSnapshot {
const record = asObject(response);
const account = asObject(record?.account) ?? record;
const accountType = asString(account?.type);

if (accountType === "apiKey") {
return {
type: "apiKey",
planType: null,
sparkEnabled: true,
};
}

if (accountType === "chatgpt") {
const planType = (account?.planType as CodexPlanType | null) ?? "unknown";
return {
type: "chatgpt",
planType,
sparkEnabled: !CODEX_SPARK_DISABLED_PLAN_TYPES.has(planType),
};
}

return {
type: "unknown",
planType: null,
sparkEnabled: true,
};
}

export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)

Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/ProviderHealth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,8 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {

it("JSON with loggedIn=true is authenticated", () => {
const parsed = parseClaudeAuthStatusFromOutput({
stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n',
stdout:
'{"loggedIn":true,"authMethod":"claude.ai","email":"claude@example.com","subscriptionType":"pro"}\n',
stderr: "",
code: 0,
});
Expand Down
180 changes: 161 additions & 19 deletions apps/server/src/provider/Layers/ProviderHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,28 @@ import type {
ServerProviderStatus,
ServerProviderStatusState,
} from "@t3tools/contracts";
import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Ref, Result, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import type { ProviderKind } from "@t3tools/contracts";

import {
formatCodexCliUpgradeMessage,
isCodexCliVersionSupported,
parseCodexCliVersion,
} from "../codexCliVersion";
import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
import {
ProviderHealth,
type ProviderAuthActionResult,
type ProviderHealthShape,
} from "../Services/ProviderHealth";

const DEFAULT_TIMEOUT_MS = 4_000;
const CODEX_PROVIDER = "codex" as const;
const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const;

const CLI_VERSION_PATTERN = /\bv?(\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?)\b/;

// ── Pure helpers ────────────────────────────────────────────────────

export interface CommandResult {
Expand Down Expand Up @@ -84,6 +92,16 @@ function extractAuthBoolean(value: unknown): boolean | undefined {
return undefined;
}

function parseJsonOutput(result: CommandResult): unknown {
const trimmed = result.stdout.trim();
if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}

export function parseAuthStatusFromOutput(result: CommandResult): {
readonly status: ServerProviderStatusState;
readonly authStatus: ServerProviderAuthStatus;
Expand Down Expand Up @@ -345,6 +363,7 @@ export const checkCodexProviderStatus: Effect.Effect<
authStatus: "unknown" as const,
checkedAt,
message: formatCodexCliUpgradeMessage(parsedVersion),
...(parsedVersion ? { version: parsedVersion } : {}),
};
}

Expand All @@ -362,6 +381,7 @@ export const checkCodexProviderStatus: Effect.Effect<
authStatus: "unknown" as const,
checkedAt,
message: "Using a custom Codex model provider; OpenAI login check skipped.",
...(parsedVersion ? { version: parsedVersion } : {}),
} satisfies ServerProviderStatus;
}

Expand Down Expand Up @@ -404,6 +424,7 @@ export const checkCodexProviderStatus: Effect.Effect<
authStatus: parsed.authStatus,
checkedAt,
...(parsed.message ? { message: parsed.message } : {}),
...(parsedVersion ? { version: parsedVersion } : {}),
} satisfies ServerProviderStatus;
});

Expand Down Expand Up @@ -444,20 +465,11 @@ export function parseClaudeAuthStatusFromOutput(result: CommandResult): {
}

// `claude auth status` returns JSON with a `loggedIn` boolean.
const parsedAuth = (() => {
const trimmed = result.stdout.trim();
if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) {
return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined };
}
try {
return {
attemptedJsonParse: true as const,
auth: extractAuthBoolean(JSON.parse(trimmed)),
};
} catch {
return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined };
}
})();
const parsedJson = parseJsonOutput(result);
const parsedAuth = {
attemptedJsonParse: parsedJson !== undefined,
auth: extractAuthBoolean(parsedJson),
};

if (parsedAuth.auth === true) {
return { status: "ready", authStatus: "authenticated" };
Expand Down Expand Up @@ -544,6 +556,9 @@ export const checkClaudeProviderStatus: Effect.Effect<
};
}

const claudeVersionMatch = CLI_VERSION_PATTERN.exec(`${version.stdout}\n${version.stderr}`);
const claudeVersion = claudeVersionMatch?.[1] ?? undefined;

// Probe 2: `claude auth status` — is the user authenticated?
const authProbe = yield* runClaudeCommand(["auth", "status"]).pipe(
Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
Expand All @@ -562,6 +577,7 @@ export const checkClaudeProviderStatus: Effect.Effect<
error instanceof Error
? `Could not verify Claude authentication status: ${error.message}.`
: "Could not verify Claude authentication status.",
...(claudeVersion ? { version: claudeVersion } : {}),
};
}

Expand All @@ -573,6 +589,7 @@ export const checkClaudeProviderStatus: Effect.Effect<
authStatus: "unknown" as const,
checkedAt,
message: "Could not verify Claude authentication status. Timed out while running command.",
...(claudeVersion ? { version: claudeVersion } : {}),
};
}

Expand All @@ -584,20 +601,145 @@ export const checkClaudeProviderStatus: Effect.Effect<
authStatus: parsed.authStatus,
checkedAt,
...(parsed.message ? { message: parsed.message } : {}),
...(claudeVersion ? { version: claudeVersion } : {}),
} satisfies ServerProviderStatus;
});

// ── Auth action helpers ──────────────────────────────────────────────

const LOGIN_TIMEOUT_MS = 30_000;
const LOGOUT_TIMEOUT_MS = 10_000;

function loginArgs(provider: ProviderKind): {
run: (args: ReadonlyArray<string>) => ReturnType<typeof runCodexCommand>;
args: ReadonlyArray<string>;
} {
switch (provider) {
case "codex":
return { run: runCodexCommand, args: ["login"] };
case "claudeAgent":
return { run: runClaudeCommand, args: ["auth", "login"] };
}
}

function logoutArgs(provider: ProviderKind): {
run: (args: ReadonlyArray<string>) => ReturnType<typeof runCodexCommand>;
args: ReadonlyArray<string>;
} {
switch (provider) {
case "codex":
return { run: runCodexCommand, args: ["logout"] };
case "claudeAgent":
return { run: runClaudeCommand, args: ["auth", "logout"] };
}
}

function providerCheck(
provider: ProviderKind,
): Effect.Effect<
ServerProviderStatus,
never,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
switch (provider) {
case CODEX_PROVIDER:
return checkCodexProviderStatus;
case CLAUDE_AGENT_PROVIDER:
return checkClaudeProviderStatus;
}
}

// ── Layer ───────────────────────────────────────────────────────────

export const ProviderHealthLive = Layer.effect(
ProviderHealth,
Effect.gen(function* () {
const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const runProviderChecks = Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], {
concurrency: "unbounded",
}).pipe(Effect.forkScoped);
}).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const statusesFiber = yield* runProviderChecks.pipe(Effect.forkScoped);
const initialStatuses: ReadonlyArray<ServerProviderStatus> = yield* Fiber.join(statusesFiber);
const statusesRef = yield* Ref.make<ReadonlyArray<ServerProviderStatus>>(initialStatuses);

const refreshAndStore = Effect.flatMap(runProviderChecks, (statuses) =>
Ref.set(statusesRef, statuses).pipe(Effect.as(statuses)),
);
const refreshStatusAndStore = (provider: ProviderKind) =>
Effect.gen(function* () {
const nextStatus = yield* providerCheck(provider).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
const currentStatuses = yield* Ref.get(statusesRef);
const providers = currentStatuses.map((status) =>
status.provider === provider ? nextStatus : status,
);
yield* Ref.set(statusesRef, providers);
return providers;
});

const runAuthAction = (
provider: ProviderKind,
getConfig: (p: ProviderKind) => ReturnType<typeof loginArgs>,
timeoutMs: number,
): Effect.Effect<ProviderAuthActionResult> =>
Effect.gen(function* () {
const { run, args } = getConfig(provider);
const result = yield* run(args).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.timeoutOption(timeoutMs),
Effect.result,
);

if (Result.isFailure(result)) {
const error = result.failure;
const providers = yield* refreshAndStore;
return {
success: false,
message: error instanceof Error ? error.message : "Command failed.",
providers,
};
}

if (Option.isNone(result.success)) {
const providers = yield* refreshAndStore;
return {
success: false,
message: "Command timed out.",
providers,
};
}

const cmd = result.success.value;
const providers = yield* refreshAndStore;
return {
success: cmd.code === 0,
...(cmd.code !== 0
? {
message:
nonEmptyTrimmed(cmd.stderr) ??
nonEmptyTrimmed(cmd.stdout) ??
`Command exited with code ${cmd.code}.`,
}
: {}),
providers,
};
});

return {
getStatuses: Fiber.join(statusesFiber),
getStatuses: Ref.get(statusesRef),
refreshStatuses: refreshAndStore,
refreshStatus: refreshStatusAndStore,
login: (provider: ProviderKind) => runAuthAction(provider, loginArgs, LOGIN_TIMEOUT_MS),
logout: (provider: ProviderKind) => runAuthAction(provider, logoutArgs, LOGOUT_TIMEOUT_MS),
} satisfies ProviderHealthShape;
}),
);
Loading
Loading