authorgravatar for crazywolf132@gmail.com0x1f99d <crazywolf132@gmail.com> 2026-03-11 04:14:58+11:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-10 10:14:58-07:00
log9becb3f40af504244f85823bb19f18bfe507ca49
treeb3f0527f7f031790be10136ff158f959ea044226
parent2c351726ae21a73ac0549500783d5a4de6394214
signature Signed by PGP key B5690EEEBB952194

fix(server): skip auth check when Codex CLI uses a custom model provider (#649)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

2 files changed, 489 insertions(+), 148 deletions(-)

apps/server/src/provider/Layers/ProviderHealth.test.ts+404-146
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import assert from "node:assert/strict";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";2import { describe, it, assert } from "@effect/vitest";
3import { Effect, Layer, Sink, Stream } from "effect";3import { Effect, FileSystem, Layer, Path, Sink, Stream } from "effect";
4import * as PlatformError from "effect/PlatformError";4import * as PlatformError from "effect/PlatformError";
5import { ChildProcessSpawner } from "effect/unstable/process";5import { ChildProcessSpawner } from "effect/unstable/process";
66
7import { checkCodexProviderStatus, parseAuthStatusFromOutput } from "./ProviderHealth";7import {
8 checkCodexProviderStatus,
9 hasCustomModelProvider,
10 parseAuthStatusFromOutput,
11 readCodexConfigModelProvider,
12} from "./ProviderHealth";
813
9// ── Test helpers ────────────────────────────────────────────────────14// ── Test helpers ────────────────────────────────────────────────────
1015
...@@ -53,157 +58,410 @@ function failingSpawnerLayer(description: string) {...@@ -53,157 +58,410 @@ function failingSpawnerLayer(description: string) {
53 );58 );
54}59}
5560
56// ── Tests ───────────────────────────────────────────────────────────61/**
5762 * Create a temporary CODEX_HOME scoped to the current Effect test.
58it.effect("returns ready when codex is installed and authenticated", () =>63 * Cleanup is registered in the test scope rather than via Vitest hooks.
59 Effect.gen(function* () {64 */
60 const status = yield* checkCodexProviderStatus;65function withTempCodexHome(configContent?: string) {
61 assert.strictEqual(status.provider, "codex");66 return Effect.gen(function* () {
62 assert.strictEqual(status.status, "ready");67 const fileSystem = yield* FileSystem.FileSystem;
63 assert.strictEqual(status.available, true);68 const path = yield* Path.Path;
64 assert.strictEqual(status.authStatus, "authenticated");69 const tmpDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-test-codex-" });
65 }).pipe(70
66 Effect.provide(71 yield* Effect.acquireRelease(
67 mockSpawnerLayer((args) => {72 Effect.sync(() => {
68 const joined = args.join(" ");73 const originalCodexHome = process.env.CODEX_HOME;
69 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };74 process.env.CODEX_HOME = tmpDir;
70 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };75 return originalCodexHome;
71 throw new Error(`Unexpected args: ${joined}`);
72 }),76 }),
73 ),77 (originalCodexHome) =>
74 ),78 Effect.sync(() => {
75);79 if (originalCodexHome !== undefined) {
7680 process.env.CODEX_HOME = originalCodexHome;
77it.effect("returns unavailable when codex is missing", () =>81 } else {
78 Effect.gen(function* () {82 delete process.env.CODEX_HOME;
79 const status = yield* checkCodexProviderStatus;83 }
80 assert.strictEqual(status.provider, "codex");84 }),
81 assert.strictEqual(status.status, "error");85 );
82 assert.strictEqual(status.available, false);86
83 assert.strictEqual(status.authStatus, "unknown");87 if (configContent !== undefined) {
84 assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH.");88 yield* fileSystem.writeFileString(path.join(tmpDir, "config.toml"), configContent);
85 }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))),89 }
86);90
8791 return { tmpDir } as const;
88it.effect("returns unavailable when codex is below the minimum supported version", () =>92 });
89 Effect.gen(function* () {93}
90 const status = yield* checkCodexProviderStatus;94
91 assert.strictEqual(status.provider, "codex");95it.layer(NodeServices.layer)("ProviderHealth", (it) => {
92 assert.strictEqual(status.status, "error");96 // ── checkCodexProviderStatus tests ────────────────────────────────
93 assert.strictEqual(status.available, false);97 //
94 assert.strictEqual(status.authStatus, "unknown");98 // These tests control CODEX_HOME to ensure the custom-provider detection
95 assert.strictEqual(99 // in hasCustomModelProvider() does not interfere with the auth-probe
96 status.message,100 // path being tested.
97 "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.",101
98 );102 describe("checkCodexProviderStatus", () => {
99 }).pipe(103 it.effect("returns ready when codex is installed and authenticated", () =>
100 Effect.provide(104 Effect.gen(function* () {
101 mockSpawnerLayer((args) => {105 // Point CODEX_HOME at an empty tmp dir (no config.toml) so the
102 const joined = args.join(" ");106 // default code path (OpenAI provider, auth probe runs) is exercised.
103 if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 };107 yield* withTempCodexHome();
104 throw new Error(`Unexpected args: ${joined}`);108 const status = yield* checkCodexProviderStatus;
109 assert.strictEqual(status.provider, "codex");
110 assert.strictEqual(status.status, "ready");
111 assert.strictEqual(status.available, true);
112 assert.strictEqual(status.authStatus, "authenticated");
113 }).pipe(
114 Effect.provide(
115 mockSpawnerLayer((args) => {
116 const joined = args.join(" ");
117 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
118 if (joined === "login status") return { stdout: "Logged in\n", stderr: "", code: 0 };
119 throw new Error(`Unexpected args: ${joined}`);
120 }),
121 ),
122 ),
123 );
124
125 it.effect("returns unavailable when codex is missing", () =>
126 Effect.gen(function* () {
127 yield* withTempCodexHome();
128 const status = yield* checkCodexProviderStatus;
129 assert.strictEqual(status.provider, "codex");
130 assert.strictEqual(status.status, "error");
131 assert.strictEqual(status.available, false);
132 assert.strictEqual(status.authStatus, "unknown");
133 assert.strictEqual(status.message, "Codex CLI (`codex`) is not installed or not on PATH.");
134 }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))),
135 );
136
137 it.effect("returns unavailable when codex is below the minimum supported version", () =>
138 Effect.gen(function* () {
139 yield* withTempCodexHome();
140 const status = yield* checkCodexProviderStatus;
141 assert.strictEqual(status.provider, "codex");
142 assert.strictEqual(status.status, "error");
143 assert.strictEqual(status.available, false);
144 assert.strictEqual(status.authStatus, "unknown");
145 assert.strictEqual(
146 status.message,
147 "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.",
148 );
149 }).pipe(
150 Effect.provide(
151 mockSpawnerLayer((args) => {
152 const joined = args.join(" ");
153 if (joined === "--version") return { stdout: "codex 0.36.0\n", stderr: "", code: 0 };
154 throw new Error(`Unexpected args: ${joined}`);
155 }),
156 ),
157 ),
158 );
159
160 it.effect("returns unauthenticated when auth probe reports login required", () =>
161 Effect.gen(function* () {
162 yield* withTempCodexHome();
163 const status = yield* checkCodexProviderStatus;
164 assert.strictEqual(status.provider, "codex");
165 assert.strictEqual(status.status, "error");
166 assert.strictEqual(status.available, true);
167 assert.strictEqual(status.authStatus, "unauthenticated");
168 assert.strictEqual(
169 status.message,
170 "Codex CLI is not authenticated. Run `codex login` and try again.",
171 );
172 }).pipe(
173 Effect.provide(
174 mockSpawnerLayer((args) => {
175 const joined = args.join(" ");
176 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
177 if (joined === "login status") {
178 return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 };
179 }
180 throw new Error(`Unexpected args: ${joined}`);
181 }),
182 ),
183 ),
184 );
185
186 it.effect("returns unauthenticated when login status output includes 'not logged in'", () =>
187 Effect.gen(function* () {
188 yield* withTempCodexHome();
189 const status = yield* checkCodexProviderStatus;
190 assert.strictEqual(status.provider, "codex");
191 assert.strictEqual(status.status, "error");
192 assert.strictEqual(status.available, true);
193 assert.strictEqual(status.authStatus, "unauthenticated");
194 assert.strictEqual(
195 status.message,
196 "Codex CLI is not authenticated. Run `codex login` and try again.",
197 );
198 }).pipe(
199 Effect.provide(
200 mockSpawnerLayer((args) => {
201 const joined = args.join(" ");
202 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
203 if (joined === "login status")
204 return { stdout: "Not logged in\n", stderr: "", code: 1 };
205 throw new Error(`Unexpected args: ${joined}`);
206 }),
207 ),
208 ),
209 );
210
211 it.effect("returns warning when login status command is unsupported", () =>
212 Effect.gen(function* () {
213 yield* withTempCodexHome();
214 const status = yield* checkCodexProviderStatus;
215 assert.strictEqual(status.provider, "codex");
216 assert.strictEqual(status.status, "warning");
217 assert.strictEqual(status.available, true);
218 assert.strictEqual(status.authStatus, "unknown");
219 assert.strictEqual(
220 status.message,
221 "Codex CLI authentication status command is unavailable in this Codex version.",
222 );
223 }).pipe(
224 Effect.provide(
225 mockSpawnerLayer((args) => {
226 const joined = args.join(" ");
227 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
228 if (joined === "login status") {
229 return { stdout: "", stderr: "error: unknown command 'login'", code: 2 };
230 }
231 throw new Error(`Unexpected args: ${joined}`);
232 }),
233 ),
234 ),
235 );
236 });
237
238 // ── Custom model provider: checkCodexProviderStatus integration ───
239
240 describe("checkCodexProviderStatus with custom model provider", () => {
241 it.effect("skips auth probe and returns ready when a custom model provider is configured", () =>
242 Effect.gen(function* () {
243 yield* withTempCodexHome(
244 [
245 'model_provider = "portkey"',
246 "",
247 "[model_providers.portkey]",
248 'base_url = "https://api.portkey.ai/v1"',
249 'env_key = "PORTKEY_API_KEY"',
250 ].join("\n"),
251 );
252 const status = yield* checkCodexProviderStatus;
253 assert.strictEqual(status.provider, "codex");
254 assert.strictEqual(status.status, "ready");
255 assert.strictEqual(status.available, true);
256 assert.strictEqual(status.authStatus, "unknown");
257 assert.strictEqual(
258 status.message,
259 "Using a custom Codex model provider; OpenAI login check skipped.",
260 );
261 }).pipe(
262 Effect.provide(
263 // The spawner only handles --version; if the test attempts
264 // "login status" the throw proves the auth probe was NOT skipped.
265 mockSpawnerLayer((args) => {
266 const joined = args.join(" ");
267 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
268 throw new Error(`Auth probe should have been skipped but got args: ${joined}`);
269 }),
270 ),
271 ),
272 );
273
274 it.effect("still reports error when codex CLI is missing even with custom provider", () =>
275 Effect.gen(function* () {
276 yield* withTempCodexHome(
277 [
278 'model_provider = "portkey"',
279 "",
280 "[model_providers.portkey]",
281 'base_url = "https://api.portkey.ai/v1"',
282 'env_key = "PORTKEY_API_KEY"',
283 ].join("\n"),
284 );
285 const status = yield* checkCodexProviderStatus;
286 assert.strictEqual(status.status, "error");
287 assert.strictEqual(status.available, false);
288 }).pipe(Effect.provide(failingSpawnerLayer("spawn codex ENOENT"))),
289 );
290 });
291
292 describe("checkCodexProviderStatus with openai model provider", () => {
293 it.effect("still runs auth probe when model_provider is openai", () =>
294 Effect.gen(function* () {
295 yield* withTempCodexHome('model_provider = "openai"\n');
296 const status = yield* checkCodexProviderStatus;
297 // The auth probe runs and sees "not logged in" → error
298 assert.strictEqual(status.status, "error");
299 assert.strictEqual(status.authStatus, "unauthenticated");
300 }).pipe(
301 Effect.provide(
302 mockSpawnerLayer((args) => {
303 const joined = args.join(" ");
304 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
305 if (joined === "login status")
306 return { stdout: "Not logged in\n", stderr: "", code: 1 };
307 throw new Error(`Unexpected args: ${joined}`);
308 }),
309 ),
310 ),
311 );
312 });
313
314 // ── parseAuthStatusFromOutput pure tests ──────────────────────────
315
316 describe("parseAuthStatusFromOutput", () => {
317 it("exit code 0 with no auth markers is ready", () => {
318 const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });
319 assert.strictEqual(parsed.status, "ready");
320 assert.strictEqual(parsed.authStatus, "authenticated");
321 });
322
323 it("JSON with authenticated=false is unauthenticated", () => {
324 const parsed = parseAuthStatusFromOutput({
325 stdout: '[{"authenticated":false}]\n',
326 stderr: "",
327 code: 0,
328 });
329 assert.strictEqual(parsed.status, "error");
330 assert.strictEqual(parsed.authStatus, "unauthenticated");
331 });
332
333 it("JSON without auth marker is warning", () => {
334 const parsed = parseAuthStatusFromOutput({
335 stdout: '[{"ok":true}]\n',
336 stderr: "",
337 code: 0,
338 });
339 assert.strictEqual(parsed.status, "warning");
340 assert.strictEqual(parsed.authStatus, "unknown");
341 });
342 });
343
344 // ── readCodexConfigModelProvider tests ─────────────────────────────
345
346 describe("readCodexConfigModelProvider", () => {
347 it.effect("returns undefined when config file does not exist", () =>
348 Effect.gen(function* () {
349 yield* withTempCodexHome();
350 assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
105 }),351 }),
106 ),352 );
107 ),353
108);354 it.effect("returns undefined when config has no model_provider key", () =>
109355 Effect.gen(function* () {
110it.effect("returns unauthenticated when auth probe reports login required", () =>356 yield* withTempCodexHome('model = "gpt-5-codex"\n');
111 Effect.gen(function* () {357 assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
112 const status = yield* checkCodexProviderStatus;
113 assert.strictEqual(status.provider, "codex");
114 assert.strictEqual(status.status, "error");
115 assert.strictEqual(status.available, true);
116 assert.strictEqual(status.authStatus, "unauthenticated");
117 assert.strictEqual(
118 status.message,
119 "Codex CLI is not authenticated. Run `codex login` and try again.",
120 );
121 }).pipe(
122 Effect.provide(
123 mockSpawnerLayer((args) => {
124 const joined = args.join(" ");
125 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
126 if (joined === "login status") {
127 return { stdout: "", stderr: "Not logged in. Run codex login.", code: 1 };
128 }
129 throw new Error(`Unexpected args: ${joined}`);
130 }),358 }),
131 ),359 );
132 ),360
133);361 it.effect("returns the provider when model_provider is set at top level", () =>
134362 Effect.gen(function* () {
135it.effect("returns unauthenticated when login status output includes 'not logged in'", () =>363 yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n');
136 Effect.gen(function* () {364 assert.strictEqual(yield* readCodexConfigModelProvider, "portkey");
137 const status = yield* checkCodexProviderStatus;
138 assert.strictEqual(status.provider, "codex");
139 assert.strictEqual(status.status, "error");
140 assert.strictEqual(status.available, true);
141 assert.strictEqual(status.authStatus, "unauthenticated");
142 assert.strictEqual(
143 status.message,
144 "Codex CLI is not authenticated. Run `codex login` and try again.",
145 );
146 }).pipe(
147 Effect.provide(
148 mockSpawnerLayer((args) => {
149 const joined = args.join(" ");
150 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
151 if (joined === "login status") return { stdout: "Not logged in\n", stderr: "", code: 1 };
152 throw new Error(`Unexpected args: ${joined}`);
153 }),365 }),
154 ),366 );
155 ),367
156);368 it.effect("returns openai when model_provider is openai", () =>
157369 Effect.gen(function* () {
158it.effect("returns warning when login status command is unsupported", () =>370 yield* withTempCodexHome('model_provider = "openai"\n');
159 Effect.gen(function* () {371 assert.strictEqual(yield* readCodexConfigModelProvider, "openai");
160 const status = yield* checkCodexProviderStatus;
161 assert.strictEqual(status.provider, "codex");
162 assert.strictEqual(status.status, "warning");
163 assert.strictEqual(status.available, true);
164 assert.strictEqual(status.authStatus, "unknown");
165 assert.strictEqual(
166 status.message,
167 "Codex CLI authentication status command is unavailable in this Codex version.",
168 );
169 }).pipe(
170 Effect.provide(
171 mockSpawnerLayer((args) => {
172 const joined = args.join(" ");
173 if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
174 if (joined === "login status") {
175 return { stdout: "", stderr: "error: unknown command 'login'", code: 2 };
176 }
177 throw new Error(`Unexpected args: ${joined}`);
178 }),372 }),
179 ),373 );
180 ),
181);
182374
183// ── Pure function tests ─────────────────────────────────────────────375 it.effect("ignores model_provider inside section headers", () =>
376 Effect.gen(function* () {
377 yield* withTempCodexHome(
378 [
379 'model = "gpt-5-codex"',
380 "",
381 "[model_providers.portkey]",
382 'base_url = "https://api.portkey.ai/v1"',
383 'model_provider = "should-be-ignored"',
384 "",
385 ].join("\n"),
386 );
387 assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
388 }),
389 );
184390
185it("parseAuthStatusFromOutput: exit code 0 with no auth markers is ready", () => {391 it.effect("handles comments and whitespace", () =>
186 const parsed = parseAuthStatusFromOutput({ stdout: "OK\n", stderr: "", code: 0 });392 Effect.gen(function* () {
187 assert.strictEqual(parsed.status, "ready");393 yield* withTempCodexHome(
188 assert.strictEqual(parsed.authStatus, "authenticated");394 [
189});395 "# This is a comment",
396 "",
397 ' model_provider = "azure" ',
398 "",
399 "[profiles.deep-review]",
400 'model = "gpt-5-pro"',
401 ].join("\n"),
402 );
403 assert.strictEqual(yield* readCodexConfigModelProvider, "azure");
404 }),
405 );
190406
191it("parseAuthStatusFromOutput: JSON with authenticated=false is unauthenticated", () => {407 it.effect("handles single-quoted values in TOML", () =>
192 const parsed = parseAuthStatusFromOutput({408 Effect.gen(function* () {
193 stdout: '[{"authenticated":false}]\n',409 yield* withTempCodexHome("model_provider = 'mistral'\n");
194 stderr: "",410 assert.strictEqual(yield* readCodexConfigModelProvider, "mistral");
195 code: 0,411 }),
412 );
196 });413 });
197 assert.strictEqual(parsed.status, "error");
198 assert.strictEqual(parsed.authStatus, "unauthenticated");
199});
200414
201it("parseAuthStatusFromOutput: JSON without auth marker is warning", () => {415 // ── hasCustomModelProvider tests ───────────────────────────────────
202 const parsed = parseAuthStatusFromOutput({416
203 stdout: '[{"ok":true}]\n',417 describe("hasCustomModelProvider", () => {
204 stderr: "",418 it.effect("returns false when no config file exists", () =>
205 code: 0,419 Effect.gen(function* () {
420 yield* withTempCodexHome();
421 assert.strictEqual(yield* hasCustomModelProvider, false);
422 }),
423 );
424
425 it.effect("returns false when model_provider is not set", () =>
426 Effect.gen(function* () {
427 yield* withTempCodexHome('model = "gpt-5-codex"\n');
428 assert.strictEqual(yield* hasCustomModelProvider, false);
429 }),
430 );
431
432 it.effect("returns false when model_provider is openai", () =>
433 Effect.gen(function* () {
434 yield* withTempCodexHome('model_provider = "openai"\n');
435 assert.strictEqual(yield* hasCustomModelProvider, false);
436 }),
437 );
438
439 it.effect("returns true when model_provider is portkey", () =>
440 Effect.gen(function* () {
441 yield* withTempCodexHome('model_provider = "portkey"\n');
442 assert.strictEqual(yield* hasCustomModelProvider, true);
443 }),
444 );
445
446 it.effect("returns true when model_provider is azure", () =>
447 Effect.gen(function* () {
448 yield* withTempCodexHome('model_provider = "azure"\n');
449 assert.strictEqual(yield* hasCustomModelProvider, true);
450 }),
451 );
452
453 it.effect("returns true when model_provider is ollama", () =>
454 Effect.gen(function* () {
455 yield* withTempCodexHome('model_provider = "ollama"\n');
456 assert.strictEqual(yield* hasCustomModelProvider, true);
457 }),
458 );
459
460 it.effect("returns true when model_provider is a custom proxy", () =>
461 Effect.gen(function* () {
462 yield* withTempCodexHome('model_provider = "my-company-proxy"\n');
463 assert.strictEqual(yield* hasCustomModelProvider, true);
464 }),
465 );
206 });466 });
207 assert.strictEqual(parsed.status, "warning");
208 assert.strictEqual(parsed.authStatus, "unknown");
209});467});
apps/server/src/provider/Layers/ProviderHealth.ts+85-2
...@@ -8,12 +8,13 @@...@@ -8,12 +8,13 @@
8 *8 *
9 * @module ProviderHealthLive9 * @module ProviderHealthLive
10 */10 */
11import * as OS from "node:os";
11import type {12import type {
12 ServerProviderAuthStatus,13 ServerProviderAuthStatus,
13 ServerProviderStatus,14 ServerProviderStatus,
14 ServerProviderStatusState,15 ServerProviderStatusState,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
16import { Array, Effect, Fiber, Layer, Option, Result, Stream } from "effect";17import { Array, Effect, Fiber, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
17import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";18import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1819
19import {20import {
...@@ -167,6 +168,72 @@ export function parseAuthStatusFromOutput(result: CommandResult): {...@@ -167,6 +168,72 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
167 };168 };
168}169}
169170
171// ── Codex CLI config detection ──────────────────────────────────────
172
173/**
174 * Providers that use OpenAI-native authentication via `codex login`.
175 * When the configured `model_provider` is one of these, the `codex login
176 * status` probe still runs. For any other provider value the auth probe
177 * is skipped because authentication is handled externally (e.g. via
178 * environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`).
179 */
180const OPENAI_AUTH_PROVIDERS = new Set(["openai"]);
181
182/**
183 * Read the `model_provider` value from the Codex CLI config file.
184 *
185 * Looks for the file at `$CODEX_HOME/config.toml` (falls back to
186 * `~/.codex/config.toml`). Uses a simple line-by-line scan rather than
187 * a full TOML parser to avoid adding a dependency for a single key.
188 *
189 * Returns `undefined` when the file does not exist or does not set
190 * `model_provider`.
191 */
192export const readCodexConfigModelProvider = Effect.gen(function* () {
193 const fileSystem = yield* FileSystem.FileSystem;
194 const path = yield* Path.Path;
195 const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex");
196 const configPath = path.join(codexHome, "config.toml");
197
198 const content = yield* fileSystem
199 .readFileString(configPath)
200 .pipe(Effect.orElseSucceed(() => undefined));
201 if (content === undefined) {
202 return undefined;
203 }
204
205 // We need to find `model_provider = "..."` at the top level of the
206 // TOML file (i.e. before any `[section]` header). Lines inside
207 // `[profiles.*]`, `[model_providers.*]`, etc. are ignored.
208 let inTopLevel = true;
209 for (const line of content.split("\n")) {
210 const trimmed = line.trim();
211 // Skip comments and empty lines.
212 if (!trimmed || trimmed.startsWith("#")) continue;
213 // Detect section headers — once we leave the top level, stop.
214 if (trimmed.startsWith("[")) {
215 inTopLevel = false;
216 continue;
217 }
218 if (!inTopLevel) continue;
219
220 const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/);
221 if (match) return match[1];
222 }
223 return undefined;
224});
225
226/**
227 * Returns `true` when the Codex CLI is configured with a custom
228 * (non-OpenAI) model provider, meaning `codex login` auth is not
229 * required because authentication is handled through provider-specific
230 * environment variables.
231 */
232export const hasCustomModelProvider = Effect.map(
233 readCodexConfigModelProvider,
234 (provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider),
235);
236
170// ── Effect-native command execution ─────────────────────────────────237// ── Effect-native command execution ─────────────────────────────────
171238
172const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>239const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
...@@ -202,7 +269,7 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>...@@ -202,7 +269,7 @@ const runCodexCommand = (args: ReadonlyArray<string>) =>
202export const checkCodexProviderStatus: Effect.Effect<269export const checkCodexProviderStatus: Effect.Effect<
203 ServerProviderStatus,270 ServerProviderStatus,
204 never,271 never,
205 ChildProcessSpawner.ChildProcessSpawner272 ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
206> = Effect.gen(function* () {273> = Effect.gen(function* () {
207 const checkedAt = new Date().toISOString();274 const checkedAt = new Date().toISOString();
208275
...@@ -265,6 +332,22 @@ export const checkCodexProviderStatus: Effect.Effect<...@@ -265,6 +332,22 @@ export const checkCodexProviderStatus: Effect.Effect<
265 }332 }
266333
267 // Probe 2: `codex login status` — is the user authenticated?334 // Probe 2: `codex login status` — is the user authenticated?
335 //
336 // Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle
337 // authentication through their own environment variables, so `codex
338 // login status` will report "not logged in" even when the CLI works
339 // fine. Skip the auth probe entirely for non-OpenAI providers.
340 if (yield* hasCustomModelProvider) {
341 return {
342 provider: CODEX_PROVIDER,
343 status: "ready" as const,
344 available: true,
345 authStatus: "unknown" as const,
346 checkedAt,
347 message: "Using a custom Codex model provider; OpenAI login check skipped.",
348 } satisfies ServerProviderStatus;
349 }
350
268 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(351 const authProbe = yield* runCodexCommand(["login", "status"]).pipe(
269 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),352 Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
270 Effect.result,353 Effect.result,