diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 0cbd79ee..dc9e6d44 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -9,10 +9,15 @@ import { CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, + DisableProviderRequest, + DisableProviderResponse, ForkSessionRequest, ForkSessionResponse, InitializeRequest, InitializeResponse, + ListProvidersRequest, + ListProvidersResponse, + LlmProtocol, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, @@ -25,9 +30,12 @@ import { PermissionOption, PromptRequest, PromptResponse, + ProviderInfo, ReadTextFileRequest, ReadTextFileResponse, RequestError, + SetProviderRequest, + SetProviderResponse, RequestPermissionRequest, RequestPermissionResponse, ResumeSessionRequest, @@ -658,6 +666,50 @@ type 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; + /** Present only for `apiType === "vertex"`. */ + vertex?: { + projectId: string; + region: string; + }; +}; + /** * Extra metadata that the agent provides for each tool_call / tool_update update. */ @@ -1128,6 +1180,10 @@ export class ClaudeAcpAgent { clientCapabilities?: ClientCapabilities; logger: Logger; 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 * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so * tests can shrink it. */ @@ -1268,6 +1324,10 @@ export class ClaudeAcpAgent { auth: { 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, sessionCapabilities: { additionalDirectories: {}, @@ -1405,11 +1465,115 @@ export class ClaudeAcpAgent { throw new Error("Method not implemented."); } + /** + * `providers/list` — returns the single client-configurable custom gateway + * provider (`main`). `current` carries only non-secret routing (never headers, + * 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 { + 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 { + 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 { + if (params.providerId === PROVIDER_ID) { + this.providerConfig = 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 { - // Clear in-memory gateway credentials supplied via `authenticate`. The - // gateway method never touches the on-disk credential store, so dropping - // this reference is the whole logout for that path. + // 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 // CLI's store (keychain or config dir), which only the binary can clear. @@ -4845,7 +5009,10 @@ export class ClaudeAcpAgent { env: { ...process.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 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) { + 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 { + if (!config) { return {}; } - const customHeaders = Object.entries(request._meta.gateway.headers) + const customHeaders = Object.entries(config.headers) .map(([key, value]) => `${key}: ${value}`) .join("\n"); - if (request.methodId === "gateway-bedrock") { + if (config.apiType === "bedrock") { return { CLAUDE_CODE_USE_BEDROCK: "1", 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, }; } + + if (config.apiType === "vertex") { + // `config.vertex` is guaranteed present for vertex by `unstable_setProvider` + // validation; fall back to empty strings defensively. + return { + 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: request._meta.gateway.baseUrl, + ANTHROPIC_BASE_URL: config.baseUrl, ANTHROPIC_CUSTOM_HEADERS: customHeaders, 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 * model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is @@ -6872,6 +7091,9 @@ export function runAcp() { agent.setSessionConfigOption(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.session.prompt, (ctx) => runPromptWithCancellation(agent, ctx.params, ctx.signal), diff --git a/src/tests/providers.test.ts b/src/tests/providers.test.ts new file mode 100644 index 00000000..08a6adcd --- /dev/null +++ b/src/tests/providers.test.ts @@ -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( + "@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("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(); + }); +});