diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c5eb125aba..cb32022839 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -41,6 +41,7 @@ import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService. import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; +import { CodexOpenAiEnvOverridesLive } from "../src/provider/Services/CodexOpenAiEnvOverrides.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; @@ -263,6 +264,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(makeCodexAdapterLive()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(CodexOpenAiEnvOverridesLive), Layer.provideMerge(providerSessionDirectoryLayer), ); const providerLayer = useRealCodex @@ -391,7 +393,9 @@ export const makeOrchestrationIntegrationHarness = ( ) => waitFor( pendingApprovalRepository - .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) }) + .getByRequestId({ + requestId: ApprovalRequestId.makeUnsafe(requestId), + }) .pipe( Effect.map((row) => Option.match(row, { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index a8a8ce4607..9c8f1ed13d 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -131,6 +131,8 @@ export interface CodexAppServerStartSessionInput { readonly serviceTier?: string; readonly resumeCursor?: unknown; readonly providerOptions?: ProviderSessionStartInput["providerOptions"]; + readonly openaiApiKey?: string; + readonly openaiBaseUrl?: string; readonly runtimeMode: RuntimeMode; } @@ -376,7 +378,9 @@ export function resolveCodexModelForAccount( function killChildTree(child: ChildProcessWithoutNullStreams): void { if (process.platform === "win32" && child.pid !== undefined) { try { - spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { + stdio: "ignore", + }); return; } catch { // fallback to direct kill @@ -541,6 +545,7 @@ export class CodexAppServerManager extends EventEmitter undefined); const stop = vi.fn(() => undefined); @@ -52,6 +53,7 @@ const testLayer = Layer.mergeAll( openInEditor: () => Effect.void, } satisfies OpenShape), AnalyticsService.layerTest, + CodexOpenAiEnvOverridesLive, FetchHttpClient.layer, NodeServices.layer, ); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cbb..008bab0f14 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -20,6 +20,7 @@ import { fixPath, resolveStateDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { CodexOpenAiEnvOverridesLive } from "./provider/Services/CodexOpenAiEnvOverrides"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; @@ -134,7 +135,10 @@ const ServerConfigLive = (input: CliInput) => const env = yield* CliEnvConfig.asEffect().pipe( Effect.mapError( (cause) => - new StartupError({ message: "Failed to read environment configuration", cause }), + new StartupError({ + message: "Failed to read environment configuration", + cause, + }), ), ); @@ -196,6 +200,7 @@ const ServerConfigLive = (input: CliInput) => const LayerLive = (input: CliInput) => Layer.empty.pipe( Layer.provideMerge(makeServerRuntimeServicesLayer()), + Layer.provideMerge(CodexOpenAiEnvOverridesLive), Layer.provideMerge(makeServerProviderLayer()), Layer.provideMerge(ProviderHealthLive), Layer.provideMerge(SqlitePersistence.layerConfig), @@ -220,7 +225,9 @@ export const recordStartupHeartbeat = Effect.gen(function* () { projectCount: snapshot.projects.length, })), Effect.catch((cause) => - Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( + Effect.logWarning("failed to gather startup snapshot for telemetry", { + cause, + }).pipe( Effect.as({ threadCount: 0, projectCount: 0, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3ad206d0be..9897f100a0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -23,6 +23,7 @@ import { } from "../../codexAppServerManager.ts"; import { ServerConfig } from "../../config.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CodexOpenAiEnvOverridesLive } from "../Services/CodexOpenAiEnvOverrides.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; @@ -151,6 +152,7 @@ const validationLayer = it.layer( makeCodexAdapterLive({ manager: validationManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(CodexOpenAiEnvOverridesLive), Layer.provideMerge(NodeServices.layer), ), ); @@ -192,6 +194,7 @@ const sessionErrorLayer = it.layer( makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(CodexOpenAiEnvOverridesLive), Layer.provideMerge(NodeServices.layer), ), ); @@ -259,6 +262,7 @@ const lifecycleLayer = it.layer( makeCodexAdapterLive({ manager: lifecycleManager }).pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(CodexOpenAiEnvOverridesLive), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 1e4b80ae9c..031aa2412b 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -37,6 +37,7 @@ import { } from "../../codexAppServerManager.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { CodexOpenAiEnvOverrides } from "../Services/CodexOpenAiEnvOverrides.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "codex" as const; @@ -1261,6 +1262,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const serverConfig = yield* Effect.service(ServerConfig); + const codexOpenAiEnvOverrides = yield* CodexOpenAiEnvOverrides; const nativeEventLogger = options?.nativeEventLogger ?? (options?.nativeEventLogPath !== undefined @@ -1287,39 +1289,45 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }), ); - const startSession: CodexAdapterShape["startSession"] = (input) => { - if (input.provider !== undefined && input.provider !== PROVIDER) { - return Effect.fail( - new ProviderAdapterValidationError({ + const startSession: CodexAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ provider: PROVIDER, operation: "startSession", issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, - }), - ); - } + }); + } - const managerInput: CodexAppServerStartSessionInput = { - threadId: input.threadId, - provider: "codex", - ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), - ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), - ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), - runtimeMode: input.runtimeMode, - ...(input.model !== undefined ? { model: input.model } : {}), - ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), - }; + const openAiOverrides = yield* codexOpenAiEnvOverrides.get; + const managerInput: CodexAppServerStartSessionInput = { + threadId: input.threadId, + provider: "codex", + ...(input.cwd !== undefined ? { cwd: input.cwd } : {}), + ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(input.providerOptions !== undefined + ? { providerOptions: input.providerOptions } + : {}), + ...(openAiOverrides.openaiApiKey ? { openaiApiKey: openAiOverrides.openaiApiKey } : {}), + ...(openAiOverrides.openaiBaseUrl + ? { openaiBaseUrl: openAiOverrides.openaiBaseUrl } + : {}), + runtimeMode: input.runtimeMode, + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), + }; - return Effect.tryPromise({ - try: () => manager.startSession(managerInput), - catch: (cause) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: toMessage(cause, "Failed to start Codex adapter session."), - cause, - }), - }).pipe(Effect.map((session) => session)); - }; + return yield* Effect.tryPromise({ + try: () => manager.startSession(managerInput), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: toMessage(cause, "Failed to start Codex adapter session."), + cause, + }), + }).pipe(Effect.map((session) => session)); + }); const sendTurn: CodexAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Services/CodexOpenAiEnvOverrides.ts b/apps/server/src/provider/Services/CodexOpenAiEnvOverrides.ts new file mode 100644 index 0000000000..241e54dde3 --- /dev/null +++ b/apps/server/src/provider/Services/CodexOpenAiEnvOverrides.ts @@ -0,0 +1,43 @@ +import { Effect, Layer, Ref, ServiceMap } from "effect"; +import type { CodexSetOpenAiEnvResult } from "@t3tools/contracts"; + +export interface CodexOpenAiEnvOverridesState { + readonly openaiApiKey: string | null; + readonly openaiBaseUrl: string | null; +} + +export interface CodexOpenAiEnvOverridesShape { + readonly get: Effect.Effect; + readonly set: ( + input: Readonly, + ) => Effect.Effect; +} + +export class CodexOpenAiEnvOverrides extends ServiceMap.Service< + CodexOpenAiEnvOverrides, + CodexOpenAiEnvOverridesShape +>()("t3/provider/Services/CodexOpenAiEnvOverrides") {} + +export const CodexOpenAiEnvOverridesLive = Layer.effect( + CodexOpenAiEnvOverrides, + Effect.gen(function* () { + const ref = yield* Ref.make({ + openaiApiKey: null, + openaiBaseUrl: null, + }); + + const get: CodexOpenAiEnvOverridesShape["get"] = Ref.get(ref); + const set: CodexOpenAiEnvOverridesShape["set"] = (input) => + Ref.set(ref, { + openaiApiKey: input.openaiApiKey, + openaiBaseUrl: input.openaiBaseUrl, + }).pipe( + Effect.as({ + openaiApiKeySet: Boolean(input.openaiApiKey && input.openaiApiKey.trim().length > 0), + openaiBaseUrl: input.openaiBaseUrl, + }), + ); + + return { get, set } satisfies CodexOpenAiEnvOverridesShape; + }), +); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index ff9b10d96f..29c3fe056e 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -25,6 +25,7 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; +import { CodexOpenAiEnvOverrides } from "./provider/Services/CodexOpenAiEnvOverrides"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { KeybindingsLive } from "./keybindings"; @@ -40,7 +41,11 @@ import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, ProviderUnsupportedError, - SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService + | SqlClient.SqlClient + | ServerConfig + | FileSystem.FileSystem + | AnalyticsService + | CodexOpenAiEnvOverrides > { return Effect.gen(function* () { const { stateDir } = yield* ServerConfig; diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f12792a318..fecd90c75e 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -46,6 +46,7 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistenc import { SqlClient, SqlError } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; import { ProviderHealth, type ProviderHealthShape } from "./provider/Services/ProviderHealth"; +import { CodexOpenAiEnvOverridesLive } from "./provider/Services/CodexOpenAiEnvOverrides"; import { Open, type OpenShape } from "./open"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; @@ -534,6 +535,7 @@ describe("WebSocket Server", () => { Layer.provideMerge(providerHealthLayer), Layer.provideMerge(openLayer), Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(CodexOpenAiEnvOverridesLive), Layer.provideMerge(AnalyticsService.layerTest), Layer.provideMerge(NodeServices.layer), ); @@ -638,7 +640,11 @@ describe("WebSocket Server", () => { const staticDir = makeTempDir("t3code-static-root-"); fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ + cwd: "/test/project", + stateDir, + staticDir, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -653,7 +659,11 @@ describe("WebSocket Server", () => { const staticDir = makeTempDir("t3code-static-traversal-"); fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ + cwd: "/test/project", + stateDir, + staticDir, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -979,7 +989,10 @@ describe("WebSocket Server", () => { "[]", (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, ); - expect(successPush.data).toEqual({ issues: [], providers: defaultProviderStatuses }); + expect(successPush.data).toEqual({ + issues: [], + providers: defaultProviderStatuses, + }); }); it("routes shell.openInEditor through the injected open service", async () => { @@ -992,7 +1005,10 @@ describe("WebSocket Server", () => { }, }; - server = await createTestServer({ cwd: "/my/workspace", open: openService }); + server = await createTestServer({ + cwd: "/my/workspace", + open: openService, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1306,7 +1322,10 @@ describe("WebSocket Server", () => { } as unknown as ProviderRuntimeEvent); const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { - const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; + const event = push.data as { + type?: string; + payload?: { messageId?: string; text?: string }; + }; return ( event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1" ); @@ -1470,7 +1489,10 @@ describe("WebSocket Server", () => { }; try { - server = await createTestServer({ cwd: "/test", open: brokenOpenService }); + server = await createTestServer({ + cwd: "/test", + open: brokenOpenService, + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1541,7 +1563,9 @@ describe("WebSocket Server", () => { it("supports projects.searchEntries", async () => { const workspace = makeTempDir("t3code-ws-workspace-entries-"); - fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); + fs.mkdirSync(path.join(workspace, "src", "components"), { + recursive: true, + }); fs.writeFileSync( path.join(workspace, "src", "components", "Composer.tsx"), "export {};", @@ -1567,7 +1591,10 @@ describe("WebSocket Server", () => { expect(response.result).toEqual({ entries: expect.arrayContaining([ expect.objectContaining({ path: "src/components", kind: "directory" }), - expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), + expect.objectContaining({ + path: "src/components/Composer.tsx", + kind: "file", + }), ]), truncated: false, }); @@ -1655,16 +1682,26 @@ describe("WebSocket Server", () => { const [ws] = await connectAndAwaitWelcome(port); connections.push(ws); - const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); + const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { + cwd: "/repo/path", + }); expect(listResponse.error).toBeUndefined(); - expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false }); + expect(listResponse.result).toEqual({ + branches: [], + isRepo: false, + hasOriginRemote: false, + }); expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); - const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); + const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { + cwd: "/repo/path", + }); expect(initResponse.error).toBeUndefined(); expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); - const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); + const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { + cwd: "/repo/path", + }); expect(pullResponse.result).toBeUndefined(); expect(pullResponse.error?.message).toContain("No upstream configured"); expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); @@ -1803,7 +1840,10 @@ describe("WebSocket Server", () => { }); it("rejects websocket connections without a valid auth token", async () => { - server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); + server = await createTestServer({ + cwd: "/test", + authToken: "secret-token", + }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..2620012bd5 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -55,6 +55,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { ProviderService } from "./provider/Services/ProviderService"; import { ProviderHealth } from "./provider/Services/ProviderHealth"; +import { CodexOpenAiEnvOverrides } from "./provider/Services/CodexOpenAiEnvOverrides"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { clamp } from "effect/Number"; import { Open, resolveAvailableEditors } from "./open"; @@ -208,7 +209,8 @@ export type ServerCoreRuntimeServices = | CheckpointDiffQuery | OrchestrationReactor | ProviderService - | ProviderHealth; + | ProviderHealth + | CodexOpenAiEnvOverrides; export type ServerRuntimeServices = | ServerCoreRuntimeServices @@ -254,6 +256,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; + const codexOpenAiEnvOverrides = yield* CodexOpenAiEnvOverrides; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -291,7 +294,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< yield* readiness.markPushBusReady; yield* keybindingsManager.start.pipe( Effect.mapError( - (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), + (cause) => + new ServerLifecycleError({ + operation: "keybindingsRuntimeStart", + cause, + }), ), ); yield* readiness.markKeybindingsReady; @@ -582,7 +589,10 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (error && !isServerNotRunningError(error)) { resume( Effect.fail( - new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }), + new ServerLifecycleError({ + operation: "closeWebSocketServer", + cause: error, + }), ), ); } else { @@ -679,7 +689,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } }).pipe( Effect.mapError( - (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }), + (cause) => + new ServerLifecycleError({ + operation: "autoBootstrapProject", + cause, + }), ), ); } @@ -883,6 +897,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.codexSetOpenAiEnv: { + const body = stripRequestTag(request.body); + return yield* codexOpenAiEnvOverrides.set(body); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ @@ -911,7 +930,9 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< if (Result.isFailure(request)) { return yield* sendWsResponse({ id: "unknown", - error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` }, + error: { + message: `Invalid request format: ${formatSchemaError(request.failure)}`, + }, }); } diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f92..349d51f1c1 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -21,6 +21,12 @@ const AppSettingsSchema = Schema.Struct({ codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( Schema.withConstructorDefault(() => Option.some("")), ), + codexOpenaiBaseUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + codexOpenaiApiKey: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), defaultThreadEnvMode: Schema.Literals(["local", "worktree"]).pipe( Schema.withConstructorDefault(() => Option.some("local")), ), diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f..aef96efd4e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -8,7 +8,7 @@ import { } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; import { QueryClient, useQueryClient } from "@tanstack/react-query"; -import { Throttler } from "@tanstack/react-pacer"; +import { Throttler, useDebouncedValue } from "@tanstack/react-pacer"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; @@ -24,6 +24,7 @@ import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; +import { useAppSettings } from "../appSettings"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -52,6 +53,7 @@ function RootRouteView() { + @@ -59,6 +61,37 @@ function RootRouteView() { ); } +function CodexOpenAiEnvSync() { + const { settings } = useAppSettings(); + const api = readNativeApi(); + const [debouncedApiKey] = useDebouncedValue(settings.codexOpenaiApiKey, { + wait: 300, + }); + const [debouncedBaseUrl] = useDebouncedValue(settings.codexOpenaiBaseUrl, { + wait: 300, + }); + const lastSignatureRef = useRef(null); + + useEffect(() => { + if (!api) return; + const openaiApiKey = debouncedApiKey.trim(); + const openaiBaseUrl = debouncedBaseUrl.trim(); + const signature = `${openaiBaseUrl}::${openaiApiKey}`; + if (signature === lastSignatureRef.current) { + return; + } + lastSignatureRef.current = signature; + void api.server + .setCodexOpenAiEnv({ + openaiApiKey: openaiApiKey.length > 0 ? openaiApiKey : null, + openaiBaseUrl: openaiBaseUrl.length > 0 ? openaiBaseUrl : null, + }) + .catch(() => undefined); + }, [api, debouncedApiKey, debouncedBaseUrl]); + + return null; +} + function RootRouteErrorView({ error, reset }: ErrorComponentProps) { const message = errorMessage(error); const details = errorDetails(error); @@ -138,7 +171,9 @@ function EventRouter() { ); const queryClient = useQueryClient(); const navigate = useNavigate(); - const pathname = useRouterState({ select: (state) => state.location.pathname }); + const pathname = useRouterState({ + select: (state) => state.location.pathname, + }); const pathnameRef = useRef(pathname); const handledBootstrapThreadIdRef = useRef(null); @@ -192,10 +227,14 @@ function EventRouter() { () => { if (needsProviderInvalidation) { needsProviderInvalidation = false; - void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + void queryClient.invalidateQueries({ + queryKey: providerQueryKeys.all, + }); // Invalidate workspace entry queries so the @-mention file picker // reflects files created, deleted, or restored during this turn. - void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all }); + void queryClient.invalidateQueries({ + queryKey: projectQueryKeys.all, + }); } void syncSnapshot(); }, @@ -260,7 +299,9 @@ function EventRouter() { // don't produce duplicate toasts. let subscribed = false; const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + void queryClient.invalidateQueries({ + queryKey: serverQueryKeys.config(), + }); if (!subscribed) return; const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); if (!issue) { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa1..1f0940e4fd 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -98,6 +98,7 @@ function SettingsRouteView() { const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [showCodexApiKey, setShowCodexApiKey] = useState(false); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -109,6 +110,8 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const codexOpenaiBaseUrl = settings.codexOpenaiBaseUrl; + const codexOpenaiApiKey = settings.codexOpenaiApiKey; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -312,7 +315,7 @@ function SettingsRouteView() {

Codex App Server

- These overrides apply to new sessions and let you use a non-default Codex install. + These overrides apply to new sessions and let you customize how Codex connects.

@@ -345,6 +348,49 @@ function SettingsRouteView() { + + +
+ OPENAI_API_KEY +
+ + updateSettings({ + codexOpenaiApiKey: event.target.value, + }) + } + placeholder="sk-..." + spellCheck={false} + autoComplete="off" + /> + +
+ + Stored on this device only. The server does not persist it to thread history. + +
+

Binary source

@@ -360,6 +406,8 @@ function SettingsRouteView() { updateSettings({ codexBinaryPath: defaults.codexBinaryPath, codexHomePath: defaults.codexHomePath, + codexOpenaiBaseUrl: defaults.codexOpenaiBaseUrl, + codexOpenaiApiKey: defaults.codexOpenaiApiKey, }) } > diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..60a70e4a7e 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -160,16 +160,21 @@ export function createWsNativeApi(): NativeApi { server: { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + setCodexOpenAiEnv: (input) => transport.request(WS_METHODS.codexSetOpenAiEnv, input), }, orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), dispatchCommand: (command) => - transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }), + transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { + command, + }), getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), getFullThreadDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input), replayEvents: (fromSequenceExclusive) => - transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), + transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { + fromSequenceExclusive, + }), onDomainEvent: (callback) => transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (message) => callback(message.data), diff --git a/packages/contracts/src/codex.ts b/packages/contracts/src/codex.ts new file mode 100644 index 0000000000..eb86bfc96f --- /dev/null +++ b/packages/contracts/src/codex.ts @@ -0,0 +1,14 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; + +export const CodexSetOpenAiEnvInput = Schema.Struct({ + openaiApiKey: Schema.NullOr(TrimmedNonEmptyString), + openaiBaseUrl: Schema.NullOr(TrimmedNonEmptyString), +}); +export type CodexSetOpenAiEnvInput = typeof CodexSetOpenAiEnvInput.Type; + +export const CodexSetOpenAiEnvResult = Schema.Struct({ + openaiApiKeySet: Schema.Boolean, + openaiBaseUrl: Schema.NullOr(TrimmedNonEmptyString), +}); +export type CodexSetOpenAiEnvResult = typeof CodexSetOpenAiEnvResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a93515..3165be850e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,3 +11,4 @@ export * from "./git"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; +export * from "./codex"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..9d8fc5a4a6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -25,6 +25,7 @@ import type { ProjectWriteFileResult, } from "./project"; import type { ServerConfig } from "./server"; +import type { CodexSetOpenAiEnvInput, CodexSetOpenAiEnvResult } from "./codex"; import type { TerminalClearInput, TerminalCloseInput, @@ -159,6 +160,7 @@ export interface NativeApi { server: { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + setCodexOpenAiEnv: (input: CodexSetOpenAiEnvInput) => Promise; }; orchestration: { getSnapshot: () => Promise; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b8..477cbb72e8 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -37,6 +37,7 @@ import { KeybindingRule } from "./keybindings"; import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; +import { CodexSetOpenAiEnvInput } from "./codex"; // ── WebSocket RPC Method Names ─────────────────────────────────────── @@ -75,6 +76,9 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", + + // Codex meta + codexSetOpenAiEnv: "codex.setOpenAiEnv", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -139,6 +143,9 @@ const WebSocketRequestBody = Schema.Union([ // Server meta tagRequestBody(WS_METHODS.serverGetConfig, Schema.Struct({})), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), + + // Codex meta + tagRequestBody(WS_METHODS.codexSetOpenAiEnv, CodexSetOpenAiEnvInput), ]); export const WebSocketRequest = Schema.Struct({