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
60 changes: 30 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "1.2.0",
"version": "1.2.1",
"description": "An ACP-compatible coding agent powered by Codex",
"main": "dist/index.js",
"bin": {
Expand Down Expand Up @@ -69,7 +69,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^1.2.1",
"@openai/codex": "^0.144.0",
"@openai/codex": "^0.144.4",
"diff": "^9.0.0",
"open": "^11.0.0",
"vscode-jsonrpc": "^9.0.1",
Expand Down
160 changes: 127 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 @@ -44,6 +44,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 @@ -88,7 +103,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 @@ -97,15 +112,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 @@ -115,33 +128,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 @@ -150,7 +145,6 @@ export class CodexAcpClient {
type: "apiKey",
apiKey,
});
this.gatewayConfig = null;
const result = await loginCompletedPromise;
return result.success;
}
Expand Down Expand Up @@ -229,8 +223,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 @@ -544,6 +626,10 @@ export class CodexAcpClient {
await this.waitForSessionNotifications(sessionId);
return await elicitationHandler.handleElicitation(params);
},
handleUserInput: async (params) => {
await this.waitForSessionNotifications(sessionId);
return await elicitationHandler.handleUserInput(params);
},
});
}

Expand Down Expand Up @@ -761,7 +847,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 @@ -866,13 +952,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 @@ -965,6 +1053,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
Loading