Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"effect": "catalog:",
"node-pty": "^1.1.0",
"open": "^10.1.0",
"toml": "^3.0.0",
"ws": "^8.18.0",
"yaml": "^2.8.1"
},
Expand Down
205 changes: 35 additions & 170 deletions apps/server/src/provider/Layers/ProviderHealth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import {
checkClaudeProviderStatus,
checkCodexProviderStatus,
hasCustomModelProvider,
parseAuthStatusFromOutput,
parseClaudeAuthStatusFromOutput,
readCodexConfigModelProvider,
} from "./ProviderHealth";

// ── Test helpers ────────────────────────────────────────────────────
Expand Down Expand Up @@ -237,53 +235,44 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
);
});

// ── Custom model provider: checkCodexProviderStatus integration ───

describe("checkCodexProviderStatus with custom model provider", () => {
it.effect("skips auth probe and returns ready when a custom model provider is configured", () =>
Effect.gen(function* () {
yield* withTempCodexHome(
[
'model_provider = "portkey"',
"",
"[model_providers.portkey]",
'base_url = "https://api.portkey.ai/v1"',
'env_key = "PORTKEY_API_KEY"',
].join("\n"),
);
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}`);
}),
// ── Codex backend auth probing ────────────────────────────────────

describe("checkCodexProviderStatus backend auth probing", () => {
const skippedBackends = [
{ id: "ollama", label: "built-in ollama" },
{ id: "lmstudio", label: "built-in lmstudio" },
{ id: "portkey", label: "curated portkey" },
{ id: "azure", label: "curated azure" },
] as const;

for (const backend of skippedBackends) {
it.effect(`skips the OpenAI login probe for ${backend.label}`, () =>
Effect.gen(function* () {
yield* withTempCodexHome(`model_provider = "${backend.id}"\n`);
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,
`Codex is configured to use backend '${backend.id}'; OpenAI login check skipped.`,
);
}).pipe(
Effect.provide(
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", () =>
it.effect("still reports error when codex CLI is missing even with a non-OpenAI backend", () =>
Effect.gen(function* () {
yield* withTempCodexHome(
[
'model_provider = "portkey"',
"",
"[model_providers.portkey]",
'base_url = "https://api.portkey.ai/v1"',
'env_key = "PORTKEY_API_KEY"',
].join("\n"),
);
yield* withTempCodexHome('model_provider = "portkey"\n');
const status = yield* checkCodexProviderStatus;
assert.strictEqual(status.status, "error");
assert.strictEqual(status.available, false);
Expand Down Expand Up @@ -343,130 +332,6 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => {
});
});

// ── readCodexConfigModelProvider tests ─────────────────────────────

describe("readCodexConfigModelProvider", () => {
it.effect("returns undefined when config file does not exist", () =>
Effect.gen(function* () {
yield* withTempCodexHome();
assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
}),
);

it.effect("returns undefined when config has no model_provider key", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model = "gpt-5-codex"\n');
assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
}),
);

it.effect("returns the provider when model_provider is set at top level", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model = "gpt-5-codex"\nmodel_provider = "portkey"\n');
assert.strictEqual(yield* readCodexConfigModelProvider, "portkey");
}),
);

it.effect("returns openai when model_provider is openai", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model_provider = "openai"\n');
assert.strictEqual(yield* readCodexConfigModelProvider, "openai");
}),
);

it.effect("ignores model_provider inside section headers", () =>
Effect.gen(function* () {
yield* withTempCodexHome(
[
'model = "gpt-5-codex"',
"",
"[model_providers.portkey]",
'base_url = "https://api.portkey.ai/v1"',
'model_provider = "should-be-ignored"',
"",
].join("\n"),
);
assert.strictEqual(yield* readCodexConfigModelProvider, undefined);
}),
);

it.effect("handles comments and whitespace", () =>
Effect.gen(function* () {
yield* withTempCodexHome(
[
"# This is a comment",
"",
' model_provider = "azure" ',
"",
"[profiles.deep-review]",
'model = "gpt-5-pro"',
].join("\n"),
);
assert.strictEqual(yield* readCodexConfigModelProvider, "azure");
}),
);

it.effect("handles single-quoted values in TOML", () =>
Effect.gen(function* () {
yield* withTempCodexHome("model_provider = 'mistral'\n");
assert.strictEqual(yield* readCodexConfigModelProvider, "mistral");
}),
);
});

// ── hasCustomModelProvider tests ───────────────────────────────────

describe("hasCustomModelProvider", () => {
it.effect("returns false when no config file exists", () =>
Effect.gen(function* () {
yield* withTempCodexHome();
assert.strictEqual(yield* hasCustomModelProvider, false);
}),
);

it.effect("returns false when model_provider is not set", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model = "gpt-5-codex"\n');
assert.strictEqual(yield* hasCustomModelProvider, false);
}),
);

it.effect("returns false when model_provider is openai", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model_provider = "openai"\n');
assert.strictEqual(yield* hasCustomModelProvider, false);
}),
);

it.effect("returns true when model_provider is portkey", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model_provider = "portkey"\n');
assert.strictEqual(yield* hasCustomModelProvider, true);
}),
);

it.effect("returns true when model_provider is azure", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model_provider = "azure"\n');
assert.strictEqual(yield* hasCustomModelProvider, true);
}),
);

it.effect("returns true when model_provider is ollama", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model_provider = "ollama"\n');
assert.strictEqual(yield* hasCustomModelProvider, true);
}),
);

it.effect("returns true when model_provider is a custom proxy", () =>
Effect.gen(function* () {
yield* withTempCodexHome('model_provider = "my-company-proxy"\n');
assert.strictEqual(yield* hasCustomModelProvider, true);
}),
);
});

// ── checkClaudeProviderStatus tests ──────────────────────────

describe("checkClaudeProviderStatus", () => {
Expand Down
86 changes: 9 additions & 77 deletions apps/server/src/provider/Layers/ProviderHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@
*
* @module ProviderHealthLive
*/
import * as OS from "node:os";
import { CopilotClient } from "@github/copilot-sdk";
import type {
ServerProvider,
ServerProviderAuthStatus,
ServerProviderStatus,
ServerProviderStatusState,
} from "@okcode/contracts";
import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect";
import { Array, Data, Effect, FileSystem, Layer, Option, Result, Stream } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import { serverBuildInfo } from "../../buildInfo.ts";
Expand All @@ -26,6 +25,7 @@ import {
isCodexCliVersionSupported,
parseCodexCliVersion,
} from "../codexCliVersion";
import { readCodexConfigSummary, usesOpenAiLoginForSelectedCodexBackend } from "../codexConfig";
import { withServerProviderModels } from "../providerCatalog.ts";
import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth";

Expand Down Expand Up @@ -243,72 +243,6 @@ 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 const readCodexConfigModelProvider = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const codexHome = process.env.CODEX_HOME || path.join(OS.homedir(), ".codex");
const configPath = path.join(codexHome, "config.toml");

const content = yield* fileSystem
.readFileString(configPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (content === undefined) {
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 const hasCustomModelProvider = Effect.map(
readCodexConfigModelProvider,
(provider) => provider !== undefined && !OPENAI_AUTH_PROVIDERS.has(provider),
);

// ── Effect-native command execution ─────────────────────────────────

const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
Expand Down Expand Up @@ -496,7 +430,7 @@ export const checkCopilotProviderStatus: Effect.Effect<ServerProviderStatus, nev
export const checkCodexProviderStatus: Effect.Effect<
ServerProviderStatus,
never,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
> = Effect.gen(function* () {
const checkedAt = new Date().toISOString();

Expand Down Expand Up @@ -566,13 +500,13 @@ export const checkCodexProviderStatus: Effect.Effect<
});
}

const codexConfig = yield* readCodexConfigSummary();

// 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 (yield* hasCustomModelProvider) {
// Non-OpenAI backends handle authentication externally, so `codex
// login status` is only meaningful for the default OpenAI backend.
if (!usesOpenAiLoginForSelectedCodexBackend(codexConfig)) {
return createServerProviderStatus({
provider: CODEX_PROVIDER,
enabled: true,
Expand All @@ -581,7 +515,7 @@ export const checkCodexProviderStatus: Effect.Effect<
status: "ready" as const,
auth: { status: "unknown" as const },
checkedAt,
message: "Using a custom Codex model provider; OpenAI login check skipped.",
message: `Codex is configured to use backend '${codexConfig.selectedModelProviderId}'; OpenAI login check skipped.`,
});
}

Expand Down Expand Up @@ -1100,7 +1034,6 @@ export const ProviderHealthLive = Layer.effect(
ProviderHealth,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const openclawGatewayConfig = yield* OpenclawGatewayConfig;

Expand All @@ -1118,7 +1051,6 @@ export const ProviderHealthLive = Layer.effect(
},
).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(OpenclawGatewayConfig, openclawGatewayConfig),
),
Expand Down
Loading