Skip to content

Commit 7c4bc46

Browse files
committed
feat: implement configurable LLM providers (providers/list, set, disable)
Implement the Configurable LLM Providers RFD
1 parent 843a504 commit 7c4bc46

5 files changed

Lines changed: 306 additions & 20 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 119 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ import type {
4141
import packageJson from "../package.json";
4242
import type {AuthenticationStatusResponse} from "./AcpExtensions";
4343

44+
/**
45+
* Well-known provider id for the client-configurable custom LLM gateway.
46+
* This is the only provider exposed through the ACP `providers/*` methods and
47+
* the `gateway` auth method; it maps to a Codex `model_providers` entry.
48+
*/
49+
export const CUSTOM_GATEWAY_PROVIDER_ID = "custom-gateway";
50+
51+
/**
52+
* ACP `LlmProtocol` values Codex can route through the custom gateway, mapped to
53+
* the Codex `wire_api`. Codex only supports the OpenAI Responses wire API here.
54+
*/
55+
const SUPPORTED_GATEWAY_PROTOCOLS: Record<string, GatewayConfig["config"]["wire_api"]> = {
56+
openai: "responses",
57+
};
58+
4459
/**
4560
* API for accessing the Codex App Server using ACP requests.
4661
* Converts ACP requests into corresponding app-server operations.
@@ -109,24 +124,11 @@ export class CodexAcpClient {
109124
const gatewaySettings = authRequest._meta["gateway"];
110125
if (!gatewaySettings) throw RequestError.invalidRequest();
111126

112-
const baseUrl = gatewaySettings.baseUrl;
113-
const providerName = typeof gatewaySettings.providerName === "string" && gatewaySettings.providerName.trim().length > 0
114-
? gatewaySettings.providerName
115-
: "User-provided gateway";
116-
const headers: Record<string, string> = {
117-
"X-Client-Feature-ID": "codex",
118-
...gatewaySettings.headers
119-
};
120-
121-
this.gatewayConfig = {
122-
modelProvider: "custom-gateway",
123-
config: {
124-
name: providerName,
125-
base_url: baseUrl,
126-
http_headers: headers,
127-
wire_api: "responses"
128-
}
129-
};
127+
this.applyGatewayConfig({
128+
baseUrl: gatewaySettings.baseUrl,
129+
headers: gatewaySettings.headers,
130+
providerName: gatewaySettings.providerName,
131+
});
130132

131133
// Early return: model provider information will be sent to Codex later during the session creation
132134
return true;
@@ -227,6 +229,98 @@ export class CodexAcpClient {
227229
return this.gatewayConfig !== null;
228230
}
229231

232+
/**
233+
* Validates and stores custom gateway routing. Shared by the `gateway` auth
234+
* method and the ACP `providers/set` method. Throws `invalid_params` for an
235+
* unsupported protocol or a malformed base URL.
236+
*/
237+
private applyGatewayConfig(params: {
238+
baseUrl: string;
239+
headers?: Record<string, string> | undefined;
240+
providerName?: string | undefined;
241+
apiType?: acp.LlmProtocol | undefined;
242+
}): void {
243+
const apiType = params.apiType ?? "openai";
244+
const wireApi = SUPPORTED_GATEWAY_PROTOCOLS[apiType];
245+
if (!wireApi) {
246+
throw RequestError.invalidParams(
247+
{apiType},
248+
`Unsupported provider apiType "${apiType}"; supported: ${Object.keys(SUPPORTED_GATEWAY_PROTOCOLS).join(", ")}`,
249+
);
250+
}
251+
if (typeof params.baseUrl !== "string" || params.baseUrl.trim().length === 0) {
252+
throw RequestError.invalidParams(undefined, "baseUrl must be a non-empty string");
253+
}
254+
const providerName = typeof params.providerName === "string" && params.providerName.trim().length > 0
255+
? params.providerName
256+
: "User-provided gateway";
257+
const headers: Record<string, string> = {
258+
"X-Client-Feature-ID": "codex",
259+
...params.headers,
260+
};
261+
262+
this.gatewayConfig = {
263+
modelProvider: CUSTOM_GATEWAY_PROVIDER_ID,
264+
config: {
265+
name: providerName,
266+
base_url: params.baseUrl,
267+
http_headers: headers,
268+
wire_api: wireApi,
269+
},
270+
};
271+
}
272+
273+
/**
274+
* `providers/list`: returns the single client-configurable custom gateway
275+
* provider. `current` carries only non-secret routing (never headers), and is
276+
* `null` when the provider is not configured/disabled.
277+
*/
278+
listProviders(): acp.ProviderInfo[] {
279+
const gatewayConfig = this.gatewayConfig;
280+
const current: acp.ProviderCurrentConfig | null = gatewayConfig
281+
? {
282+
apiType: gatewayApiTypeFromConfig(gatewayConfig),
283+
baseUrl: gatewayConfig.config.base_url,
284+
}
285+
: null;
286+
return [
287+
{
288+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
289+
supported: Object.keys(SUPPORTED_GATEWAY_PROTOCOLS),
290+
required: false,
291+
current,
292+
},
293+
];
294+
}
295+
296+
/**
297+
* `providers/set`: replaces the full configuration for the custom gateway
298+
* provider. Rejects unknown provider ids with `invalid_params`.
299+
*/
300+
setProvider(request: acp.SetProviderRequest): void {
301+
if (request.providerId !== CUSTOM_GATEWAY_PROVIDER_ID) {
302+
throw RequestError.invalidParams(
303+
{providerId: request.providerId},
304+
`Unknown providerId "${request.providerId}"; only "${CUSTOM_GATEWAY_PROVIDER_ID}" is configurable`,
305+
);
306+
}
307+
this.applyGatewayConfig({
308+
apiType: request.apiType,
309+
baseUrl: request.baseUrl,
310+
headers: request.headers,
311+
});
312+
}
313+
314+
/**
315+
* `providers/disable`: disables the custom gateway provider. Disabling an
316+
* unknown provider id is idempotent success (RFD behavior §7).
317+
*/
318+
disableProvider(request: acp.DisableProviderRequest): void {
319+
if (request.providerId === CUSTOM_GATEWAY_PROVIDER_ID) {
320+
this.gatewayConfig = null;
321+
}
322+
}
323+
230324
async getAccount(): Promise<GetAccountResponse> {
231325
return this.codexClient.accountRead({refreshToken: false});
232326
}
@@ -732,7 +826,7 @@ export class CodexAcpClient {
732826
const [allProviders, archivedAllProviders, customGateway] = await Promise.all([
733827
this.codexClient.threadList({}),
734828
this.codexClient.threadList({archived: true}),
735-
this.codexClient.threadList({modelProviders: ["custom-gateway"]}),
829+
this.codexClient.threadList({modelProviders: [CUSTOM_GATEWAY_PROVIDER_ID]}),
736830
]);
737831

738832
return {
@@ -936,6 +1030,12 @@ function isJsonObject(value: JsonValue | undefined): value is JsonObject {
9361030
return value !== null && typeof value === "object" && !Array.isArray(value);
9371031
}
9381032

1033+
function gatewayApiTypeFromConfig(gatewayConfig: GatewayConfig): acp.LlmProtocol {
1034+
const wireApi = gatewayConfig.config.wire_api;
1035+
const match = Object.entries(SUPPORTED_GATEWAY_PROTOCOLS).find(([, wire]) => wire === wireApi);
1036+
return match?.[0] ?? "openai";
1037+
}
1038+
9391039
function mergeGatewayConfig(config: JsonObject, gatewayConfig: GatewayConfig | null): JsonObject {
9401040
if (gatewayConfig !== null) {
9411041
const newConfig = {...config};

src/CodexAcpServer.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import type {
1515
Thread,
1616
ThreadGoalStatus,
1717
ThreadItem,
18-
TurnCompletedNotification,
1918
UserInput
2019
} from "./app-server/v2";
2120
import type {RateLimitsMap} from "./RateLimitsMap";
@@ -205,6 +204,7 @@ export class CodexAcpServer {
205204
auth: {
206205
logout: {},
207206
},
207+
providers: {},
208208
loadSession: true,
209209
promptCapabilities: {
210210
embeddedContext: true,
@@ -632,6 +632,20 @@ export class CodexAcpServer {
632632
logger.log("Logout request completed");
633633
}
634634

635+
listProviders(_params: acp.ListProvidersRequest): acp.ListProvidersResponse {
636+
return { providers: this.codexAcpClient.listProviders() };
637+
}
638+
639+
setProvider(params: acp.SetProviderRequest): acp.SetProviderResponse {
640+
this.codexAcpClient.setProvider(params);
641+
return { };
642+
}
643+
644+
disableProvider(params: acp.DisableProviderRequest): acp.DisableProviderResponse {
645+
this.codexAcpClient.disableProvider(params);
646+
return { };
647+
}
648+
635649
private async refreshSessionsAuthState(authProvider: string | null): Promise<void> {
636650
if (this.sessions.size === 0) return;
637651

src/__tests__/CodexACPAgent/initialize.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ describe('CodexACPAgent - initialize', () => {
4141
auth: {
4242
logout: {},
4343
},
44+
providers: {},
4445
loadSession: true,
4546
promptCapabilities: {
4647
embeddedContext: true,
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import {describe, expect, it, vi} from "vitest";
2+
import * as acp from "@agentclientprotocol/sdk";
3+
import {createCodexMockTestFixture} from "../acp-test-utils";
4+
import {CUSTOM_GATEWAY_PROVIDER_ID} from "../../CodexAcpClient";
5+
6+
function expectInvalidParams(fn: () => unknown): void {
7+
let caught: unknown;
8+
try {
9+
fn();
10+
} catch (err) {
11+
caught = err;
12+
}
13+
expect(caught).toBeInstanceOf(acp.RequestError);
14+
expect((caught as acp.RequestError).code).toBe(-32602);
15+
}
16+
17+
describe("Configurable LLM providers (providers/*)", () => {
18+
it("advertises the providers capability in initialize", async () => {
19+
const fixture = createCodexMockTestFixture();
20+
const result = await fixture.getCodexAcpAgent().initialize({
21+
protocolVersion: acp.PROTOCOL_VERSION,
22+
});
23+
expect(result.agentCapabilities?.providers).toEqual({});
24+
});
25+
26+
it("lists the custom gateway provider as unconfigured before any set", () => {
27+
const fixture = createCodexMockTestFixture();
28+
const response = fixture.getCodexAcpAgent().listProviders({});
29+
expect(response).toEqual({
30+
providers: [
31+
{
32+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
33+
supported: ["openai"],
34+
required: false,
35+
current: null,
36+
},
37+
],
38+
});
39+
});
40+
41+
it("reflects set routing in list without echoing headers", () => {
42+
const fixture = createCodexMockTestFixture();
43+
const agent = fixture.getCodexAcpAgent();
44+
agent.setProvider({
45+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
46+
apiType: "openai",
47+
baseUrl: "https://llm-gateway.corp.example.com/openai/v1",
48+
headers: {Authorization: "Bearer super-secret"},
49+
});
50+
51+
const provider = agent.listProviders({}).providers[0]!;
52+
expect(provider.current).toEqual({
53+
apiType: "openai",
54+
baseUrl: "https://llm-gateway.corp.example.com/openai/v1",
55+
});
56+
// The secret headers must never be echoed back through providers/list.
57+
expect(JSON.stringify(provider)).not.toContain("super-secret");
58+
});
59+
60+
it("rejects an unsupported apiType with invalid_params", () => {
61+
const fixture = createCodexMockTestFixture();
62+
const agent = fixture.getCodexAcpAgent();
63+
expectInvalidParams(() => agent.setProvider({
64+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
65+
apiType: "anthropic",
66+
baseUrl: "https://example.com",
67+
}));
68+
});
69+
70+
it("rejects an unknown providerId with invalid_params", () => {
71+
const fixture = createCodexMockTestFixture();
72+
const agent = fixture.getCodexAcpAgent();
73+
expectInvalidParams(() => agent.setProvider({
74+
providerId: "does-not-exist",
75+
apiType: "openai",
76+
baseUrl: "https://example.com",
77+
}));
78+
});
79+
80+
it("rejects a malformed baseUrl with invalid_params", () => {
81+
const fixture = createCodexMockTestFixture();
82+
const agent = fixture.getCodexAcpAgent();
83+
expectInvalidParams(() => agent.setProvider({
84+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
85+
apiType: "openai",
86+
baseUrl: " ",
87+
}));
88+
});
89+
90+
it("disables the custom gateway provider and encodes it as current: null", () => {
91+
const fixture = createCodexMockTestFixture();
92+
const agent = fixture.getCodexAcpAgent();
93+
agent.setProvider({
94+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
95+
apiType: "openai",
96+
baseUrl: "https://example.com",
97+
});
98+
expect(agent.listProviders({}).providers[0]!.current).not.toBeNull();
99+
100+
agent.disableProvider({providerId: CUSTOM_GATEWAY_PROVIDER_ID});
101+
expect(agent.listProviders({}).providers[0]!.current).toBeNull();
102+
});
103+
104+
it("treats disabling an unknown providerId as idempotent success", () => {
105+
const fixture = createCodexMockTestFixture();
106+
const agent = fixture.getCodexAcpAgent();
107+
expect(() => agent.disableProvider({providerId: "not-a-real-provider"})).not.toThrow();
108+
// The known provider remains discoverable.
109+
expect(agent.listProviders({}).providers[0]!.providerId).toBe(CUSTOM_GATEWAY_PROVIDER_ID);
110+
});
111+
112+
it("applies the configured gateway to Codex config on session creation", async () => {
113+
const fixture = createCodexMockTestFixture();
114+
const agent = fixture.getCodexAcpAgent();
115+
const codexAcpClient = fixture.getCodexAcpClient();
116+
const codexAppServerClient = fixture.getCodexAppServerClient();
117+
118+
vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false);
119+
const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart")
120+
.mockRejectedValue(new Error("stop after capturing config"));
121+
122+
agent.setProvider({
123+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
124+
apiType: "openai",
125+
baseUrl: "https://llm-gateway.corp.example.com/openai/v1",
126+
headers: {Authorization: "Bearer super-secret"},
127+
});
128+
129+
await expect(agent.newSession({cwd: "/workspace", mcpServers: []})).rejects.toThrow();
130+
131+
expect(threadStartSpy).toHaveBeenCalledWith(expect.objectContaining({
132+
modelProvider: CUSTOM_GATEWAY_PROVIDER_ID,
133+
config: expect.objectContaining({
134+
model_providers: expect.objectContaining({
135+
[CUSTOM_GATEWAY_PROVIDER_ID]: expect.objectContaining({
136+
base_url: "https://llm-gateway.corp.example.com/openai/v1",
137+
wire_api: "responses",
138+
http_headers: expect.objectContaining({
139+
"Authorization": "Bearer super-secret",
140+
"X-Client-Feature-ID": "codex",
141+
}),
142+
}),
143+
}),
144+
}),
145+
}));
146+
});
147+
148+
it("shares state with the legacy gateway auth method", async () => {
149+
const fixture = createCodexMockTestFixture();
150+
const codexAcpClient = fixture.getCodexAcpClient();
151+
152+
await codexAcpClient.authenticate({
153+
methodId: "gateway",
154+
_meta: {
155+
gateway: {
156+
baseUrl: "https://gateway.internal/openai",
157+
headers: {Authorization: "Bearer via-auth"},
158+
providerName: "Corp gateway",
159+
},
160+
},
161+
} as acp.AuthenticateRequest);
162+
163+
expect(codexAcpClient.listProviders()[0]!.current).toEqual({
164+
apiType: "openai",
165+
baseUrl: "https://gateway.internal/openai",
166+
});
167+
});
168+
});

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ function startAcpServer() {
124124
.onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params))
125125
.onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params))
126126
.onRequest(acp.methods.agent.logout, (ctx) => getAgent().logout(ctx.params))
127+
.onRequest(acp.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params))
128+
.onRequest(acp.methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params))
129+
.onRequest(acp.methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params))
127130
.onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal))
128131
.onNotification(acp.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params))
129132
.onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params))

0 commit comments

Comments
 (0)