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
138 changes: 119 additions & 19 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, GatewayConfig["config"]["wire_api"]> = {
openai: "responses",
};

/**
* API for accessing the Codex App Server using ACP requests.
* Converts ACP requests into corresponding app-server operations.
Expand Down Expand Up @@ -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<string, string> = {
"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;
Expand Down Expand Up @@ -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<string, string> | 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<string, string> = {
"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<GetAccountResponse> {
return this.codexClient.accountRead({refreshToken: false});
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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};
Expand Down
16 changes: 15 additions & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import type {
Thread,
ThreadGoalStatus,
ThreadItem,
TurnCompletedNotification,
UserInput
} from "./app-server/v2";
import type {RateLimitsMap} from "./RateLimitsMap";
Expand Down Expand Up @@ -205,6 +204,7 @@ export class CodexAcpServer {
auth: {
logout: {},
},
providers: {},
loadSession: true,
promptCapabilities: {
embeddedContext: true,
Expand Down Expand Up @@ -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<void> {
if (this.sessions.size === 0) return;

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('CodexACPAgent - initialize', () => {
auth: {
logout: {},
},
providers: {},
loadSession: true,
promptCapabilities: {
embeddedContext: true,
Expand Down
168 changes: 168 additions & 0 deletions src/__tests__/CodexACPAgent/providers.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down