Skip to content

Commit 23ac756

Browse files
BunsDevCopilot
andauthored
[codex] restore Codex backend provider catalog (#455)
* restore Codex backend provider catalog * fix: require status=ready in claudeAgent auth-token-helper override Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/2fef1788-3f93-4657-95e2-921b05c337f3 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent dee407e commit 23ac756

31 files changed

Lines changed: 1497 additions & 4106 deletions

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"effect": "catalog:",
3232
"node-pty": "^1.1.0",
3333
"open": "^10.1.0",
34+
"toml": "^3.0.0",
3435
"ws": "^8.18.0",
3536
"yaml": "^2.8.1"
3637
},

apps/server/src/provider/Layers/ProviderHealth.test.ts

Lines changed: 35 additions & 170 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,8 @@ import { ChildProcessSpawner } from "effect/unstable/process";
77
import {
88
checkClaudeProviderStatus,
99
checkCodexProviderStatus,
10-
hasCustomModelProvider,
1110
parseAuthStatusFromOutput,
1211
parseClaudeAuthStatusFromOutput,
13-
readCodexConfigModelProvider,
1412
} from "./ProviderHealth";
1513

1614
// ── Test helpers ────────────────────────────────────────────────────
@@ -237,53 +235,44 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
237235
);
238236
});
239237

