Skip to content

Commit 24c35d2

Browse files
feat: implement configurable LLM providers (#272)
* feat: implement configurable LLM providers (providers/list, set, disable) Implement the Configurable LLM Providers RFD * cleanup: remove dead code & simplify types --------- Co-authored-by: Alexandr Suhinin <alexandr.suhinin@jetbrains.com>
1 parent 388c447 commit 24c35d2

6 files changed

Lines changed: 311 additions & 35 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 123 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {CODEX_API_KEY_ENV_VAR, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod";
1+
import {CODEX_API_KEY_ENV_VAR, GatewayAuthMethod, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod";
22
import type {EmbeddedResourceResource} from "@agentclientprotocol/sdk";
33
import * as acp from "@agentclientprotocol/sdk";
44
import {type McpServer, RequestError} from "@agentclientprotocol/sdk";
@@ -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<acp.LlmProtocol, WireApi> = {
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.
@@ -82,7 +97,7 @@ export class CodexAcpClient {
8297
if (!isCodexAuthRequest(authRequest)) {
8398
throw RequestError.invalidRequest();
8499
}
85-
100+
this.gatewayConfig = null;
86101
switch (authRequest.methodId) {
87102
case "api-key": {
88103
const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv();
@@ -91,15 +106,13 @@ export class CodexAcpClient {
91106
case "chat-gpt": {
92107
const accountResponse = await this.codexClient.accountRead({refreshToken: true});
93108
if (accountResponse.account?.type === "chatgpt") {
94-
this.gatewayConfig = null;
95109
return true;
96110
}
97111
const loginCompletedPromise = this.awaitNextLoginCompleted();
98112
const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"});
99113
if (loginResponse.type == "chatgpt") {
100114
await open(loginResponse.authUrl);
101115
}
102-
this.gatewayConfig = null;
103116
const result = await loginCompletedPromise;
104117
return result.success;
105118
}
@@ -109,33 +122,15 @@ export class CodexAcpClient {
109122
const gatewaySettings = authRequest._meta["gateway"];
110123
if (!gatewaySettings) throw RequestError.invalidRequest();
111124

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-
};
125+
this.applyGatewayConfig({
126+
baseUrl: gatewaySettings.baseUrl,
127+
apiType: GatewayAuthMethod._meta.gateway.protocol,
128+
headers: gatewaySettings.headers,
129+
providerName: gatewaySettings.providerName,
130+
});
130131

131-
// Early return: model provider information will be sent to Codex later during the session creation
132132
return true;
133-
134133
}
135-
136-
// Reset the gateway config to null if another authentication method was used
137-
this.gatewayConfig = null;
138-
return false;
139134
}
140135

141136
private async authenticateWithApiKey(apiKey: string): Promise<Boolean> {
@@ -144,7 +139,6 @@ export class CodexAcpClient {
144139
type: "apiKey",
145140
apiKey,
146141
});
147-
this.gatewayConfig = null;
148142
const result = await loginCompletedPromise;
149143
return result.success;
150144
}
@@ -223,8 +217,96 @@ export class CodexAcpClient {
223217
return response.requiresOpenaiAuth && !response.account;
224218
}
225219

226-
hasGatewayAuth(): boolean {
227-
return this.gatewayConfig !== null;
220+
/**
221+
* Validates and stores custom gateway routing. Shared by the `gateway` auth
222+
* method and the ACP `providers/set` method. Throws `invalid_params` for an
223+
* unsupported protocol or a malformed base URL.
224+
*/
225+
private applyGatewayConfig(params: {
226+
baseUrl: string;
227+
headers?: Record<string, string> | undefined;
228+
providerName?: string | undefined;
229+
apiType: acp.LlmProtocol;
230+
}): void {
231+
const apiType = params.apiType;
232+
const wireApi = SUPPORTED_GATEWAY_PROTOCOLS[apiType];
233+
if (!wireApi) {
234+
throw RequestError.invalidParams(
235+
{apiType},
236+
`Unsupported provider apiType "${apiType}"; supported: ${Object.keys(SUPPORTED_GATEWAY_PROTOCOLS).join(", ")}`,
237+
);
238+
}
239+
if (typeof params.baseUrl !== "string" || params.baseUrl.trim().length === 0) {
240+
throw RequestError.invalidParams(undefined, "baseUrl must be a non-empty string");
241+
}
242+
const providerName = typeof params.providerName === "string" && params.providerName.trim().length > 0
243+
? params.providerName
244+
: "User-provided gateway";
245+
const headers: Record<string, string> = {
246+
"X-Client-Feature-ID": "codex",
247+
...params.headers,
248+
};
249+
250+
this.gatewayConfig = {
251+
modelProvider: CUSTOM_GATEWAY_PROVIDER_ID,
252+
config: {
253+
name: providerName,
254+
base_url: params.baseUrl,
255+
http_headers: headers,
256+
wire_api: wireApi,
257+
},
258+
};
259+
}
260+
261+
/**
262+
* `providers/list`: returns the single client-configurable custom gateway
263+
* provider. `current` carries only non-secret routing (never headers), and is
264+
* `null` when the provider is not configured/disabled.
265+
*/
266+
listProviders(): acp.ProviderInfo[] {
267+
const gatewayConfig = this.gatewayConfig;
268+
const current: acp.ProviderCurrentConfig | null = gatewayConfig
269+
? {
270+
apiType: gatewayApiTypeFromConfig(gatewayConfig),
271+
baseUrl: gatewayConfig.config.base_url,
272+
}
273+
: null;
274+
return [
275+
{
276+
providerId: CUSTOM_GATEWAY_PROVIDER_ID,
277+
supported: Object.keys(SUPPORTED_GATEWAY_PROTOCOLS),
278+
required: false,
279+
current,
280+
},
281+
];
282+
}
283+
284+
/**
285+
* `providers/set`: replaces the full configuration for the custom gateway
286+
* provider. Rejects unknown provider ids with `invalid_params`.
287+
*/
288+
setProvider(request: acp.SetProviderRequest): void {
289+
if (request.providerId !== CUSTOM_GATEWAY_PROVIDER_ID) {
290+
throw RequestError.invalidParams(
291+
{providerId: request.providerId},
292+
`Unknown providerId "${request.providerId}"; only "${CUSTOM_GATEWAY_PROVIDER_ID}" is configurable`,
293+
);
294+
}
295+
this.applyGatewayConfig({
296+
apiType: request.apiType,
297+
baseUrl: request.baseUrl,
298+
headers: request.headers,
299+
});
300+
}
301+
302+
/**
303+
* `providers/disable`: disables the custom gateway provider. Disabling an
304+
* unknown provider id is idempotent success (RFD behavior §7).
305+
*/
306+
disableProvider(request: acp.DisableProviderRequest): void {
307+
if (request.providerId === CUSTOM_GATEWAY_PROVIDER_ID) {
308+
this.gatewayConfig = null;
309+
}
228310
}
229311

230312
async getAccount(): Promise<GetAccountResponse> {
@@ -732,7 +814,7 @@ export class CodexAcpClient {
732814
const [allProviders, archivedAllProviders, customGateway] = await Promise.all([
733815
this.codexClient.threadList({}),
734816
this.codexClient.threadList({archived: true}),
735-
this.codexClient.threadList({modelProviders: ["custom-gateway"]}),
817+
this.codexClient.threadList({modelProviders: [CUSTOM_GATEWAY_PROVIDER_ID]}),
736818
]);
737819

738820
return {
@@ -837,13 +919,15 @@ function shouldDeduplicateMcpConflicts(): boolean {
837919
return !disabledByEnv;
838920
}
839921

922+
type WireApi = "responses";
923+
840924
interface GatewayConfig {
841925
modelProvider: string;
842926
config: {
843927
name: string,
844928
base_url: string,
845929
http_headers: Record<string, string>,
846-
wire_api: "responses"
930+
wire_api: WireApi
847931
}
848932
}
849933

@@ -936,6 +1020,12 @@ function isJsonObject(value: JsonValue | undefined): value is JsonObject {
9361020
return value !== null && typeof value === "object" && !Array.isArray(value);
9371021
}
9381022

1023+
function gatewayApiTypeFromConfig(gatewayConfig: GatewayConfig): acp.LlmProtocol {
1024+
const wireApi = gatewayConfig.config.wire_api;
1025+
const match = Object.entries(SUPPORTED_GATEWAY_PROTOCOLS).find(([, wire]) => wire === wireApi);
1026+
return match?.[0] ?? "openai";
1027+
}
1028+
9391029
function mergeGatewayConfig(config: JsonObject, gatewayConfig: GatewayConfig | null): JsonObject {
9401030
if (gatewayConfig !== null) {
9411031
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";
@@ -208,6 +207,7 @@ export class CodexAcpServer {
208207
auth: {
209208
logout: {},
210209
},
210+
providers: {},
211211
loadSession: true,
212212
promptCapabilities: {
213213
embeddedContext: true,
@@ -635,6 +635,20 @@ export class CodexAcpServer {
635635
logger.log("Logout request completed");
636636
}
637637

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

src/CodexAuthMethod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export interface ChatGPTAuthRequest extends AuthenticateRequest {
3434
methodId: "chat-gpt";
3535
}
3636

37-
const GatewayAuthMethod: AuthMethod = {
37+
export const GatewayAuthMethod = {
3838
id: "gateway",
3939
name: "Custom model gateway",
4040
description: "Use a custom gateway to authenticate and access models",

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,

0 commit comments

Comments
 (0)