Restore native provider state after overrides (#996)

### Summary
- expose one `main` provider slot because Anthropic, Bedrock, and Vertex
routing are mutually exclusive
- advertise all three supported API types on that slot and replace the
active configuration on each `providers/set`
- restore native Claude routing when the slot is disabled
- keep provider headers out of list responses and preserve per-protocol
validation

### Testing
- `npm run test:run` (767 passed, 20 skipped)
- `npm run build`
- `npm run check`

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
Sergey Shkredov
2026-08-14 12:13:38 +02:00
committed by GitHub
parent e4dba808ea
commit 11dd73b871
2 changed files with 70 additions and 43 deletions

View File

@@ -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<ListProvidersResponse> {
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<DisableProviderResponse> {
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<void> {

View File

@@ -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",
});
});
});