mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
240
src/acp-agent.ts
240
src/acp-agent.ts
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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 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;
|
||||||
|
}
|
||||||
|
// 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> {
|
async logout(_params: LogoutRequest): Promise<void> {
|
||||||
// Clear in-memory gateway credentials supplied via `authenticate`. The
|
// Clear in-memory gateway credentials supplied via `authenticate` and any
|
||||||
// gateway method never touches the on-disk credential store, so dropping
|
// provider routing set via `providers/set`. Neither touches the on-disk
|
||||||
// this reference is the whole logout for that path.
|
// credential store, so dropping these references is the whole logout for
|
||||||
|
// those paths.
|
||||||
this.gatewayAuthRequest = undefined;
|
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 {
|
||||||
|
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 {
|
return {
|
||||||
ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
|
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
303
src/tests/providers.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user