Skip to content
Merged
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
156 changes: 123 additions & 33 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down 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<acp.LlmProtocol, WireApi> = {
openai: "responses",
};

/**
* API for accessing the Codex App Server using ACP requests.
* Converts ACP requests into corresponding app-server operations.
Expand Down Expand Up @@ -82,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();
Expand All @@ -91,15 +106,13 @@ 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();
const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"});
if (loginResponse.type == "chatgpt") {
await open(loginResponse.authUrl);
}
this.gatewayConfig = null;
const result = await loginCompletedPromise;
return result.success;
}
Expand All @@ -109,33 +122,15 @@ 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,
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<Boolean> {
Expand All @@ -144,7 +139,6 @@ export class CodexAcpClient {
type: "apiKey",
apiKey,
});
this.gatewayConfig = null;
const result = await loginCompletedPromise;
return result.success;
}
Expand Down Expand Up @@ -223,8 +217,96 @@ 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
* unsupported protocol or a malformed base URL.
*/
private applyGatewayConfig(params: {
baseUrl: string;
headers?: Record<string, string> | undefined;
providerName?: string | undefined;
apiType: acp.LlmProtocol;
}): void {
const apiType = params.apiType;
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> {
Expand Down Expand Up @@ -732,7 +814,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 @@ -837,13 +919,15 @@ function shouldDeduplicateMcpConflicts(): boolean {
return !disabledByEnv;
}

type WireApi = "responses";

interface GatewayConfig {
modelProvider: string;
config: {
name: string,
base_url: string,
http_headers: Record<string, string>,
wire_api: "responses"
wire_api: WireApi
}
}

Expand Down Expand Up @@ -936,6 +1020,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
2 changes: 1 addition & 1 deletion src/CodexAuthMethod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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
Loading