diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c5eaa536c3f..6be93b39a30 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -287,6 +287,32 @@ validationLayer("CodexAdapterLive validation", (it) => { }); }), ); + + it.effect("passes configured profile names to Codex app-server sessions", () => { + const runtimeFactory = makeRuntimeFactory(); + const layer = Layer.effect( + CodexAdapter, + makeCodexAdapter(decodeCodexSettings({ profileName: "work" }), { + makeRuntime: runtimeFactory.factory, + }), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("thread-profile"), + runtimeMode: "full-access", + }); + + assert.equal(runtimeFactory.factory.mock.calls[0]?.[0].profileName, "work"); + }).pipe(Effect.provide(layer)); + }); }); const sessionRuntimeFactory = makeRuntimeFactory(); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 28af1cda27b..42499d8fa7f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1385,6 +1385,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( binaryPath: codexConfig.binaryPath, ...(options?.environment ? { environment: options.environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), + ...(codexConfig.profileName ? { profileName: codexConfig.profileName } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } : {}), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 178450fb7fd..533c388dbb3 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -251,6 +251,7 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; + readonly profileName?: string; readonly cwd: string; readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -263,7 +264,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const clientContext = yield* Layer.build( CodexClient.layerCommand({ command: input.binaryPath, - args: ["app-server"], + args: [...(input.profileName ? ["-p", input.profileName] : []), "app-server"], cwd: input.cwd, env: { ...(input.environment ?? process.env), @@ -292,7 +293,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun const version = versionMatch ? versionMatch[1] : undefined; const accountResponse = yield* client.request("account/read", {}); - if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + if (!input.profileName && !accountResponse.account && accountResponse.requiresOpenaiAuth) { return { account: accountResponse, version, @@ -404,6 +405,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu probe: (input: { readonly binaryPath: string; readonly homePath?: string; + readonly profileName?: string; readonly cwd: string; readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; @@ -441,6 +443,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, + profileName: codexSettings.profileName, cwd: process.cwd(), customModels: codexSettings.customModels, environment, @@ -489,7 +492,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu } const snapshot = probeResult.success.value; - const accountStatus = accountProbeStatus(snapshot.account); + const hasProviderModels = snapshot.models.some((model) => !model.isCustom); + const account = hasProviderModels + ? { ...snapshot.account, requiresOpenaiAuth: false } + : snapshot.account; + const accountStatus = accountProbeStatus(account); return buildServerProvider({ presentation: CODEX_PRESENTATION, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 82546621d32..2d64f659930 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -225,6 +225,7 @@ describe("openCodexThread", () => { runtimeMode: "full-access", cwd: "/tmp/project", requestedModel: "gpt-5.3-codex", + modelProvider: "codex-lb", serviceTier: undefined, resumeThreadId: "stale-thread", }), @@ -235,6 +236,7 @@ describe("openCodexThread", () => { calls.map((call) => call.method), ["thread/resume", "thread/start"], ); + assert.equal((calls[0]!.payload as { modelProvider?: string }).modelProvider, "codex-lb"); }); it("propagates non-recoverable resume failures", async () => { @@ -265,6 +267,7 @@ describe("openCodexThread", () => { runtimeMode: "full-access", cwd: "/tmp/project", requestedModel: "gpt-5.3-codex", + modelProvider: undefined, serviceTier: undefined, resumeThreadId: "stale-thread", }), diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 7f71ef46b2c..592cd2ea9b1 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -97,6 +97,7 @@ export interface CodexSessionRuntimeOptions { readonly providerInstanceId?: ProviderInstanceId; readonly binaryPath: string; readonly homePath?: string; + readonly profileName?: string; readonly environment?: NodeJS.ProcessEnv; readonly cwd: string; readonly runtimeMode: RuntimeMode; @@ -286,6 +287,7 @@ function buildThreadStartParams(input: { readonly cwd: string; readonly runtimeMode: RuntimeMode; readonly model: string | undefined; + readonly modelProvider: string | undefined; readonly serviceTier: CodexServiceTier | undefined; }): EffectCodexSchema.V2ThreadStartParams { const config = runtimeModeToThreadConfig(input.runtimeMode); @@ -294,6 +296,7 @@ function buildThreadStartParams(input: { approvalPolicy: config.approvalPolicy, sandbox: config.sandbox, ...(input.model ? { model: input.model } : {}), + ...(input.modelProvider ? { modelProvider: input.modelProvider } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), }; } @@ -435,6 +438,7 @@ export const openCodexThread = (input: { readonly runtimeMode: RuntimeMode; readonly cwd: string; readonly requestedModel: string | undefined; + readonly modelProvider: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; }): Effect.Effect => { @@ -443,6 +447,7 @@ export const openCodexThread = (input: { cwd: input.cwd, runtimeMode: input.runtimeMode, model: input.requestedModel, + modelProvider: input.modelProvider, serviceTier: input.serviceTier, }); @@ -717,9 +722,12 @@ export const makeCodexSessionRuntime = ( ...(options.environment ?? process.env), ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; + const appServerArgs = options.profileName + ? ["-p", options.profileName, "app-server"] + : ["app-server"]; const child = yield* spawner .spawn( - ChildProcess.make(options.binaryPath, ["app-server"], { + ChildProcess.make(options.binaryPath, appServerArgs, { cwd: options.cwd, env, forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, @@ -1183,6 +1191,16 @@ export const makeCodexSessionRuntime = ( yield* client.notify("initialized", undefined); const requestedModel = normalizeCodexModelSlug(options.model); + const codexConfig = yield* client.request("config/read", { cwd: options.cwd }).pipe( + Effect.map((response) => response.config), + Effect.orElseSucceed(() => undefined), + ); + const modelProvider = + ( + (options.profileName + ? codexConfig?.profiles?.[options.profileName]?.model_provider + : undefined) ?? codexConfig?.model_provider + )?.trim() || undefined; const opened = yield* openCodexThread({ client, @@ -1190,6 +1208,7 @@ export const makeCodexSessionRuntime = ( runtimeMode: options.runtimeMode, cwd: options.cwd, requestedModel, + modelProvider, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), }); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 86f99c97326..2af7ad27468 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -58,6 +58,7 @@ const makeCodexConfig = (overrides: Partial): CodexSettings => ({ binaryPath: "codex", homePath: "", shadowHomePath: "", + profileName: "", customModels: [], ...overrides, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb6eb3b443d..9f78922a38e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -355,6 +355,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T account: null, requiresOpenaiAuth: true, }, + models: [], }), ), ); @@ -368,6 +369,52 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("keeps profiled Codex providers ready when models are available", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus( + { ...defaultCodexSettings, profileName: "work" }, + () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, + }, + }), + ), + ); + + assert.strictEqual(status.status, "ready"); + assert.strictEqual(status.auth.status, "unknown"); + }), + ); + + it.effect("keeps OpenAI auth required when only custom models are available", () => + Effect.gen(function* () { + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: true, + }, + models: [ + { + slug: "custom-model", + name: "custom-model", + isCustom: true, + capabilities: null, + }, + ], + }), + ), + ); + + assert.strictEqual(status.status, "error"); + assert.strictEqual(status.auth.status, "unauthenticated"); + }), + ); + it.effect( "returns ready with unknown auth when app-server does not require OpenAI auth", () => @@ -388,6 +435,21 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("passes the configured Codex profile to the app-server probe", () => + Effect.gen(function* () { + let profileName: string | undefined; + yield* checkCodexProviderStatus( + { ...defaultCodexSettings, profileName: "work" }, + (input) => { + profileName = input.profileName; + return Effect.succeed(makeCodexProbeSnapshot()); + }, + ); + + assert.strictEqual(profileName, "work"); + }), + ); + it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 7af27f0b7cf..80a4f0312ff 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -35,6 +35,9 @@ it.layer(NodeServices.layer)("server settings", (it) => { assert.deepEqual(decodePatch({ providers: { codex: { binaryPath: "/tmp/codex" } } }), { providers: { codex: { binaryPath: "/tmp/codex" } }, }); + assert.deepEqual(decodePatch({ providers: { codex: { profileName: "work" } } }), { + providers: { codex: { profileName: "work" } }, + }); assert.deepEqual( decodePatch({ @@ -118,6 +121,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "/Users/julius/.codex", shadowHomePath: "", + profileName: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { @@ -359,6 +363,7 @@ it.layer(NodeServices.layer)("server settings", (it) => { binaryPath: "/opt/homebrew/bin/codex", homePath: "", shadowHomePath: "", + profileName: "", customModels: [], }); assert.deepEqual(next.providers.claudeAgent, { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 87a2f95fbf5..133766fc8f1 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -34,6 +34,7 @@ function makeFakeCodexBinary( requireImage?: boolean; requireFastServiceTier?: boolean; requireReasoningEffort?: string; + requireProfileName?: string; forbidReasoningEffort?: boolean; stdinMustContain?: string; stdinMustNotContain?: string; @@ -54,7 +55,14 @@ function makeFakeCodexBinary( 'seen_image="0"', 'seen_fast_service_tier="0"', 'seen_reasoning_effort=""', + 'seen_profile_name=""', "while [ $# -gt 0 ]; do", + ' if [ "$1" = "-p" ]; then', + " shift", + ' seen_profile_name="$1"', + " shift", + " continue", + " fi", ' if [ "$1" = "--image" ]; then', " shift", ' if [ -n "$1" ]; then', @@ -109,6 +117,14 @@ function makeFakeCodexBinary( "fi", ] : []), + ...(input.requireProfileName !== undefined + ? [ + `if [ "$seen_profile_name" != "${input.requireProfileName}" ]; then`, + ' printf "%s\\n" "unexpected profile name: $seen_profile_name" >&2', + ` exit 8`, + "fi", + ] + : []), ...(input.forbidReasoningEffort ? [ 'if [ -n "$seen_reasoning_effort" ]; then', @@ -163,6 +179,7 @@ function withFakeCodexEnv( requireImage?: boolean; requireFastServiceTier?: boolean; requireReasoningEffort?: string; + requireProfileName?: string; forbidReasoningEffort?: boolean; stdinMustContain?: string; stdinMustNotContain?: string; @@ -173,7 +190,10 @@ function withFakeCodexEnv( const fs = yield* FileSystem.FileSystem; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); const codexPath = yield* makeFakeCodexBinary(tempDir, input); - const config = decodeCodexSettings({ binaryPath: codexPath }); + const config = decodeCodexSettings({ + binaryPath: codexPath, + ...(input.requireProfileName ? { profileName: input.requireProfileName } : {}), + }); const textGeneration = yield* makeCodexTextGeneration(config); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); @@ -219,6 +239,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { }), requireFastServiceTier: true, requireReasoningEffort: "xhigh", + requireProfileName: "work", stdinMustNotContain: "branch must be a short semantic git branch fragment", }, (textGeneration) => diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 3d1637a7fc0..5e6412fac20 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -190,6 +190,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func const command = ChildProcess.make( codexConfig.binaryPath || "codex", [ + ...(codexConfig.profileName ? ["-p", codexConfig.profileName] : []), "exec", "--ephemeral", "--skip-git-repo-check", diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 611eaf572d0..d6bfab495fa 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -113,6 +113,7 @@ function createBaseServerConfig(): ServerConfig { binaryPath: "", homePath: "", shadowHomePath: "", + profileName: "", customModels: [], }, claudeAgent: { diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 0d3bc5ae98a..eb16dc070a3 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -18,6 +18,7 @@ describe("ProviderSettingsForm helpers", () => { "binaryPath", "homePath", "shadowHomePath", + "profileName", ]); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..1a0b1fa0a6c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -191,6 +191,10 @@ export const CodexSettings = makeProviderSettingsSchema( }, }), ), + profileName: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ title: "Config profile" }), + ), customModels: Schema.Array(Schema.String).pipe( Schema.withDecodingDefault(Effect.succeed([])), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), @@ -419,6 +423,7 @@ const CodexSettingsPatch = Schema.Struct({ binaryPath: Schema.optionalKey(TrimmedString), homePath: Schema.optionalKey(TrimmedString), shadowHomePath: Schema.optionalKey(TrimmedString), + profileName: Schema.optionalKey(TrimmedString), customModels: Schema.optionalKey(Schema.Array(Schema.String)), });