mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 11:51:14 +00:00
Compare commits
3 Commits
refactor/v
...
fix/reject
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
665a86303e | ||
|
|
e5774d64d7 | ||
|
|
bd87e81053 |
@@ -64,4 +64,4 @@ When an ACP client routes the agent through a gateway with `providers/set`, the
|
||||
|
||||
The model picker then offers only the SDK models whose resolved model the gateway lists, matched without dates, version suffixes (`-v1:0`), `-fast`, `@` suffixes, context hints (`[1m]`) and provider prefixes, after the `availableModels` allowlist above. On Bedrock, a model the CLI resolved to another region prefix is dropped as well. When the gateway does not list the model a session runs, that is the default model or a resumed session's last model, the session switches to the first remaining picker row.
|
||||
|
||||
A 4xx answer means the gateway has no listing: `providers/set` succeeds and nothing is narrowed. A failed listing (network error, a status other than 2xx or 4xx, unexpected body, the 10 s timeout, an invalid header, or a base URL with credentials) fails `providers/set` with a JSON-RPC internal error, and the previous provider stays in effect. A listing that matches no SDK model leaves the session with no model to offer: there is no model config option. Gateways set through `authenticate`, `vertex` providers, and native routing are not listed. Setting the same gateway again (the same protocol, base URL and headers) reuses its listing, unless it answered 4xx; `providers/set` and `providers/disable` apply in the order they arrive, and logout clears the routing after `claude auth logout`, including a `providers/set` still listing; `providers/disable` and logout drop it.
|
||||
A 4xx answer means the gateway has no listing: `providers/set` succeeds and nothing is narrowed. A failed listing (network error, a status other than 2xx or 4xx, unexpected body, the 10 s timeout, an invalid header, or a base URL with credentials) fails `providers/set` with a JSON-RPC internal error, and the previous provider stays in effect. If an authoritative listing removes every model from a nonempty allowed SDK catalog with concrete resolved model identities, session setup fails with an explanation to choose a different provider. An SDK catalog that is already empty, or has any unresolved identities among its allowed models, keeps its existing behavior. Gateways set through `authenticate`, `vertex` providers, and native routing are not listed. Setting the same gateway again (the same protocol, base URL and headers) reuses its listing, unless it answered 4xx; `providers/set` and `providers/disable` apply in the order they arrive, and logout clears the routing after `claude auth logout`, including a `providers/set` still listing; `providers/disable` and logout drop it.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import { PROVIDER_ID } from "./providers.js";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
@@ -549,6 +551,27 @@ export function narrowToGatewayModels(
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Reject a confirmed provider mismatch; incomplete SDK identities remain inconclusive. */
|
||||
export function requireCompatibleProviderModels(
|
||||
sdkModels: ModelInfo[],
|
||||
allowedModels: ModelInfo[],
|
||||
narrowedModels: ModelInfo[],
|
||||
narrowedByProvider: boolean,
|
||||
): void {
|
||||
if (
|
||||
narrowedByProvider &&
|
||||
sdkModels.length > 0 &&
|
||||
allowedModels.length > 0 &&
|
||||
allowedModels.every((model) => Boolean(model.resolvedModel?.trim())) &&
|
||||
narrowedModels.length === 0
|
||||
) {
|
||||
throw RequestError.internalError(
|
||||
{ providerId: PROVIDER_ID },
|
||||
"The selected provider offers no compatible Claude models. Choose a different provider and try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The capabilities of the session's current model, and the model infos that
|
||||
* every later lookup reads.
|
||||
|
||||
@@ -3,14 +3,12 @@ import { beforeEach, vi } from "vitest";
|
||||
// `providers/set` lists the gateway's models with `fetch` (gateway-models.ts),
|
||||
// and a failed listing fails the call. Tests that set an `anthropic` or
|
||||
// `bedrock` provider would otherwise send that request to their example
|
||||
// gateway URLs, so every test gets an empty listing instead. An empty listing
|
||||
// narrows the picker to no model: a test that checks the picker after
|
||||
// `providers/set` stubs `fetch` with the models it expects. This default is
|
||||
// deliberate: listing fixture models for every test would hide which tests
|
||||
// depend on a listing.
|
||||
// gateway URLs, so every test gets a 404 instead. That means the gateway has
|
||||
// no listing and leaves the SDK catalog unchanged. Tests of model discovery
|
||||
// supply their own listing, including authoritative empty results.
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(JSON.stringify({ data: [], modelSummaries: [] }))),
|
||||
vi.fn(async () => new Response("No model listing", { status: 404 })),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeAcpAgent } from "../../v1/acp-agent.js";
|
||||
import { FAST_MODE_CONFIG_ID } from "../../session-fast-mode.js";
|
||||
import type { AcpClient } from "../../acp-client.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../../air-extension.js";
|
||||
import {
|
||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||
AIR_SESSION_FAILURE_CAPABILITY,
|
||||
} from "../../air-extension.js";
|
||||
import { initializeClient, makeMockQuery } from "../helpers.js";
|
||||
import { successfulResultMessage, userEcho } from "./session-doubles.js";
|
||||
|
||||
@@ -516,6 +519,54 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
|
||||
},
|
||||
);
|
||||
|
||||
it("cleans up an incompatible provider restart and can create a session after recovery", async () => {
|
||||
await initializeClient(agent, {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: [AIR_SESSION_FAILURE_CAPABILITY] } } },
|
||||
});
|
||||
const { sessionId } = await newSession();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json({ data: [{ id: "gpt-5" }] })),
|
||||
);
|
||||
|
||||
await agent.unstable_setProvider({ ...PROVIDER, baseUrl: "https://incompatible.example" });
|
||||
|
||||
expect(agent.sessions[sessionId]).toBeUndefined();
|
||||
expect(queries).toHaveLength(2);
|
||||
expect(queries.every((query) => query.close.mock.calls.length === 1)).toBe(true);
|
||||
expect(updates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
sessionId,
|
||||
update: expect.objectContaining({
|
||||
_meta: expect.objectContaining({
|
||||
jetbrains: expect.objectContaining({
|
||||
air: expect.objectContaining({
|
||||
sessionFailure: expect.objectContaining({
|
||||
details: expect.stringContaining(
|
||||
"The selected provider offers no compatible Claude models.",
|
||||
),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({
|
||||
data: MODELS.map((model) => ({ id: model.resolvedModel })),
|
||||
}),
|
||||
),
|
||||
);
|
||||
await refresh();
|
||||
const recovered = await newSession();
|
||||
expect(agent.sessions[recovered.sessionId]).toBeDefined();
|
||||
expect(current(recovered.sessionId, "model")).toBe("sonnet");
|
||||
});
|
||||
|
||||
it("removes the old session when provider recreation fails", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
const held = deferred();
|
||||
|
||||
@@ -799,9 +799,49 @@ describe("providers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers no model when the listing succeeds but matches none", async () => {
|
||||
it.each([{ listedModels: [] }, { listedModels: ["gpt-5"] }])(
|
||||
"rejects incompatible provider models $listedModels and recovers after a switch",
|
||||
async ({ listedModels }) => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
const setModel = mockQueryWithModels(mockQuery);
|
||||
stubListing(listedModels);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
await expect(
|
||||
agent.newSession({ cwd: process.cwd(), mcpServers: [] }),
|
||||
).rejects.toMatchObject({
|
||||
code: -32603,
|
||||
message: expect.stringContaining(
|
||||
"The selected provider offers no compatible Claude models.",
|
||||
),
|
||||
data: { providerId: "main" },
|
||||
});
|
||||
|
||||
expect(Object.keys(agent.sessions)).toEqual([]);
|
||||
const rejectedQuery = mockQuery.mock.results.at(-1)!.value;
|
||||
expect(rejectedQuery.close).toHaveBeenCalledOnce();
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
|
||||
stubListing(["claude-sonnet-5"]);
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://compatible-provider.example",
|
||||
});
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(modelOptionValues(response)).toEqual(["sonnet"]);
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves an empty SDK catalog when the provider has an authoritative listing", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
const setModel = mockQueryWithModels(mockQuery);
|
||||
mockQueryWithModels(mockQuery, []);
|
||||
stubListing(["gpt-5"]);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
@@ -813,7 +853,49 @@ describe("providers", () => {
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")).toBeUndefined();
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "alias-only", models: GATEWAY_MODELS.map(({ resolvedModel: _, ...model }) => model) },
|
||||
{
|
||||
name: "mixed",
|
||||
models: [GATEWAY_MODELS[0], { value: "custom", displayName: "Custom", description: "" }],
|
||||
},
|
||||
])("preserves a $name SDK catalog with unresolved model identities", async ({ models }) => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
mockQueryWithModels(mockQuery, models);
|
||||
stubListing(["claude-sonnet-5"]);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
});
|
||||
|
||||
it("preserves an empty SDK catalog with configured model choices", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
mockQueryWithModels(mockQuery, []);
|
||||
stubListing(["gpt-5"]);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
const response = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings: { availableModels: ["sonnet"] } } } },
|
||||
});
|
||||
|
||||
expect(agent.sessions[response.sessionId]).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets a gateway that has no listing and narrows nothing", async () => {
|
||||
|
||||
@@ -1134,6 +1134,7 @@ describe("ACP v2 sessions", () => {
|
||||
await agent.request(v2.methods.agent.session.setConfigOption, { sessionId, ...request });
|
||||
}
|
||||
sdk.flagSettings = [];
|
||||
vi.mocked(fetch).mockResolvedValueOnce(Response.json({ data: [] }));
|
||||
await agent.request(v2.methods.agent.providers.set, {
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
|
||||
@@ -528,6 +528,53 @@ describe("ACP v2 core", () => {
|
||||
baseUrl: "https://gateway.example.com",
|
||||
});
|
||||
|
||||
it.each([{ listing: [] }, { listing: ["gpt-5"] }])(
|
||||
"rejects an incompatible provider listing %j before a prompt and recovers",
|
||||
async ({ listing }) => {
|
||||
sdk.models = [
|
||||
{
|
||||
value: "sonnet",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
resolvedModel: "claude-sonnet-5-5",
|
||||
},
|
||||
];
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "v2-test-client", version: "1" },
|
||||
});
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
Response.json({ data: listing.map((id) => ({ id })) }),
|
||||
);
|
||||
await setProvider(agent);
|
||||
await expect(agent.request(v2.methods.agent.session.new, { cwd })).rejects.toMatchObject({
|
||||
code: -32603,
|
||||
message: expect.stringContaining(
|
||||
"The selected provider offers no compatible Claude models.",
|
||||
),
|
||||
});
|
||||
expect(sdk.processes[0].close).toHaveBeenCalledOnce();
|
||||
expect(sdk.processes[0].prompts).toEqual([]);
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
Response.json({ data: [{ id: "claude-sonnet-5-5" }] }),
|
||||
);
|
||||
await agent.request(v2.methods.agent.providers.set, {
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://recovered.example.com",
|
||||
});
|
||||
const response = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
expect(
|
||||
response.configOptions?.find((option) => option.configId === "model"),
|
||||
).toMatchObject({
|
||||
options: [expect.objectContaining({ value: "sonnet" })],
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("answers at once while a background subagent runs, and restarts the session once it ends", async () => {
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
|
||||
@@ -332,6 +332,7 @@ import {
|
||||
import {
|
||||
aliasResumedModelRow,
|
||||
applyAvailableModelsAllowlist,
|
||||
requireCompatibleProviderModels,
|
||||
getAvailableModels,
|
||||
MODEL_CONFIG_ID,
|
||||
modelDisplayName,
|
||||
@@ -10441,6 +10442,12 @@ export class ClaudeAcpAgent {
|
||||
resolvedProvider.bedrockRegionPrefix,
|
||||
)
|
||||
: settingsAllowedModels;
|
||||
requireCompatibleProviderModels(
|
||||
initializationResult.models,
|
||||
settingsAllowedModels,
|
||||
allowedModels,
|
||||
resolvedProvider?.listedModels !== undefined,
|
||||
);
|
||||
|
||||
const models = await getAvailableModels(
|
||||
q,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
aliasResumedModelRow,
|
||||
applyAvailableModelsAllowlist,
|
||||
requireCompatibleProviderModels,
|
||||
getAvailableModels,
|
||||
type SessionModelState,
|
||||
withCurrentModelInfo,
|
||||
@@ -85,6 +86,7 @@ export async function sessionModels(
|
||||
)
|
||||
: listed;
|
||||
const narrowed = narrow(allowed);
|
||||
requireCompatibleProviderModels(sdkModels, allowed, narrowed, narrowedByProvider);
|
||||
const models = await getAvailableModels(
|
||||
query,
|
||||
narrowed,
|
||||
|
||||
Reference in New Issue
Block a user