From 9dc1b8153b04eaf6bfadde173fdf5d8c0243f630 Mon Sep 17 00:00:00 2001 From: BinBandit Date: Mon, 9 Mar 2026 20:44:39 +1100 Subject: [PATCH] fix(server): skip auth check when Codex CLI uses a custom model provider When the Codex CLI is configured with a custom model_provider in ~/.codex/config.toml (e.g. Portkey, Azure OpenAI proxy), authentication is handled via provider-specific environment variables rather than `codex login`. The `codex login status` probe would report 'not logged in' and t3code would treat this as a blocking error, even though the CLI works perfectly fine. This change reads the model_provider key from the Codex CLI config file at startup. When a non-OpenAI provider is detected, the auth probe is skipped and the provider health check returns ready with authStatus 'unknown' instead of erroring out. Fixes #644 --- .../provider/Layers/ProviderHealth.test.ts | 497 +++++++++++++----- .../src/provider/Layers/ProviderHealth.ts | 83 +++ 2 files changed, 455 insertions(+), 125 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 90df9b691f..bfbbc3d471 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -1,10 +1,19 @@ import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { it } from "@effect/vitest"; import { Effect, Layer, Sink, Stream } from "effect"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, beforeEach, describe } from "vitest"; -import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth"; +import { + checkCodexProviderStatus, + hasCustomModelProvider, + parseAuthStatusFromOutput, + readCodexConfigModelProvider, +} from "./ProviderHealth"; // ── Test helpers ──────────────────────────────────────────────────── @@ -53,88 +62,99 @@ function failingSpawnerLayer(description: string) { ); } -// ── Tests ─────────────────────────────────────────────────────────── - -it.effect("returns ready when codex is installed and authenticated", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "ready"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "authenticated"); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), -); - -it.effect("returns unavailable when codex is missing", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); - }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), -); - -it.effect("returns unavailable when codex is below the minimum supported version", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, false); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; - throw new Error(`Unexpected args: ${joined}`); - }), +/** + * Create a temporary CODEX_HOME with an optional config.toml content. + * Returns a cleanup function that restores the original env var. + */ +function withTempCodexHome(configContent?: string): { tmpDir: string; cleanup: () => void } { + const originalCodexHome = process.env.CODEX_HOME; + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-test-codex-")); + process.env.CODEX_HOME = tmpDir; + if (configContent !== undefined) { + fs.writeFileSync(path.join(tmpDir, "config.toml"), configContent); + } + return { + tmpDir, + cleanup: () => { + if (originalCodexHome !== undefined) { + process.env.CODEX_HOME = originalCodexHome; + } else { + delete process.env.CODEX_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }, + }; +} + +// ── checkCodexProviderStatus tests ────────────────────────────────── +// +// These tests control CODEX_HOME to ensure the custom-provider detection +// in hasCustomModelProvider() does not interfere with the auth-probe +// path being tested. + +describe("checkCodexProviderStatus", () => { + let cleanup: () => void; + + // Point CODEX_HOME at an empty tmp dir (no config.toml) so the + // default code path (OpenAI provider, auth probe runs) is exercised. + beforeEach(() => { + ({ cleanup } = withTempCodexHome()); + }); + afterEach(() => cleanup()); + + it.effect("returns ready when codex is installed and authenticated", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "authenticated"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), ), - ), -); - -it.effect("returns unauthenticated when auth probe reports login required", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "error"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unauthenticated"); - assert.strictEqual( - status.message, - "Codex CLI is not authenticated. Run `codex login` and try again.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), + ); + + it.effect("returns unavailable when codex is missing", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH."); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); + + it.effect("returns unavailable when codex is below the minimum supported version", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), ), - ), -); + ); -it.effect( - "returns unauthenticated when login status output includes 'not logged in'", - () => + it.effect("returns unauthenticated when auth probe reports login required", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus; assert.strictEqual(status.provider, "codex"); @@ -145,6 +165,137 @@ it.effect( status.message, "Codex CLI is not authenticated. Run `codex login` and try again.", ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect( + "returns unauthenticated when login status output includes 'not logged in'", + () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unauthenticated"); + assert.strictEqual( + status.message, + "Codex CLI is not authenticated. Run `codex login` and try again.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") + return { stdout: "Not logged in\n", stderr: "", code: 1 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("returns warning when login status command is unsupported", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "warning"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Codex CLI authentication status command is unavailable in this Codex version.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + if (joined === "login status") { + return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); +}); + +// ── Custom model provider: checkCodexProviderStatus integration ───── + +describe("checkCodexProviderStatus with custom model provider", () => { + let cleanup: () => void; + + beforeEach(() => { + ({ cleanup } = withTempCodexHome( + ['model_provider = "portkey"', "", "[model_providers.portkey]", 'base_url = "https://api.portkey.ai/v1"', 'env_key = "PORTKEY_API_KEY"'].join( + "\n", + ), + )); + }); + afterEach(() => cleanup()); + + it.effect( + "skips auth probe and returns ready when a custom model provider is configured", + () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.provider, "codex"); + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.available, true); + assert.strictEqual(status.authStatus, "unknown"); + assert.strictEqual( + status.message, + "Using a custom Codex model provider; OpenAI login check skipped.", + ); + }).pipe( + Effect.provide( + // The spawner only handles --version; if the test attempts + // "login status" the throw proves the auth probe was NOT skipped. + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; + throw new Error(`Auth probe should have been skipped but got args: ${joined}`); + }), + ), + ), + ); + + it.effect( + "still reports error when codex CLI is missing even with custom provider", + () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.available, false); + }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))), + ); +}); + +describe("checkCodexProviderStatus with openai model provider", () => { + let cleanup: () => void; + + beforeEach(() => { + ({ cleanup } = withTempCodexHome('model_provider = "openai"\n')); + }); + afterEach(() => cleanup()); + + it.effect("still runs auth probe when model_provider is openai", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus; + // The auth probe runs and sees "not logged in" → error + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.authStatus, "unauthenticated"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -156,57 +307,153 @@ it.effect( }), ), ), -); - -it.effect("returns warning when login status command is unsupported", () => - Effect.gen(function* () { - const status = yield* checkCodexProviderStatus; - assert.strictEqual(status.provider, "codex"); - assert.strictEqual(status.status, "warning"); - assert.strictEqual(status.available, true); - assert.strictEqual(status.authStatus, "unknown"); - assert.strictEqual( - status.message, - "Codex CLI authentication status command is unavailable in this Codex version.", - ); - }).pipe( - Effect.provide( - mockSpawnerLayer((args) => { - const joined = args.join(" "); - if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; - if (joined === "login status") { - return { stdout: "", stderr: "error: unknown command 'login'", code: 2 }; - } - throw new Error(`Unexpected args: ${joined}`); - }), - ), - ), -); + ); +}); + +// ── parseAuthStatusFromOutput pure tests ──────────────────────────── + +describe("parseAuthStatusFromOutput", () => { + it("exit code 0 with no auth markers is ready", () => { + const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); + assert.strictEqual(parsed.status, "ready"); + assert.strictEqual(parsed.authStatus, "authenticated"); + }); -// ── Pure function tests ───────────────────────────────────────────── + it("JSON with authenticated=false is unauthenticated", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"authenticated":false}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "error"); + assert.strictEqual(parsed.authStatus, "unauthenticated"); + }); -it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => { - const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 }); - assert.strictEqual(parsed.status, "ready"); - assert.strictEqual(parsed.authStatus, "authenticated"); + it("JSON without auth marker is warning", () => { + const parsed = parseAuthStatusFromOutput({ + stdout: '[{"ok":true}]\n', + stderr: "", + code: 0, + }); + assert.strictEqual(parsed.status, "warning"); + assert.strictEqual(parsed.authStatus, "unknown"); + }); }); -it("parseAuthStatusFromOutput: JSON with authenticated=false is unauthenticated", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"authenticated":false}]\n', - stderr: "", - code: 0, +// ── readCodexConfigModelProvider tests ─────────────────────────────── + +describe("readCodexConfigModelProvider", () => { + let cleanup: () => void; + let tmpDir: string; + + beforeEach(() => { + ({ tmpDir, cleanup } = withTempCodexHome()); + }); + afterEach(() => cleanup()); + + it("returns undefined when config file does not exist", () => { + assert.strictEqual(readCodexConfigModelProvider(), undefined); + }); + + it("returns undefined when config has no model_provider key", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model = "gpt-5-codex"\n'); + assert.strictEqual(readCodexConfigModelProvider(), undefined); + }); + + it("returns the provider when model_provider is set at top level", () => { + fs.writeFileSync( + path.join(tmpDir, "config.toml"), + 'model = "gpt-5-codex"\nmodel_provider = "portkey"\n', + ); + assert.strictEqual(readCodexConfigModelProvider(), "portkey"); + }); + + it("returns openai when model_provider is openai", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model_provider = "openai"\n'); + assert.strictEqual(readCodexConfigModelProvider(), "openai"); + }); + + it("ignores model_provider inside section headers", () => { + fs.writeFileSync( + path.join(tmpDir, "config.toml"), + [ + 'model = "gpt-5-codex"', + "", + "[model_providers.portkey]", + 'base_url = "https://api.portkey.ai/v1"', + 'model_provider = "should-be-ignored"', + "", + ].join("\n"), + ); + assert.strictEqual(readCodexConfigModelProvider(), undefined); + }); + + it("handles comments and whitespace", () => { + fs.writeFileSync( + path.join(tmpDir, "config.toml"), + [ + "# This is a comment", + "", + ' model_provider = "azure" ', + "", + "[profiles.deep-review]", + 'model = "gpt-5-pro"', + ].join("\n"), + ); + assert.strictEqual(readCodexConfigModelProvider(), "azure"); + }); + + it("handles single-quoted values in TOML", () => { + fs.writeFileSync( + path.join(tmpDir, "config.toml"), + "model_provider = 'mistral'\n", + ); + assert.strictEqual(readCodexConfigModelProvider(), "mistral"); }); - assert.strictEqual(parsed.status, "error"); - assert.strictEqual(parsed.authStatus, "unauthenticated"); }); -it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => { - const parsed = parseAuthStatusFromOutput({ - stdout: '[{"ok":true}]\n', - stderr: "", - code: 0, +// ── hasCustomModelProvider tests ───────────────────────────────────── + +describe("hasCustomModelProvider", () => { + let cleanup: () => void; + let tmpDir: string; + + beforeEach(() => { + ({ tmpDir, cleanup } = withTempCodexHome()); + }); + afterEach(() => cleanup()); + + it("returns false when no config file exists", () => { + assert.strictEqual(hasCustomModelProvider(), false); + }); + + it("returns false when model_provider is not set", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model = "gpt-5-codex"\n'); + assert.strictEqual(hasCustomModelProvider(), false); + }); + + it("returns false when model_provider is openai", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model_provider = "openai"\n'); + assert.strictEqual(hasCustomModelProvider(), false); + }); + + it("returns true when model_provider is portkey", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model_provider = "portkey"\n'); + assert.strictEqual(hasCustomModelProvider(), true); + }); + + it("returns true when model_provider is azure", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model_provider = "azure"\n'); + assert.strictEqual(hasCustomModelProvider(), true); + }); + + it("returns true when model_provider is ollama", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model_provider = "ollama"\n'); + assert.strictEqual(hasCustomModelProvider(), true); + }); + + it("returns true when model_provider is a custom proxy", () => { + fs.writeFileSync(path.join(tmpDir, "config.toml"), 'model_provider = "my-company-proxy"\n'); + assert.strictEqual(hasCustomModelProvider(), true); }); - assert.strictEqual(parsed.status, "warning"); - assert.strictEqual(parsed.authStatus, "unknown"); }); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 59f41edf81..bd90108acb 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -8,6 +8,9 @@ * * @module ProviderHealthLive */ +import * as fs from "node:fs"; +import * as OS from "node:os"; +import * as NodePath from "node:path"; import type { ServerProviderAuthStatus, ServerProviderStatus, @@ -167,6 +170,70 @@ export function parseAuthStatusFromOutput(result: CommandResult): { }; } +// ── Codex CLI config detection ────────────────────────────────────── + +/** + * Providers that use OpenAI-native authentication via `codex login`. + * When the configured `model_provider` is one of these, the `codex login + * status` probe still runs. For any other provider value the auth probe + * is skipped because authentication is handled externally (e.g. via + * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`). + */ +const OPENAI_AUTH_PROVIDERS = new Set(["openai"]); + +/** + * Read the `model_provider` value from the Codex CLI config file. + * + * Looks for the file at `$CODEX_HOME/config.toml` (falls back to + * `~/.codex/config.toml`). Uses a simple line-by-line scan rather than + * a full TOML parser to avoid adding a dependency for a single key. + * + * Returns `undefined` when the file does not exist or does not set + * `model_provider`. + */ +export function readCodexConfigModelProvider(): string | undefined { + const codexHome = process.env.CODEX_HOME || NodePath.join(OS.homedir(), ".codex"); + const configPath = NodePath.join(codexHome, "config.toml"); + + let content: string; + try { + content = fs.readFileSync(configPath, "utf8"); + } catch { + return undefined; + } + + // We need to find `model_provider = "..."` at the top level of the + // TOML file (i.e. before any `[section]` header). Lines inside + // `[profiles.*]`, `[model_providers.*]`, etc. are ignored. + let inTopLevel = true; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + // Skip comments and empty lines. + if (!trimmed || trimmed.startsWith("#")) continue; + // Detect section headers — once we leave the top level, stop. + if (trimmed.startsWith("[")) { + inTopLevel = false; + continue; + } + if (!inTopLevel) continue; + + const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/); + if (match) return match[1]; + } + return undefined; +} + +/** + * Returns `true` when the Codex CLI is configured with a custom + * (non-OpenAI) model provider, meaning `codex login` auth is not + * required because authentication is handled through provider-specific + * environment variables. + */ +export function hasCustomModelProvider(): boolean { + const provider = readCodexConfigModelProvider(); + return provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider); +} + // ── Effect-native command execution ───────────────────────────────── const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => @@ -265,6 +332,22 @@ export const checkCodexProviderStatus: Effect.Effect< } // Probe 2: `codex login status` — is the user authenticated? + // + // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle + // authentication through their own environment variables, so `codex + // login status` will report "not logged in" even when the CLI works + // fine. Skip the auth probe entirely for non-OpenAI providers. + if (hasCustomModelProvider()) { + return { + provider: CODEX_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Using a custom Codex model provider; OpenAI login check skipped.", + } satisfies ServerProviderStatus; + } + const authProbe = yield* runCodexCommand(["login", "status"]).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result,