Compare commits

...

3 Commits

9 changed files with 222 additions and 11 deletions

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -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();

View File

@@ -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 () => {

View File

@@ -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",

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -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,