240-
// ── Custom model provider: checkCodexProviderStatus integration ───
241-
242-
describe("checkCodexProviderStatus with custom model provider", () => {
243-
it.effect("skips auth probe and returns ready when a custom model provider is configured", () =>
244-
Effect.gen(function* () {
245-
yield* withTempCodexHome(
246-
[
247-
'model_provider = "portkey"',
248-
"",
249-
"[model_providers.portkey]",
250-
'base_url = "https://api.portkey.ai/v1"',
251-
'env_key = "PORTKEY_API_KEY"',
252-
].join("\n"),
253-
);
254-
const status = yield* checkCodexProviderStatus;
255-
assert.strictEqual(status.provider, "codex");
256-
assert.strictEqual(status.status, "ready");
257-
assert.strictEqual(status.available, true);
258-
assert.strictEqual(status.authStatus, "unknown");
259-
assert.strictEqual(
260-
status.message,
261-
"Using a custom Codex model provider; OpenAI login check skipped.",
262-
);
263-
}).pipe(
264-
Effect.provide(
265-
// The spawner only handles --version; if the test attempts
266-
// "login status" the throw proves the auth probe was NOT skipped.
267-
mockSpawnerLayer((args) => {
268-
const joined = args.join(" ");
269-
if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
270-
throw new Error(`Auth probe should have been skipped but got args: ${joined}`);
271-
}),
238+
// ── Codex backend auth probing ────────────────────────────────────
239+
240+
describe("checkCodexProviderStatus backend auth probing", () => {
241+
const skippedBackends = [
242+
{ id: "ollama", label: "built-in ollama" },
243+
{ id: "lmstudio", label: "built-in lmstudio" },
244+
{ id: "portkey", label: "curated portkey" },
245+
{ id: "azure", label: "curated azure" },
246+
] as const;
247+
248+
for (const backend of skippedBackends) {
249+
it.effect(`skips the OpenAI login probe for ${backend.label}`, () =>
250+
Effect.gen(function* () {
251+
yield* withTempCodexHome(`model_provider = "${backend.id}"\n`);
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+
`Codex is configured to use backend '${backend.id}'; OpenAI login check skipped.`,
260+
);
261+
}).pipe(
262+
Effect.provide(
263+
mockSpawnerLayer((args) => {
264+
const joined = args.join(" ");
265+
if (joined === "--version") return { stdout: "codex 1.0.0\n", stderr: "", code: 0 };
266+
throw new Error(`Auth probe should have been skipped but got args: ${joined}`);
267+
}),
268+
),
272269
),
273-
),
274-
);
270+
);
271+
}
275272

276-
it.effect("still reports error when codex CLI is missing even with custom provider", () =>
273+
it.effect("still reports error when codex CLI is missing even with a non-OpenAI backend", () =>
277274
Effect.gen(function* () {
278-
yield* withTempCodexHome(
279-
[
280-
'model_provider = "portkey"',
281-
"",
282-
"[model_providers.portkey]",
283-
'base_url = "https://api.portkey.ai/v1"',
284-
'env_key = "PORTKEY_API_KEY"',
285-
].join("\n"),
286-
);
275+
yield* withTempCodexHome('model_provider = "portkey"\n');
287276
const status = yield* checkCodexProviderStatus;
288277
assert.strictEqual(status.status, "error");
289278
assert.strictEqual(status.available, false);
@@ -343,130 +332,6 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
343332
});
344333
});
345334

346-
// ── readCodexConfigModelProvider tests ─────────────────────────────
347-
348-
describe("readCodexConfigModelProvider", () => {
349-
it.effect("returns undefined when config file does not exist", () =>
350-
Effect.gen(function* () {
351-
yield* withTempCodexHome();
352-
assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
353-
}),
354-
);
355-
356-
it.effect("returns undefined when config has no model_provider key", () =>
357-
Effect.gen(function* () {
358-
yield* withTempCodexHome('model = "gpt-5-codex"\n');
359-
assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
360-
}),
361-
);
362-
363-
it.effect("returns the provider when model_provider is set at top level", () =>
364-
Effect.gen(function* () {
365-
yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n');
366-
assert.strictEqual(yield* readCodexConfigModelProvider, "portkey");
367-
}),
368-
);
369-
370-
it.effect("returns openai when model_provider is openai", () =>
371-
Effect.gen(function* () {
372-
yield* withTempCodexHome('model_provider = "openai"\n');
373-
assert.strictEqual(yield* readCodexConfigModelProvider, "openai");
374-
}),
375-
);
376-
377-
it.effect("ignores model_provider inside section headers", () =>
378-
Effect.gen(function* () {
379-
yield* withTempCodexHome(
380-
[
381-
'model = "gpt-5-codex"',
382-
"",
383-
"[model_providers.portkey]",
384-
'base_url = "https://api.portkey.ai/v1"',
385-
'model_provider = "should-be-ignored"',
386-
"",
387-
].join("\n"),
388-
);
389-
assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
390-
}),
391-
);
392-
393-
it.effect("handles comments and whitespace", () =>
394-
Effect.gen(function* () {
395-
yield* withTempCodexHome(
396-
[
397-
"# This is a comment",
398-
"",
399-
' model_provider = "azure" ',
400-
"",
401-
"[profiles.deep-review]",
402-
'model = "gpt-5-pro"',
403-
].join("\n"),
404-
);
405-
assert.strictEqual(yield* readCodexConfigModelProvider, "azure");
406-
}),
407-
);
408-
409-
it.effect("handles single-quoted values in TOML", () =>
410-
Effect.gen(function* () {
411-
yield* withTempCodexHome("model_provider = 'mistral'\n");
412-
assert.strictEqual(yield* readCodexConfigModelProvider, "mistral");
413-
}),
414-
);
415-
});
416-
417-
// ── hasCustomModelProvider tests ───────────────────────────────────
418-
419-
describe("hasCustomModelProvider", () => {
420-
it.effect("returns false when no config file exists", () =>
421-
Effect.gen(function* () {
422-
yield* withTempCodexHome();
423-
assert.strictEqual(yield* hasCustomModelProvider, false);
424-
}),
425-
);
426-
427-
it.effect("returns false when model_provider is not set", () =>
428-
Effect.gen(function* () {
429-
yield* withTempCodexHome('model = "gpt-5-codex"\n');
430-
assert.strictEqual(yield* hasCustomModelProvider, false);
431-
}),
432-
);
433-
434-
it.effect("returns false when model_provider is openai", () =>
435-
Effect.gen(function* () {
436-
yield* withTempCodexHome('model_provider = "openai"\n');
437-
assert.strictEqual(yield* hasCustomModelProvider, false);
438-
}),
439-
);
440-
441-
it.effect("returns true when model_provider is portkey", () =>
442-
Effect.gen(function* () {
443-
yield* withTempCodexHome('model_provider = "portkey"\n');
444-
assert.strictEqual(yield* hasCustomModelProvider, true);
445-
}),
446-
);
447-
448-
it.effect("returns true when model_provider is azure", () =>
449-
Effect.gen(function* () {
450-
yield* withTempCodexHome('model_provider = "azure"\n');
451-
assert.strictEqual(yield* hasCustomModelProvider, true);
452-
}),
453-
);
454-
455-
it.effect("returns true when model_provider is ollama", () =>
456-
Effect.gen(function* () {
457-
yield* withTempCodexHome('model_provider = "ollama"\n');
458-
assert.strictEqual(yield* hasCustomModelProvider, true);
459-
}),
460-
);
461-
462-
it.effect("returns true when model_provider is a custom proxy", () =>
463-
Effect.gen(function* () {
464-
yield* withTempCodexHome('model_provider = "my-company-proxy"\n');
465-
assert.strictEqual(yield* hasCustomModelProvider, true);
466-
}),
467-
);
468-
});
469-
470335
// ── checkClaudeProviderStatus tests ──────────────────────────
471336

472337
describe("checkClaudeProviderStatus", () => {

apps/server/src/provider/Layers/ProviderHealth.ts

Lines changed: 9 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@
77
*
88
* @module ProviderHealthLive
99
*/
10-
import * as OS from "node:os";
1110
import { CopilotClient } from "@github/copilot-sdk";
1211
import type {
1312
ServerProvider,
1413
ServerProviderAuthStatus,
1514
ServerProviderStatus,
1615
ServerProviderStatusState,
1716
} from "@okcode/contracts";
18-
import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
17+
import { Array, Data, Effect, FileSystem, Layer, Option, Result, Stream } from "effect";
1918
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2019

2120
import { serverBuildInfo } from "../../buildInfo.ts";
@@ -26,6 +25,7 @@ import {
2625
isCodexCliVersionSupported,
2726
parseCodexCliVersion,
2827
} from "../codexCliVersion";
28+
import { readCodexConfigSummary, usesOpenAiLoginForSelectedCodexBackend } from "../codexConfig";
2929
import { withServerProviderModels } from "../providerCatalog.ts";
3030
import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";
3131

@@ -243,72 +243,6 @@ export function parseAuthStatusFromOutput(result: CommandResult): {
243243
};
244244
}
245245

246-
// ── Codex CLI config detection ──────────────────────────────────────
247-
248-
/**
249-
* Providers that use OpenAI-native authentication via `codex login`.
250-
* When the configured `model_provider` is one of these, the `codex login
251-
* status` probe still runs. For any other provider value the auth probe
252-
* is skipped because authentication is handled externally (e.g. via
253-
* environment variables like `PORTKEY_API_KEY` or `AZURE_API_KEY`).
254-
*/
255-
const OPENAI_AUTH_PROVIDERS = new Set(["openai"]);
256-
257-
/**
258-
* Read the `model_provider` value from the Codex CLI config file.
259-
*
260-
* Looks for the file at `$CODEX_HOME/config.toml` (falls back to
261-
* `~/.codex/config.toml`). Uses a simple line-by-line scan rather than
262-
* a full TOML parser to avoid adding a dependency for a single key.
263-
*
264-
* Returns `undefined` when the file does not exist or does not set
265-
* `model_provider`.
266-
*/
267-
export const readCodexConfigModelProvider = Effect.gen(function* () {
268-
const fileSystem = yield* FileSystem.FileSystem;
269-
const path = yield* Path.Path;
270-
const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex");
271-
const configPath = path.join(codexHome, "config.toml");
272-
273-
const content = yield* fileSystem
274-
.readFileString(configPath)
275-
.pipe(Effect.orElseSucceed(() => undefined));
276-
if (content === undefined) {
277-
return undefined;
278-
}
279-
280-
// We need to find `model_provider = "..."` at the top level of the
281-
// TOML file (i.e. before any `[section]` header). Lines inside
282-
// `[profiles.*]`, `[model_providers.*]`, etc. are ignored.
283-
let inTopLevel = true;
284-
for (const line of content.split("\n")) {
285-
const trimmed = line.trim();
286-
// Skip comments and empty lines.
287-
if (!trimmed || trimmed.startsWith("#")) continue;
288-
// Detect section headers — once we leave the top level, stop.
289-
if (trimmed.startsWith("[")) {
290-
inTopLevel = false;
291-
continue;
292-
}
293-
if (!inTopLevel) continue;
294-
295-
const match = trimmed.match(/^model_provider\s*=\s*["']([^"']+)["']/);
296-
if (match) return match[1];
297-
}
298-
return undefined;
299-
});
300-
301-
/**
302-
* Returns `true` when the Codex CLI is configured with a custom
303-
* (non-OpenAI) model provider, meaning `codex login` auth is not
304-
* required because authentication is handled through provider-specific
305-
* environment variables.
306-
*/
307-
export const hasCustomModelProvider = Effect.map(
308-
readCodexConfigModelProvider,
309-
(provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider),
310-
);
311-
312246
// ── Effect-native command execution ─────────────────────────────────
313247

314248
const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
@@ -496,7 +430,7 @@ export const checkCopilotProviderStatus: Effect.Effect<ServerProviderStatus, nev
496430
export const checkCodexProviderStatus: Effect.Effect<
497431
ServerProviderStatus,
498432
never,
499-
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
433+
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
500434
> = Effect.gen(function* () {
501435
const checkedAt = new Date().toISOString();
502436

@@ -566,13 +500,13 @@ export const checkCodexProviderStatus: Effect.Effect<
566500
});
567501
}
568502

503+
const codexConfig = yield* readCodexConfigSummary();
504+
569505
// Probe 2: `codex login status` — is the user authenticated?
570506
//
571-
// Custom model providers (e.g. Portkey, Azure OpenAI proxy) handle
572-
// authentication through their own environment variables, so `codex
573-
// login status` will report "not logged in" even when the CLI works
574-
// fine. Skip the auth probe entirely for non-OpenAI providers.
575-
if (yield* hasCustomModelProvider) {
507+
// Non-OpenAI backends handle authentication externally, so `codex
508+
// login status` is only meaningful for the default OpenAI backend.
509+
if (!usesOpenAiLoginForSelectedCodexBackend(codexConfig)) {
576510
return createServerProviderStatus({
577511
provider: CODEX_PROVIDER,
578512
enabled: true,
@@ -581,7 +515,7 @@ export const checkCodexProviderStatus: Effect.Effect<
581515
status: "ready" as const,
582516
auth: { status: "unknown" as const },
583517
checkedAt,
584-
message: "Using a custom Codex model provider; OpenAI login check skipped.",
518+
message: `Codex is configured to use backend '${codexConfig.selectedModelProviderId}'; OpenAI login check skipped.`,
585519
});
586520
}
587521

@@ -1100,7 +1034,6 @@ export const ProviderHealthLive = Layer.effect(
11001034
ProviderHealth,
11011035
Effect.gen(function* () {
11021036
const fileSystem = yield* FileSystem.FileSystem;
1103-
const path = yield* Path.Path;
11041037
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
11051038
const openclawGatewayConfig = yield* OpenclawGatewayConfig;
11061039

@@ -1118,7 +1051,6 @@ export const ProviderHealthLive = Layer.effect(
11181051
},
11191052
).pipe(
11201053
Effect.provideService(FileSystem.FileSystem, fileSystem),
1121-
Effect.provideService(Path.Path, path),
11221054
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
11231055
Effect.provideService(OpenclawGatewayConfig, openclawGatewayConfig),
11241056
),

0 commit comments

Comments
 (0)