Skip to content
Draft
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/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
[WS_METHODS.serverConsumeProviderRateLimitReset]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope,
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,40 @@ const validationLayer = it.layer(
),
);

const rateLimitLayer = it.layer(
Layer.effect(
CodexAdapter,
Effect.gen(function* () {
const codexConfig = decodeCodexSettings({});
return yield* makeCodexAdapter(codexConfig, {
makeRuntime: validationRuntimeFactory.factory,
consumeRateLimitResetCredit: () => Effect.succeed("reset"),
});
}),
).pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(NodeServices.layer),
),
);

rateLimitLayer("CodexAdapterLive rate-limit resets", (it) => {
it.effect("redeems a banked reset through the Codex account API", () =>
Effect.gen(function* () {
const adapter = yield* CodexAdapter;
const consume = adapter.consumeRateLimitResetCredit;
NodeAssert.ok(consume);
const outcome = yield* consume({
creditId: "reset-1",
idempotencyKey: "attempt-1",
});

NodeAssert.equal(outcome, "reset");
}),
);
});

validationLayer("CodexAdapterLive validation", (it) => {
it.effect("returns validation error for non-codex provider on startSession", () =>
Effect.gen(function* () {
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
ProviderDriverKind,
type ProviderEvent,
ProviderInstanceId,
type ProviderRateLimitResetOutcome,
type ProviderRateLimitResetRequest,
type ProviderRuntimeEvent,
type ProviderRequestKind,
type ThreadTokenUsageSnapshot,
Expand Down Expand Up @@ -64,6 +66,7 @@ import {
} from "./CodexSessionRuntime.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
import { resolveCodexLaunchArgs } from "./codexLaunchArgs.ts";
import { consumeCodexRateLimitResetCredit } from "./CodexProvider.ts";
const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);
const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);
const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
Expand All @@ -85,6 +88,9 @@ export interface CodexAdapterLiveOptions {
>;
readonly nativeEventLogPath?: string;
readonly nativeEventLogger?: EventNdjsonLogger;
readonly consumeRateLimitResetCredit?: (
input: ProviderRateLimitResetRequest,
) => Effect.Effect<ProviderRateLimitResetOutcome, CodexErrors.CodexAppServerError>;
}

interface CodexAdapterSessionContext {
Expand Down Expand Up @@ -1957,6 +1963,37 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
const hasSession: CodexAdapterShape["hasSession"] = (threadId) =>
Effect.succeed(Boolean(sessions.get(threadId) && !sessions.get(threadId)?.stopped));

const consumeRateLimitReset: NonNullable<CodexAdapterShape["consumeRateLimitResetCredit"]> = (
input,
) =>
(options?.consumeRateLimitResetCredit
? options.consumeRateLimitResetCredit(input)
: consumeCodexRateLimitResetCredit(
{
binaryPath: codexConfig.binaryPath,
cwd: process.cwd(),
...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment),
...(options?.environment ? { environment: options.environment } : {}),
},
input,
).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
Effect.scoped,
)
).pipe(
Effect.timeout("30 seconds"),
Effect.mapError(
(cause) =>
new ProviderAdapterRequestError({
provider: PROVIDER,
method: "account/rateLimitResetCredit/consume",
detail: cause.message,
cause,
}),
),
);

const stopAll: CodexAdapterShape["stopAll"] = () =>
Effect.forEach(Array.from(sessions.values()), stopSessionInternal, {
concurrency: 1,
Expand All @@ -1981,6 +2018,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
interruptTurn,
readThread,
rollbackThread,
consumeRateLimitResetCredit: consumeRateLimitReset,
respondToRequest,
respondToUserInput,
stopSession,
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/provider/Layers/CodexProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,50 @@ import { assert, it } from "@effect/vitest";
import {
applyPreferredCodexDefaultModel,
isLegacyCodexModel,
mapCodexRateLimits,
mapCodexModelCapabilities,
} from "./CodexProvider.ts";

it("maps Codex windows and banked resets into the provider snapshot", () => {
assert.deepStrictEqual(
mapCodexRateLimits({
rateLimits: {
primary: { usedPercent: 72, resetsAt: 1_777_000_000, windowDurationMins: 300 },
secondary: { usedPercent: 46, resetsAt: null, windowDurationMins: null },
},
rateLimitResetCredits: {
availableCount: 2,
credits: [
{
id: "reset-1",
resetType: "codexRateLimits",
status: "available",
grantedAt: 1_776_000_000,
expiresAt: 1_778_000_000,
title: "Referral reset",
},
],
},
}),
{
primary: { usedPercent: 72, resetsAt: 1_777_000_000, windowDurationMins: 300 },
secondary: { usedPercent: 46 },
resetCredits: {
availableCount: 2,
credits: [
{
id: "reset-1",
status: "available",
grantedAt: 1_776_000_000,
expiresAt: 1_778_000_000,
title: "Referral reset",
},
],
},
},
);
});

it("keeps current Codex models out of legacy models", () => {
assert.deepStrictEqual(
[
Expand Down
128 changes: 88 additions & 40 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import * as CodexErrors from "effect-codex-app-server/errors";

import type {
CodexSettings,
ProviderRateLimitResetRequest,
ProviderRateLimits,
ProviderRateLimitWindow,
ServerProvider,
ServerProviderState,
ModelCapabilities,
Expand Down Expand Up @@ -45,6 +48,7 @@ const CODEX_PRESENTATION = {

export interface CodexAppServerProviderSnapshot {
readonly account: CodexSchema.V2GetAccountResponse;
readonly rateLimits?: ProviderRateLimits;
readonly version: string | undefined;
readonly models: ReadonlyArray<ServerProviderModel>;
readonly skills: ReadonlyArray<ServerProviderSkill>;
Expand Down Expand Up @@ -294,6 +298,46 @@ function parseCodexSkillsListResponse(
});
}

function mapCodexRateLimitWindow(
window: CodexSchema.V2GetAccountRateLimitsResponse["rateLimits"]["primary"],
): ProviderRateLimitWindow | undefined {
if (!window) return undefined;
return {
usedPercent: window.usedPercent,
...(window.resetsAt != null ? { resetsAt: window.resetsAt } : {}),
...(window.windowDurationMins != null ? { windowDurationMins: window.windowDurationMins } : {}),
};
}

export function mapCodexRateLimits(
response: CodexSchema.V2GetAccountRateLimitsResponse,
): ProviderRateLimits {
const credits = response.rateLimitResetCredits?.credits?.map((credit) => ({
id: credit.id,
status: credit.status,
grantedAt: credit.grantedAt,
...(credit.expiresAt != null ? { expiresAt: credit.expiresAt } : {}),
...(credit.title ? { title: credit.title } : {}),
...(credit.description ? { description: credit.description } : {}),
}));

const primary = mapCodexRateLimitWindow(response.rateLimits.primary);
const secondary = mapCodexRateLimitWindow(response.rateLimits.secondary);

return {
...(primary ? { primary } : {}),
...(secondary ? { secondary } : {}),
...(response.rateLimitResetCredits
? {
resetCredits: {
availableCount: response.rateLimitResetCredits.availableCount,
...(credits ? { credits } : {}),
},
}
: {}),
};
}

const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* (
client: CodexClient.CodexAppServerClient["Service"],
) {
Expand Down Expand Up @@ -325,18 +369,17 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
};
}

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
interface CodexAppServerConnectionInput {
readonly binaryPath: string;
readonly homePath?: string;
readonly launchArgs?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly environment?: NodeJS.ProcessEnv;
}) {
// `~` is not shell-expanded when env vars are set via `child_process.spawn`,
// so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
// "CODEX_HOME points to '~/.codex_work', but that path does not exist".
// Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`.
}

const connectCodexAppServer = Effect.fn("connectCodexAppServer")(function* (
input: CodexAppServerConnectionInput,
) {
const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The invariant comment explaining why homePath is expanded here (~ is not shell-expanded for env vars passed to child_process.spawn) was dropped while this code moved into connectCodexAppServer. Suggest restoring it so the reason for expandHomePath stays documented.

Suggested change
const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;
// `~` is not shell-expanded when env vars are set via `child_process.spawn`,
// so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
// "CODEX_HOME points to '~/.codex_work', but that path does not exist".
// Expand here for parity with `CodexTextGeneration`/`CodexSessionRuntime`.
const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined;

Posted via Macroscope — Effect Service Conventions

const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const environment = {
Expand All @@ -346,10 +389,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
const spawnCommand = yield* resolveSpawnCommand(
input.binaryPath,
codexAppServerArgs(input.launchArgs),
{
env: environment,
extendEnv: true,
},
{ env: environment, extendEnv: true },
);
const child = yield* spawner
.spawn(
Expand All @@ -374,22 +414,25 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe(
Effect.provide(clientContext),
);

const initialize = yield* client.request("initialize", {
clientInfo: {
name: "t3code_desktop",
title: "T3 Code Desktop",
version: "0.1.0",
},
capabilities: {
experimentalApi: true,
},
});
const initialize = yield* client.request("initialize", buildCodexInitializeParams());
yield* client.notify("initialized", undefined);

// Extract the version string after the first '/' in userAgent, up to the next space or the end
const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
const version = versionMatch ? versionMatch[1] : undefined;
const version = initialize.userAgent.match(/\/([^\s]+)/)?.[1];
return { client, version };
});

export const consumeCodexRateLimitResetCredit = Effect.fn("consumeCodexRateLimitResetCredit")(
function* (connection: CodexAppServerConnectionInput, input: ProviderRateLimitResetRequest) {
const { client } = yield* connectCodexAppServer(connection);
const response = yield* client.request("account/rateLimitResetCredit/consume", input);
return response.outcome;
},
);

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (
input: CodexAppServerConnectionInput & { readonly customModels?: ReadonlyArray<string> },
) {
const { client, version } = yield* connectCodexAppServer(input);

const accountResponse = yield* client.request("account/read", {});
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
Expand All @@ -401,18 +444,20 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
} satisfies CodexAppServerProviderSnapshot;
}

const [skillsResponse, models] = yield* Effect.all(
const [skillsResponse, models, rateLimits] = yield* Effect.all(
[
client.request("skills/list", {
cwds: [input.cwd],
}),
requestAllCodexModels(client),
client.request("account/rateLimits/read", undefined).pipe(Effect.option),
],
{ concurrency: "unbounded" },
);

return {
account: accountResponse,
...(Option.isSome(rateLimits) ? { rateLimits: mapCodexRateLimits(rateLimits.value) } : {}),
version,
models: applyPreferredCodexDefaultModel(
appendCustomCodexModels(models, input.customModels ?? []),
Expand Down Expand Up @@ -601,20 +646,23 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
const snapshot = probeResult.success.value;
const accountStatus = accountProbeStatus(snapshot.account);

return buildServerProvider({
presentation: CODEX_PRESENTATION,
enabled: codexSettings.enabled,
checkedAt,
models: snapshot.models,
skills: snapshot.skills,
probe: {
installed: true,
version: snapshot.version ?? null,
status: accountStatus.status,
auth: accountStatus.auth,
...(accountStatus.message ? { message: accountStatus.message } : {}),
},
});
return {
...buildServerProvider({
presentation: CODEX_PRESENTATION,
enabled: codexSettings.enabled,
checkedAt,
models: snapshot.models,
skills: snapshot.skills,
probe: {
installed: true,
version: snapshot.version ?? null,
status: accountStatus.status,
auth: accountStatus.auth,
...(accountStatus.message ? { message: accountStatus.message } : {}),
},
}),
...(snapshot.rateLimits ? { rateLimits: snapshot.rateLimits } : {}),
};
});

// NOTE: the singleton `CodexProviderLive` Layer has been removed as part of
Expand Down
Loading
Loading