From 7c4bc467c569e6c6d600a53f14704631b44a4e64 Mon Sep 17 00:00:00 2001 From: "mark.tkachenko" Date: Wed, 8 Jul 2026 14:14:13 +0200 Subject: [PATCH 1/2] feat: implement configurable LLM providers (providers/list, set, disable) Implement the Configurable LLM Providers RFD --- src/CodexAcpClient.ts | 138 ++++++++++++-- src/CodexAcpServer.ts | 16 +- .../CodexACPAgent/initialize.test.ts | 1 + src/__tests__/CodexACPAgent/providers.test.ts | 168 ++++++++++++++++++ src/index.ts | 3 + 5 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/providers.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 2d5dbd64..2aa8e08d 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -41,6 +41,21 @@ import type { import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; +/** + * Well-known provider id for the client-configurable custom LLM gateway. + * This is the only provider exposed through the ACP `providers/*` methods and + * the `gateway` auth method; it maps to a Codex `model_providers` entry. + */ +export const CUSTOM_GATEWAY_PROVIDER_ID = "custom-gateway"; + +/** + * ACP `LlmProtocol` values Codex can route through the custom gateway, mapped to + * the Codex `wire_api`. Codex only supports the OpenAI Responses wire API here. + */ +const SUPPORTED_GATEWAY_PROTOCOLS: Record = { + openai: "responses", +}; + /** * API for accessing the Codex App Server using ACP requests. * Converts ACP requests into corresponding app-server operations. @@ -109,24 +124,11 @@ export class CodexAcpClient { const gatewaySettings = authRequest._meta["gateway"]; if (!gatewaySettings) throw RequestError.invalidRequest(); - const baseUrl = gatewaySettings.baseUrl; - const providerName = typeof gatewaySettings.providerName === "string" && gatewaySettings.providerName.trim().length > 0 - ? gatewaySettings.providerName - : "User-provided gateway"; - const headers: Record = { - "X-Client-Feature-ID": "codex", - ...gatewaySettings.headers - }; - - this.gatewayConfig = { - modelProvider: "custom-gateway", - config: { - name: providerName, - base_url: baseUrl, - http_headers: headers, - wire_api: "responses" - } - }; + this.applyGatewayConfig({ + baseUrl: gatewaySettings.baseUrl, + headers: gatewaySettings.headers, + providerName: gatewaySettings.providerName, + }); // Early return: model provider information will be sent to Codex later during the session creation return true; @@ -227,6 +229,98 @@ export class CodexAcpClient { return this.gatewayConfig !== null; } + /** + * Validates and stores custom gateway routing. Shared by the `gateway` auth + * method and the ACP `providers/set` method. Throws `invalid_params` for an + * unsupported protocol or a malformed base URL. + */ + private applyGatewayConfig(params: { + baseUrl: string; + headers?: Record | undefined; + providerName?: string | undefined; + apiType?: acp.LlmProtocol | undefined; + }): void { + const apiType = params.apiType ?? "openai"; + const wireApi = SUPPORTED_GATEWAY_PROTOCOLS[apiType]; + if (!wireApi) { + throw RequestError.invalidParams( + {apiType}, + `Unsupported provider apiType "${apiType}"; supported: ${Object.keys(SUPPORTED_GATEWAY_PROTOCOLS).join(", ")}`, + ); + } + if (typeof params.baseUrl !== "string" || params.baseUrl.trim().length === 0) { + throw RequestError.invalidParams(undefined, "baseUrl must be a non-empty string"); + } + const providerName = typeof params.providerName === "string" && params.providerName.trim().length > 0 + ? params.providerName + : "User-provided gateway"; + const headers: Record = { + "X-Client-Feature-ID": "codex", + ...params.headers, + }; + + this.gatewayConfig = { + modelProvider: CUSTOM_GATEWAY_PROVIDER_ID, + config: { + name: providerName, + base_url: params.baseUrl, + http_headers: headers, + wire_api: wireApi, + }, + }; + } + + /** + * `providers/list`: returns the single client-configurable custom gateway + * provider. `current` carries only non-secret routing (never headers), and is + * `null` when the provider is not configured/disabled. + */ + listProviders(): acp.ProviderInfo[] { + const gatewayConfig = this.gatewayConfig; + const current: acp.ProviderCurrentConfig | null = gatewayConfig + ? { + apiType: gatewayApiTypeFromConfig(gatewayConfig), + baseUrl: gatewayConfig.config.base_url, + } + : null; + return [ + { + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + supported: Object.keys(SUPPORTED_GATEWAY_PROTOCOLS), + required: false, + current, + }, + ]; + } + + /** + * `providers/set`: replaces the full configuration for the custom gateway + * provider. Rejects unknown provider ids with `invalid_params`. + */ + setProvider(request: acp.SetProviderRequest): void { + if (request.providerId !== CUSTOM_GATEWAY_PROVIDER_ID) { + throw RequestError.invalidParams( + {providerId: request.providerId}, + `Unknown providerId "${request.providerId}"; only "${CUSTOM_GATEWAY_PROVIDER_ID}" is configurable`, + ); + } + this.applyGatewayConfig({ + apiType: request.apiType, + baseUrl: request.baseUrl, + headers: request.headers, + }); + } + + /** + * `providers/disable`: disables the custom gateway provider. Disabling an + * unknown provider id is idempotent success (RFD behavior ยง7). + */ + disableProvider(request: acp.DisableProviderRequest): void { + if (request.providerId === CUSTOM_GATEWAY_PROVIDER_ID) { + this.gatewayConfig = null; + } + } + async getAccount(): Promise { return this.codexClient.accountRead({refreshToken: false}); } @@ -732,7 +826,7 @@ export class CodexAcpClient { const [allProviders, archivedAllProviders, customGateway] = await Promise.all([ this.codexClient.threadList({}), this.codexClient.threadList({archived: true}), - this.codexClient.threadList({modelProviders: ["custom-gateway"]}), + this.codexClient.threadList({modelProviders: [CUSTOM_GATEWAY_PROVIDER_ID]}), ]); return { @@ -936,6 +1030,12 @@ function isJsonObject(value: JsonValue | undefined): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } +function gatewayApiTypeFromConfig(gatewayConfig: GatewayConfig): acp.LlmProtocol { + const wireApi = gatewayConfig.config.wire_api; + const match = Object.entries(SUPPORTED_GATEWAY_PROTOCOLS).find(([, wire]) => wire === wireApi); + return match?.[0] ?? "openai"; +} + function mergeGatewayConfig(config: JsonObject, gatewayConfig: GatewayConfig | null): JsonObject { if (gatewayConfig !== null) { const newConfig = {...config}; diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 22d5e703..496fe635 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -15,7 +15,6 @@ import type { Thread, ThreadGoalStatus, ThreadItem, - TurnCompletedNotification, UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; @@ -205,6 +204,7 @@ export class CodexAcpServer { auth: { logout: {}, }, + providers: {}, loadSession: true, promptCapabilities: { embeddedContext: true, @@ -632,6 +632,20 @@ export class CodexAcpServer { logger.log("Logout request completed"); } + listProviders(_params: acp.ListProvidersRequest): acp.ListProvidersResponse { + return { providers: this.codexAcpClient.listProviders() }; + } + + setProvider(params: acp.SetProviderRequest): acp.SetProviderResponse { + this.codexAcpClient.setProvider(params); + return { }; + } + + disableProvider(params: acp.DisableProviderRequest): acp.DisableProviderResponse { + this.codexAcpClient.disableProvider(params); + return { }; + } + private async refreshSessionsAuthState(authProvider: string | null): Promise { if (this.sessions.size === 0) return; diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index e7050101..79b46f59 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -41,6 +41,7 @@ describe('CodexACPAgent - initialize', () => { auth: { logout: {}, }, + providers: {}, loadSession: true, promptCapabilities: { embeddedContext: true, diff --git a/src/__tests__/CodexACPAgent/providers.test.ts b/src/__tests__/CodexACPAgent/providers.test.ts new file mode 100644 index 00000000..e7addda8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/providers.test.ts @@ -0,0 +1,168 @@ +import {describe, expect, it, vi} from "vitest"; +import * as acp from "@agentclientprotocol/sdk"; +import {createCodexMockTestFixture} from "../acp-test-utils"; +import {CUSTOM_GATEWAY_PROVIDER_ID} from "../../CodexAcpClient"; + +function expectInvalidParams(fn: () => unknown): void { + let caught: unknown; + try { + fn(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(acp.RequestError); + expect((caught as acp.RequestError).code).toBe(-32602); +} + +describe("Configurable LLM providers (providers/*)", () => { + it("advertises the providers capability in initialize", async () => { + const fixture = createCodexMockTestFixture(); + const result = await fixture.getCodexAcpAgent().initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + }); + expect(result.agentCapabilities?.providers).toEqual({}); + }); + + it("lists the custom gateway provider as unconfigured before any set", () => { + const fixture = createCodexMockTestFixture(); + const response = fixture.getCodexAcpAgent().listProviders({}); + expect(response).toEqual({ + providers: [ + { + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + supported: ["openai"], + required: false, + current: null, + }, + ], + }); + }); + + it("reflects set routing in list without echoing headers", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + headers: {Authorization: "Bearer super-secret"}, + }); + + const provider = agent.listProviders({}).providers[0]!; + expect(provider.current).toEqual({ + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + }); + // The secret headers must never be echoed back through providers/list. + expect(JSON.stringify(provider)).not.toContain("super-secret"); + }); + + it("rejects an unsupported apiType with invalid_params", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expectInvalidParams(() => agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "anthropic", + baseUrl: "https://example.com", + })); + }); + + it("rejects an unknown providerId with invalid_params", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expectInvalidParams(() => agent.setProvider({ + providerId: "does-not-exist", + apiType: "openai", + baseUrl: "https://example.com", + })); + }); + + it("rejects a malformed baseUrl with invalid_params", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expectInvalidParams(() => agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: " ", + })); + }); + + it("disables the custom gateway provider and encodes it as current: null", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://example.com", + }); + expect(agent.listProviders({}).providers[0]!.current).not.toBeNull(); + + agent.disableProvider({providerId: CUSTOM_GATEWAY_PROVIDER_ID}); + expect(agent.listProviders({}).providers[0]!.current).toBeNull(); + }); + + it("treats disabling an unknown providerId as idempotent success", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expect(() => agent.disableProvider({providerId: "not-a-real-provider"})).not.toThrow(); + // The known provider remains discoverable. + expect(agent.listProviders({}).providers[0]!.providerId).toBe(CUSTOM_GATEWAY_PROVIDER_ID); + }); + + it("applies the configured gateway to Codex config on session creation", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart") + .mockRejectedValue(new Error("stop after capturing config")); + + agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + headers: {Authorization: "Bearer super-secret"}, + }); + + await expect(agent.newSession({cwd: "/workspace", mcpServers: []})).rejects.toThrow(); + + expect(threadStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + modelProvider: CUSTOM_GATEWAY_PROVIDER_ID, + config: expect.objectContaining({ + model_providers: expect.objectContaining({ + [CUSTOM_GATEWAY_PROVIDER_ID]: expect.objectContaining({ + base_url: "https://llm-gateway.corp.example.com/openai/v1", + wire_api: "responses", + http_headers: expect.objectContaining({ + "Authorization": "Bearer super-secret", + "X-Client-Feature-ID": "codex", + }), + }), + }), + }), + })); + }); + + it("shares state with the legacy gateway auth method", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpClient = fixture.getCodexAcpClient(); + + await codexAcpClient.authenticate({ + methodId: "gateway", + _meta: { + gateway: { + baseUrl: "https://gateway.internal/openai", + headers: {Authorization: "Bearer via-auth"}, + providerName: "Corp gateway", + }, + }, + } as acp.AuthenticateRequest); + + expect(codexAcpClient.listProviders()[0]!.current).toEqual({ + apiType: "openai", + baseUrl: "https://gateway.internal/openai", + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 5f8a83dd..ca0b3d37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,6 +124,9 @@ function startAcpServer() { .onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)) .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params)) .onRequest(acp.methods.agent.logout, (ctx) => getAgent().logout(ctx.params)) + .onRequest(acp.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params)) + .onRequest(acp.methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params)) + .onRequest(acp.methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params)) .onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal)) .onNotification(acp.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params)) .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) From 0f70e47333c986a61118ba317c258a0054d1d48a Mon Sep 17 00:00:00 2001 From: Alexandr Suhinin Date: Fri, 10 Jul 2026 10:28:38 +0300 Subject: [PATCH 2/2] cleanup: remove dead code & simplify types --- src/CodexAcpClient.ts | 28 +++++++++------------------- src/CodexAuthMethod.ts | 2 +- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 2aa8e08d..12593a65 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,4 +1,4 @@ -import {CODEX_API_KEY_ENV_VAR, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod"; +import {CODEX_API_KEY_ENV_VAR, GatewayAuthMethod, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod"; import type {EmbeddedResourceResource} from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk"; import {type McpServer, RequestError} from "@agentclientprotocol/sdk"; @@ -52,7 +52,7 @@ export const CUSTOM_GATEWAY_PROVIDER_ID = "custom-gateway"; * ACP `LlmProtocol` values Codex can route through the custom gateway, mapped to * the Codex `wire_api`. Codex only supports the OpenAI Responses wire API here. */ -const SUPPORTED_GATEWAY_PROTOCOLS: Record = { +const SUPPORTED_GATEWAY_PROTOCOLS: Record = { openai: "responses", }; @@ -97,7 +97,7 @@ export class CodexAcpClient { if (!isCodexAuthRequest(authRequest)) { throw RequestError.invalidRequest(); } - + this.gatewayConfig = null; switch (authRequest.methodId) { case "api-key": { const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv(); @@ -106,7 +106,6 @@ export class CodexAcpClient { case "chat-gpt": { const accountResponse = await this.codexClient.accountRead({refreshToken: true}); if (accountResponse.account?.type === "chatgpt") { - this.gatewayConfig = null; return true; } const loginCompletedPromise = this.awaitNextLoginCompleted(); @@ -114,7 +113,6 @@ export class CodexAcpClient { if (loginResponse.type == "chatgpt") { await open(loginResponse.authUrl); } - this.gatewayConfig = null; const result = await loginCompletedPromise; return result.success; } @@ -126,18 +124,13 @@ export class CodexAcpClient { this.applyGatewayConfig({ baseUrl: gatewaySettings.baseUrl, + apiType: GatewayAuthMethod._meta.gateway.protocol, headers: gatewaySettings.headers, providerName: gatewaySettings.providerName, }); - // Early return: model provider information will be sent to Codex later during the session creation return true; - } - - // Reset the gateway config to null if another authentication method was used - this.gatewayConfig = null; - return false; } private async authenticateWithApiKey(apiKey: string): Promise { @@ -146,7 +139,6 @@ export class CodexAcpClient { type: "apiKey", apiKey, }); - this.gatewayConfig = null; const result = await loginCompletedPromise; return result.success; } @@ -225,10 +217,6 @@ export class CodexAcpClient { return response.requiresOpenaiAuth && !response.account; } - hasGatewayAuth(): boolean { - return this.gatewayConfig !== null; - } - /** * Validates and stores custom gateway routing. Shared by the `gateway` auth * method and the ACP `providers/set` method. Throws `invalid_params` for an @@ -238,9 +226,9 @@ export class CodexAcpClient { baseUrl: string; headers?: Record | undefined; providerName?: string | undefined; - apiType?: acp.LlmProtocol | undefined; + apiType: acp.LlmProtocol; }): void { - const apiType = params.apiType ?? "openai"; + const apiType = params.apiType; const wireApi = SUPPORTED_GATEWAY_PROTOCOLS[apiType]; if (!wireApi) { throw RequestError.invalidParams( @@ -931,13 +919,15 @@ function shouldDeduplicateMcpConflicts(): boolean { return !disabledByEnv; } +type WireApi = "responses"; + interface GatewayConfig { modelProvider: string; config: { name: string, base_url: string, http_headers: Record, - wire_api: "responses" + wire_api: WireApi } } diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index 94c1833a..9f550583 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -34,7 +34,7 @@ export interface ChatGPTAuthRequest extends AuthenticateRequest { methodId: "chat-gpt"; } -const GatewayAuthMethod: AuthMethod = { +export const GatewayAuthMethod = { id: "gateway", name: "Custom model gateway", description: "Use a custom gateway to authenticate and access models",