diff --git a/src/acp-agent.ts b/src/acp-agent.ts index cf313095..c16625f2 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -834,18 +834,9 @@ 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"]; +const PROVIDER_ID = "main"; +const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"; /** * Vertex needs project + region that the standard `providers/set` payload @@ -1604,9 +1595,7 @@ 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}. */ + /** Set while ACP overrides the agent's native provider configuration. */ 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 @@ -1908,21 +1897,13 @@ 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 config = this.providerConfig ?? this.defaultProviderConfig(); const provider: ProviderInfo = { providerId: PROVIDER_ID, supported: SUPPORTED_PROTOCOLS, required: false, - current: config ? { apiType: config.apiType, baseUrl: config.baseUrl } : null, + current: { apiType: config.apiType, baseUrl: config.baseUrl }, }; return { providers: [provider] }; } @@ -1983,31 +1964,45 @@ export class ClaudeAcpAgent { } /** - * `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. + * `providers/disable` ends ACP ownership of the single mutually exclusive + * backend slot and restores the agent's native routing state. */ 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 this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest); + } + + private defaultProviderConfig(): ProviderConfig { + const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest); + if (gatewayConfig) { + return gatewayConfig; } - return gatewayRequestToProviderConfig(this.gatewayAuthRequest); + if (process.env.CLAUDE_CODE_USE_BEDROCK) { + return { + apiType: "bedrock", + baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com", + headers: {}, + }; + } + if (process.env.CLAUDE_CODE_USE_VERTEX) { + return { + apiType: "vertex", + baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? "https://aiplatform.googleapis.com", + headers: {}, + }; + } + return { + apiType: "anthropic", + baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL, + headers: {}, + }; } async logout(_params: LogoutRequest): Promise { diff --git a/src/tests/providers.test.ts b/src/tests/providers.test.ts index 08a6adcd..11993823 100644 --- a/src/tests/providers.test.ts +++ b/src/tests/providers.test.ts @@ -59,7 +59,7 @@ describe("providers", () => { expect(response.agentCapabilities?.providers).toEqual({}); }); - it("lists a single optional 'main' provider, unconfigured by default", async () => { + it("lists one mutually exclusive provider slot with native routing", async () => { const [agent] = await createAgentMock(); const response = await agent.unstable_listProviders({}); expect(response.providers).toEqual([ @@ -67,7 +67,7 @@ describe("providers", () => { providerId: "main", supported: ["anthropic", "bedrock", "vertex"], required: false, - current: null, + current: { apiType: "anthropic", baseUrl: "https://api.anthropic.com" }, }, ]); }); @@ -126,7 +126,7 @@ describe("providers", () => { ).rejects.toMatchObject({ code: -32602 }); }); - it("disables the 'main' provider by clearing config and reporting current: null", async () => { + it("restores native routing after the last override is disabled", async () => { const [agent] = await createAgentMock(); await agent.unstable_setProvider({ providerId: "main", @@ -137,7 +137,36 @@ describe("providers", () => { await expect(agent.unstable_disableProvider({ providerId: "main" })).resolves.toEqual({}); - expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull(); + expect((await agent.unstable_listProviders({})).providers[0].current).toEqual({ + apiType: "anthropic", + baseUrl: "https://api.anthropic.com", + }); + }); + + it("replaces the active api type when the single slot is set again", async () => { + const [agent] = await createAgentMock(); + await agent.unstable_setProvider({ + providerId: "main", + apiType: "anthropic", + baseUrl: "https://anthropic.example", + }); + await agent.unstable_setProvider({ + providerId: "main", + apiType: "bedrock", + baseUrl: "https://bedrock.example", + }); + + const overridden = await agent.unstable_listProviders({}); + expect(overridden.providers[0].current).toEqual({ + apiType: "bedrock", + baseUrl: "https://bedrock.example", + }); + + await agent.unstable_disableProvider({ providerId: "main" }); + expect((await agent.unstable_listProviders({})).providers[0].current).toEqual({ + apiType: "anthropic", + baseUrl: "https://api.anthropic.com", + }); }); it("treats disabling an unknown provider as an idempotent no-op", async () => { @@ -298,6 +327,9 @@ describe("providers", () => { await agent.logout({}); - expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull(); + expect((await agent.unstable_listProviders({})).providers[0].current).toEqual({ + apiType: "anthropic", + baseUrl: "https://api.anthropic.com", + }); }); });