Skip to content
Open
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
72 changes: 47 additions & 25 deletions apps/server/src/provider/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as CodexSchema from "effect-codex-app-server/schema";
import * as CodexErrors from "effect-codex-app-server/errors";

import type {
CodexProbeTimeouts,
CodexSettings,
CustomModelSetting,
ServerProvider,
Expand All @@ -27,7 +28,11 @@ import type {
ServerProviderModel,
ServerProviderSkill,
} from "@t3tools/contracts";
import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts";
import {
DEFAULT_CODEX_PROBE_TIMEOUTS,
PREFERRED_DEFAULT_CODEX_MODELS,
ServerSettingsError,
} from "@t3tools/contracts";

import {
codexModelFamily,
Expand All @@ -54,9 +59,9 @@ import {
type CodexResetCreditsSummary,
} from "./codexUsageLimits.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
import packageJson from "../../package.json" with { type: "json" };
const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError);
const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000;

type CodexRateLimitsProbe =
| {
Expand All @@ -71,17 +76,19 @@ type CodexRateLimitsProbe =
| { readonly failure: string };

const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const;
const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000;
const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000;
const CODEX_CATALOG_REFRESH_POLL = "100 millis" as const;
const CODEX_CLIENT_NAME = "T3 Code";
// Handshake plus account/models/skills requests can exceed the shared 10s probe budget.
const CODEX_AUTH_PROBE_TIMEOUT_MS = 20_000;
const CODEX_DISCOVERY_MARGIN_MS = 250;
// `codex-cli 0.159.0`, `codex-cli 0.160.0-alpha.1` or the npm CLI's bare `0.1.4161006`.
// Keep a prerelease suffix so compatibility stays unknown for it, as it does for `initialize`.
const CODEX_CLI_VERSION_PATTERN = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?=\s|$)/u;

export const readCodexProbeTimeouts = (host: ProviderHost.ProviderHost["Service"]) =>
host.settings.get.pipe(
Effect.map((settings) => settings.codexProbeTimeouts),
Effect.orElseSucceed(() => DEFAULT_CODEX_PROBE_TIMEOUTS),
);

const CODEX_PRESENTATION = {
displayName: "Codex",
showInteractionModeToggle: true,
Expand Down Expand Up @@ -421,6 +428,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio
readonly codexHome: string;
readonly spawnedAtMs: number;
readonly deadlineMs: number;
readonly catalogRefreshMs: number;
}) {
const models = yield* withCodexDiscoveryDeadline(
requestAllCodexModels(input.client),
Expand All @@ -435,7 +443,7 @@ const requestCodexChatGptModels = Effect.fn("requestCodexChatGptModels")(functio
}),
),
input.deadlineMs,
CODEX_CATALOG_REFRESH_TIMEOUT_MS,
input.catalogRefreshMs,
);
if (refreshed === undefined) {
yield* Effect.logWarning(
Expand Down Expand Up @@ -533,7 +541,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv;
readonly skipNativeUsage?: boolean;
readonly timeouts?: CodexProbeTimeouts;
}) {
const timeouts = input.timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS;
const spawnedAtMs = DateTime.toEpochMillis(yield* DateTime.now);
const { client, initialize } = yield* withCodexAppServerClient(input);

Expand All @@ -551,7 +561,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
}

// Optional discovery must finish before the outer timeout can discard the account result.
const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS;
const deadlineMs = spawnedAtMs + timeouts.startup - CODEX_DISCOVERY_MARGIN_MS;
const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all(
[
withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs),
Expand All @@ -561,13 +571,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
codexHome: initialize.codexHome,
spawnedAtMs,
deadlineMs,
catalogRefreshMs: timeouts.catalogRefresh,
})
: withCodexDiscoveryDeadline(requestAllCodexModels(client), deadlineMs),
// The shared catalog must exclude config from the server's launch project.
withCodexDiscoveryDeadline(
client.request("config/read", { includeLayers: false }),
deadlineMs,
CODEX_CONFIG_PROBE_TIMEOUT_MS,
timeouts.config,
).pipe(
Effect.map((response) => response?.config.service_tier ?? undefined),
Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))),
Expand All @@ -579,7 +590,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
: withCodexDiscoveryDeadline(
client.request("account/rateLimits/read", null),
deadlineMs,
RATE_LIMITS_PROBE_TIMEOUT_MS,
timeouts.rateLimits,
).pipe(
Effect.map((response): CodexRateLimitsProbe =>
response
Expand Down Expand Up @@ -644,20 +655,27 @@ const probeCodexCliVersion = Effect.fn("probeCodexCliVersion")(
),
);

export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv;
}) {
const { client } = yield* withCodexAppServerClient({
...input,
environment: providerProbeEnvironment(input.environment ?? process.env),
});
const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
return parseCodexSkillsListResponse(skillsResponse, input.cwd);
});
export const probeCodexSkillsForCwd = Effect.fn("probeCodexSkillsForCwd")(
function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv;
readonly timeouts?: CodexProbeTimeouts;
}) {
const { client } = yield* withCodexAppServerClient({
...input,
environment: providerProbeEnvironment(input.environment ?? process.env),
});
const skillsResponse = yield* client.request("skills/list", { cwds: [input.cwd] });
return parseCodexSkillsListResponse(skillsResponse, input.cwd);
},
(effect, input) =>
effect.pipe(
Effect.timeout(Duration.millis((input.timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS).skills)),
),
);

