feat: implement configurable LLM providers (#853)

According to [custom endpoints
rfd](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/custom-llm-endpoint.mdx)
Implement the Configurable LLM Providers RFD so clients can discover and
route the agent's LLM traffic through their own proxy or gateway without
relying solely on the ad-hoc gateway auth _meta mechanism.
This commit is contained in:
Mark Tkachenko
2026-07-16 17:37:08 +02:00
committed by GitHub
parent 30b7c06f76
commit 82cd692e50
2 changed files with 534 additions and 9 deletions

View File

@@ -9,10 +9,15 @@ import {
CompleteElicitationNotification, CompleteElicitationNotification,
CreateElicitationRequest, CreateElicitationRequest,
CreateElicitationResponse, CreateElicitationResponse,
DisableProviderRequest,
DisableProviderResponse,
ForkSessionRequest, ForkSessionRequest,
ForkSessionResponse, ForkSessionResponse,
InitializeRequest, InitializeRequest,
InitializeResponse, InitializeResponse,
ListProvidersRequest,
ListProvidersResponse,
LlmProtocol,
ListSessionsRequest, ListSessionsRequest,
ListSessionsResponse, ListSessionsResponse,
LoadSessionRequest, LoadSessionRequest,
@@ -25,9 +30,12 @@ import {
PermissionOption, PermissionOption,
PromptRequest, PromptRequest,
PromptResponse, PromptResponse,
ProviderInfo,
ReadTextFileRequest, ReadTextFileRequest,
ReadTextFileResponse, ReadTextFileResponse,
RequestError, RequestError,
SetProviderRequest,
SetProviderResponse,
RequestPermissionRequest, RequestPermissionRequest,
RequestPermissionResponse, RequestPermissionResponse,
ResumeSessionRequest, ResumeSessionRequest,
@@ -658,6 +666,50 @@ type GatewayAuthMeta = {
type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta }; type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta };
/**
* The single provider ID this agent exposes via `providers/*`. Claude Code has
* one LLM backend selected by protocol (anthropic / bedrock / vertex), so there
* is exactly one configurable provider.
*/
const PROVIDER_ID = "main";
/**
* Protocols the `main` provider can be configured with. These mirror the
* env-var mappings understood by {@link createEnvForProvider}.
*/
const SUPPORTED_PROTOCOLS: LlmProtocol[] = ["anthropic", "bedrock", "vertex"];
/**
* Vertex needs project + region that the standard `providers/set` payload
* (`apiType`/`baseUrl`/`headers`) does not model, so clients pass them through
* `_meta.claudeCode.vertex`. Required only when `apiType === "vertex"`.
*/
type SetProviderMeta = {
claudeCode?: {
vertex?: {
projectId: string;
region: string;
};
};
};
/**
* Resolved, non-secret + secret routing config for the `main` provider. This is
* the shared shape produced by both `providers/set` and the legacy gateway auth
* path, and consumed by {@link createEnvForProvider}. `null` means the provider
* is unconfigured (no client-managed routing in effect).
*/
type ProviderConfig = {
apiType: LlmProtocol;
baseUrl: string;
headers: Record<string, string>;
/** Present only for `apiType === "vertex"`. */
vertex?: {
projectId: string;
region: string;
};
};
/** /**
* Extra metadata that the agent provides for each tool_call / tool_update update. * Extra metadata that the agent provides for each tool_call / tool_update update.
*/ */
@@ -1128,6 +1180,10 @@ export class ClaudeAcpAgent {
clientCapabilities?: ClientCapabilities; clientCapabilities?: ClientCapabilities;
logger: Logger; logger: Logger;
gatewayAuthRequest?: GatewayAuthRequest; gatewayAuthRequest?: GatewayAuthRequest;
/** Client-managed LLM routing set via `providers/set`. Process-scoped and
* never persisted to disk (see the Configurable LLM Providers RFD). When
* set, it takes precedence over {@link gatewayAuthRequest}. */
providerConfig?: ProviderConfig;
/** Grace period before a `session/cancel` forces a wedged prompt loop to /** Grace period before a `session/cancel` forces a wedged prompt loop to
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
* tests can shrink it. */ * tests can shrink it. */
@@ -1268,6 +1324,10 @@ export class ClaudeAcpAgent {
auth: { auth: {
logout: {}, logout: {},
}, },
// Client-managed LLM routing via `providers/list`, `providers/set`, and
// `providers/disable`. Advertised unconditionally; there is no client
// capability prerequisite for the provider methods.
providers: {},
loadSession: true, loadSession: true,
sessionCapabilities: { sessionCapabilities: {
additionalDirectories: {}, additionalDirectories: {},
@@ -1405,11 +1465,115 @@ export class ClaudeAcpAgent {
throw new Error("Method not implemented."); throw new Error("Method not implemented.");
} }
async logout(_params: LogoutRequest): Promise<void> { /**
// Clear in-memory gateway credentials supplied via `authenticate`. The * `providers/list` — returns the single client-configurable custom gateway
// gateway method never touches the on-disk credential store, so dropping * provider (`main`). `current` carries only non-secret routing (never headers,
// this reference is the whole logout for that path. * which may hold secrets); only `apiType`/`baseUrl` are surfaced for UI
* display, and is `null` when the provider is not configured/disabled. The
* provider is optional (`required: false`): while disabled/unconfigured the
* agent falls back to its own default routing (normal Claude login).
*/
async unstable_listProviders(_params: ListProvidersRequest): Promise<ListProvidersResponse> {
const config = this.resolveProviderConfig();
const provider: ProviderInfo = {
providerId: PROVIDER_ID,
supported: SUPPORTED_PROTOCOLS,
required: false,
current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null,
};
return { providers: [provider] };
}
/**
* `providers/set` — replace the full configuration for the `main` provider.
* Rejects unknown IDs, unsupported protocols, and empty/invalid base URLs with
* `invalid_params`. Config is process-scoped and applies to sessions created or
* loaded after this call.
*/
async unstable_setProvider(params: SetProviderRequest): Promise<SetProviderResponse> {
if (params.providerId !== PROVIDER_ID) {
throw RequestError.invalidParams(
{ providerId: params.providerId },
`Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`,
);
}
if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
throw RequestError.invalidParams(
{ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS },
`Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`,
);
}
if (!isValidBaseUrl(params.baseUrl)) {
throw RequestError.invalidParams(
{ baseUrl: params.baseUrl },
"baseUrl must be a non-empty absolute http(s) URL.",
);
}
const config: ProviderConfig = {
apiType: params.apiType,
baseUrl: params.baseUrl,
headers: params.headers ?? {},
};
// Vertex requires project + region, which the standard payload cannot
// carry, so they arrive via `_meta.claudeCode.vertex`.
if (params.apiType === "vertex") {
const vertex = (params._meta as SetProviderMeta | undefined)?.claudeCode?.vertex;
if (
!vertex ||
typeof vertex.projectId !== "string" ||
vertex.projectId.trim() === "" ||
typeof vertex.region !== "string" ||
vertex.region.trim() === ""
) {
throw RequestError.invalidParams(
undefined,
"vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.",
);
}
config.vertex = { projectId: vertex.projectId, region: vertex.region };
}
this.providerConfig = config;
return {};
}
/**
* `providers/disable` — disabling the `main` provider clears any client-managed
* routing (both a `providers/set` config and the legacy gateway auth request),
* so the agent reverts to its own default routing and `providers/list` reports
* `current: null`. Disabling any other (unknown) ID is treated as a successful
* no-op per the RFD's idempotency rule.
*/
async unstable_disableProvider(params: DisableProviderRequest): Promise<DisableProviderResponse> {
if (params.providerId === PROVIDER_ID) {
this.providerConfig = undefined;
this.gatewayAuthRequest = undefined; this.gatewayAuthRequest = undefined;
}
// Unknown provider: idempotent success.
return {};
}
/**
* Resolve the effective client-managed routing config. `providers/set` takes
* precedence; otherwise fall back to the legacy gateway auth request. Returns
* `null` when neither is configured.
*/
resolveProviderConfig(): ProviderConfig | null {
if (this.providerConfig) {
return this.providerConfig;
}
return gatewayRequestToProviderConfig(this.gatewayAuthRequest);
}
async logout(_params: LogoutRequest): Promise<void> {
// Clear in-memory gateway credentials supplied via `authenticate` and any
// provider routing set via `providers/set`. Neither touches the on-disk
// credential store, so dropping these references is the whole logout for
// those paths.
this.gatewayAuthRequest = undefined;
this.providerConfig = undefined;
// For the Claude/Console login methods the credentials live in the native // For the Claude/Console login methods the credentials live in the native
// CLI's store (keychain or config dir), which only the binary can clear. // CLI's store (keychain or config dir), which only the binary can clear.
@@ -4845,7 +5009,10 @@ export class ClaudeAcpAgent {
env: { env: {
...process.env, ...process.env,
...userProvidedOptions?.env, ...userProvidedOptions?.env,
...createEnvForGateway(this.gatewayAuthRequest), // Client-managed LLM routing: `providers/set` config wins, else the
// legacy gateway auth request. Baked into the query at creation, so it
// only affects sessions started after the change (matching the RFD).
...createEnvForProvider(this.resolveProviderConfig()),
// Opt-in to session state events like when the agent is idle // Opt-in to session state events like when the agent is idle
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1", CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
}, },
@@ -5289,29 +5456,81 @@ function snapshotFromUsage(usage: {
}; };
} }
function createEnvForGateway(request?: GatewayAuthRequest) { /**
* Adapt a legacy gateway `authenticate` request into the shared
* {@link ProviderConfig} shape. Returns `null` when no gateway request is
* present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
* otherwise anthropic.
*/
function gatewayRequestToProviderConfig(request?: GatewayAuthRequest): ProviderConfig | null {
if (!request?._meta) { if (!request?._meta) {
return null;
}
return {
apiType: request.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
baseUrl: request._meta.gateway.baseUrl,
headers: request._meta.gateway.headers,
};
}
/**
* Map a resolved provider config into the Claude Code env vars that redirect API
* traffic and inject headers. Returns an empty object when routing is
* unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
* skips its normal login/credential checks when a gateway is in use.
*/
function createEnvForProvider(config: ProviderConfig | null): Record<string, string> {
if (!config) {
return {}; return {};
} }
const customHeaders = Object.entries(request._meta.gateway.headers) const customHeaders = Object.entries(config.headers)
.map(([key, value]) => `${key}: ${value}`) .map(([key, value]) => `${key}: ${value}`)
.join("\n"); .join("\n");
if (request.methodId === "gateway-bedrock") { if (config.apiType === "bedrock") {
return { return {
CLAUDE_CODE_USE_BEDROCK: "1", CLAUDE_CODE_USE_BEDROCK: "1",
AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl, ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
ANTHROPIC_CUSTOM_HEADERS: customHeaders, ANTHROPIC_CUSTOM_HEADERS: customHeaders,
}; };
} }
if (config.apiType === "vertex") {
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
// validation; fall back to empty strings defensively.
return { return {
ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl, CLAUDE_CODE_USE_VERTEX: "1",
ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
CLOUD_ML_REGION: config.vertex?.region ?? "",
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
};
}
return {
ANTHROPIC_BASE_URL: config.baseUrl,
ANTHROPIC_CUSTOM_HEADERS: customHeaders, ANTHROPIC_CUSTOM_HEADERS: customHeaders,
ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
}; };
} }
/**
* Validate a provider base URL: must be a non-empty absolute http(s) URL.
*/
function isValidBaseUrl(baseUrl: string): boolean {
if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
return false;
}
let parsed: URL;
try {
parsed = new URL(baseUrl);
} catch {
return false;
}
return parsed.protocol === "http:" || parsed.protocol === "https:";
}
/** /**
* Build the list of permission modes the agent will advertise for the given * Build the list of permission modes the agent will advertise for the given
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
@@ -6872,6 +7091,9 @@ export function runAcp() {
agent.setSessionConfigOption(ctx.params), agent.setSessionConfigOption(ctx.params),
) )
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params)) .onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
.onRequest(methods.agent.providers.list, (ctx) => agent.unstable_listProviders(ctx.params))
.onRequest(methods.agent.providers.set, (ctx) => agent.unstable_setProvider(ctx.params))
.onRequest(methods.agent.providers.disable, (ctx) => agent.unstable_disableProvider(ctx.params))
.onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params)) .onRequest(methods.agent.logout, (ctx) => agent.logout(ctx.params))
.onRequest(methods.agent.session.prompt, (ctx) => .onRequest(methods.agent.session.prompt, (ctx) =>
runPromptWithCancellation(agent, ctx.params, ctx.signal), runPromptWithCancellation(agent, ctx.params, ctx.signal),

303
src/tests/providers.test.ts Normal file
View File

@@ -0,0 +1,303 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({
initializationResult: vi.fn().mockResolvedValue({
models: [
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
],
}),
setModel: vi.fn(),
setPermissionMode: vi.fn(),
supportedCommands: vi.fn().mockResolvedValue([]),
})),
);
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: mockQuery,
}));
// `logout` shells out to `claude auth logout`; make execFile succeed so the
// real logout body runs (and clears provider config) without a live CLI.
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: vi.fn((_file: string, _args: string[], cb: (...a: unknown[]) => void) =>
cb(null, { stdout: "", stderr: "" }),
),
};
});
describe("providers", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.resetAllMocks();
});
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
const connectionMock = {
sessionUpdate: async (_: any) => {},
} as AcpClient;
const agent = new ClaudeAcpAgent(connectionMock);
return [agent, mockQuery];
}
it("advertises the providers capability in initialize", async () => {
const [agent] = await createAgentMock();
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
expect(response.agentCapabilities?.providers).toEqual({});
});
it("lists a single optional 'main' provider, unconfigured by default", async () => {
const [agent] = await createAgentMock();
const response = await agent.unstable_listProviders({});
expect(response.providers).toEqual([
{
providerId: "main",
supported: ["anthropic", "bedrock", "vertex"],
required: false,
current: null,
},
]);
});
it("reflects apiType/baseUrl after set, and never echoes headers", async () => {
const [agent] = await createAgentMock();
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gateway.example/v1",
headers: { authorization: "Bearer secret" },
});
const response = await agent.unstable_listProviders({});
expect(response.providers[0].current).toEqual({
apiType: "anthropic",
baseUrl: "https://gateway.example/v1",
});
// headers must not leak through list
expect(JSON.stringify(response.providers)).not.toContain("secret");
});
it("rejects set for an unknown providerId", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({
providerId: "openai",
apiType: "anthropic",
baseUrl: "https://gateway.example",
}),
).rejects.toMatchObject({ code: -32602 });
});
it("rejects set for an unsupported apiType", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "openai",
baseUrl: "https://gateway.example",
}),
).rejects.toMatchObject({ code: -32602 });
});
it("rejects set for an empty or non-http baseUrl", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({ providerId: "main", apiType: "anthropic", baseUrl: "" }),
).rejects.toMatchObject({ code: -32602 });
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "ftp://nope.example",
}),
).rejects.toMatchObject({ code: -32602 });
});
it("disables the 'main' provider by clearing config and reporting current: null", async () => {
const [agent] = await createAgentMock();
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gateway.example/v1",
});
expect((await agent.unstable_listProviders({})).providers[0].current).not.toBeNull();
await expect(agent.unstable_disableProvider({ providerId: "main" })).resolves.toEqual({});
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
});
it("treats disabling an unknown provider as an idempotent no-op", async () => {
const [agent] = await createAgentMock();
await expect(agent.unstable_disableProvider({ providerId: "openai" })).resolves.toEqual({});
});
it("routes anthropic provider config into session env", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gateway.example",
headers: { "x-api-key": "test" },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_AUTH_TOKEN: " ",
ANTHROPIC_BASE_URL: "https://gateway.example",
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
}),
}),
}),
);
});
it("routes bedrock provider config into session env", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.unstable_setProvider({
providerId: "main",
apiType: "bedrock",
baseUrl: "https://gateway.example",
headers: { "custom-header": "test" },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
CLAUDE_CODE_USE_BEDROCK: "1",
AWS_BEARER_TOKEN_BEDROCK: " ",
ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example",
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
}),
}),
}),
);
});
it("accepts vertex config via _meta and routes it into session env", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.unstable_setProvider({
providerId: "main",
apiType: "vertex",
baseUrl: "https://vertex.example",
headers: { "custom-header": "test" },
_meta: { claudeCode: { vertex: { projectId: "my-project", region: "us-east5" } } },
});
// list surfaces apiType/baseUrl but not the _meta extras
const listed = await agent.unstable_listProviders({});
expect(listed.providers[0].current).toEqual({
apiType: "vertex",
baseUrl: "https://vertex.example",
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
CLAUDE_CODE_USE_VERTEX: "1",
ANTHROPIC_VERTEX_BASE_URL: "https://vertex.example",
ANTHROPIC_VERTEX_PROJECT_ID: "my-project",
CLOUD_ML_REGION: "us-east5",
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
}),
}),
}),
);
});
it("rejects vertex set without _meta project/region", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "vertex",
baseUrl: "https://vertex.example",
}),
).rejects.toMatchObject({ code: -32602 });
// partial _meta (missing region) is also rejected
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "vertex",
baseUrl: "https://vertex.example",
_meta: { claudeCode: { vertex: { projectId: "my-project" } } } as any,
}),
).rejects.toMatchObject({ code: -32602 });
});
it("providers/set takes precedence over gateway auth", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { auth: { terminal: true, _meta: { gateway: true } } } as any,
});
// Gateway auth first...
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gateway.example", headers: {} } },
});
// ...then providers/set wins.
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://provider.example",
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_BASE_URL: "https://provider.example",
}),
}),
}),
);
});
it("clears provider config on logout", async () => {
const [agent] = await createAgentMock();
// Avoids resolving the native CLI binary in claudeCliPath().
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/bin/true");
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://provider.example",
});
expect((await agent.unstable_listProviders({})).providers[0].current).not.toBeNull();
await agent.logout({});
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
});
});