const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] =>
appendCustomCodexModels([], codexSettings.customModels);
Expand Down Expand Up @@ -745,18 +763,21 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv;
readonly skipNativeUsage?: boolean;
readonly timeouts?: CodexProbeTimeouts;
}) => Effect.Effect<
CodexAppServerProviderSnapshot,
CodexErrors.CodexAppServerError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | Scope.Scope
> = probeCodexAppServerProvider,
environment?: NodeJS.ProcessEnv,
managedAuth?: ServerProvider["auth"],
timeouts?: CodexProbeTimeouts,
): Effect.fn.Return<
ServerProviderDraft,
ServerSettingsError,
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path
> {
const resolvedTimeouts = timeouts ?? DEFAULT_CODEX_PROBE_TIMEOUTS;
const resolvedEnvironment = providerProbeEnvironment(environment ?? process.env);
const checkedAt = DateTime.formatIso(yield* DateTime.now);
const emptyModels = emptyCodexModelsFromSettings(codexSettings);
Expand Down Expand Up @@ -785,9 +806,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
cwd: process.cwd(),
environment: resolvedEnvironment,
...(managedAuth ? { skipNativeUsage: true } : {}),
timeouts: resolvedTimeouts,
}).pipe(
// Process teardown can outlast the work budget without discarding a completed snapshot.
Effect.timeoutOption(Duration.millis(CODEX_AUTH_PROBE_TIMEOUT_MS)),
Effect.timeoutOption(Duration.millis(resolvedTimeouts.startup)),
Effect.scoped,
Effect.result,
);
Expand Down
56 changes: 35 additions & 21 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts";
import * as Digest from "effect/crypto/Digest";
import * as Duration from "effect/Duration";
import * as SecureRandom from "effect/crypto/SecureRandom";
import type * as Signature from "effect/crypto/Signature";
import * as Effect from "effect/Effect";
Expand All @@ -26,6 +27,7 @@ import {
checkCodexProviderStatus,
makePendingCodexProvider,
probeCodexSkillsForCwd,
readCodexProbeTimeouts,
withCodexAppServerClient,
} from "../CodexProvider.ts";
import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts";
Expand Down Expand Up @@ -147,6 +149,8 @@ export const CodexDriver: ProviderDriver<
config,
});
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const providerHost = yield* ProviderHost.ProviderHost;
const probeTimeouts = readCodexProbeTimeouts(providerHost);
const resetCreditCoordinator = yield* ResetCreditCoordinator.ResetCreditCoordinator;
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
Expand Down Expand Up @@ -234,7 +238,11 @@ export const CodexDriver: ProviderDriver<
const checkProvider = modelCatalog.refreshInBackground.pipe(
Effect.andThen(
Effect.zipWith(
checkCodexProviderStatus(effectiveConfig, undefined, processEnv),
probeTimeouts.pipe(
Effect.flatMap((timeouts) =>
checkCodexProviderStatus(effectiveConfig, undefined, processEnv, undefined, timeouts),
),
),
currentCatalog,
(draft, catalog) => stampIdentity(applyCodexModelCatalog(draft, catalog)),
{ concurrent: true },
Expand Down Expand Up @@ -289,15 +297,18 @@ export const CodexDriver: ProviderDriver<
? snapshot.getSnapshot
: Effect.all([
snapshot.getSnapshot,
probeCodexSkillsForCwd({
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
cwd,
environment: processEnv,
}).pipe(
probeTimeouts.pipe(
Effect.flatMap((timeouts) =>
probeCodexSkillsForCwd({
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
cwd,
environment: processEnv,
timeouts,
}),
),
Effect.scoped,
Effect.timeout("20 seconds"),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
),
]).pipe(
Expand All @@ -321,18 +332,21 @@ export const CodexDriver: ProviderDriver<
resetCreditCoordinator
.redeem(accountKey, (idempotencyKey) =>
Effect.gen(function* () {
const { client } = yield* withCodexAppServerClient({
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
cwd: process.cwd(),
environment: processEnv,
});
const response = yield* client.request("account/rateLimitResetCredit/consume", {
idempotencyKey,
});
return response.outcome;
}).pipe(Effect.scoped, Effect.timeout("20 seconds")),
const timeouts = yield* probeTimeouts;
return yield* Effect.gen(function* () {
const { client } = yield* withCodexAppServerClient({
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv),
cwd: process.cwd(),
environment: processEnv,
});
const response = yield* client.request("account/rateLimitResetCredit/consume", {
idempotencyKey,
});
return response.outcome;
}).pipe(Effect.scoped, Effect.timeout(Duration.millis(timeouts.resetCredit)));
}),
)
.pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down
20 changes: 13 additions & 7 deletions apps/server/src/provider/Drivers/CodexManagedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
checkCodexProviderStatus,
makePendingCodexProvider,
probeCodexSkillsForCwd,
readCodexProbeTimeouts,
} from "../CodexProvider.ts";
import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider";
import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment";
Expand Down Expand Up @@ -132,6 +133,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
undefined,
effective.environment,
managedAuth,
yield* readCodexProbeTimeouts(host),
);
const credentials = yield* runtime.auth.access;
// Another server can replace the saved profile while the native probe runs.
Expand Down Expand Up @@ -314,13 +316,17 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu
),
),
onSuccess: (effective) =>
probeCodexSkillsForCwd({
binaryPath: effective.config.binaryPath,
homePath: effective.config.homePath,
launchArgs: effective.config.launchArgs,
cwd,
environment: effective.environment,
}).pipe(
readCodexProbeTimeouts(host).pipe(
Effect.flatMap((timeouts) =>
probeCodexSkillsForCwd({
binaryPath: effective.config.binaryPath,
homePath: effective.config.homePath,
launchArgs: effective.config.launchArgs,
cwd,
environment: effective.environment,
timeouts,
}),
),
Effect.flatMap((skills) =>
snapshot.getSnapshot.pipe(Effect.map((draft) => ({ ...draft, skills }))),
),
Expand Down
Loading
Loading