Compare commits

...

1 Commits

Author SHA1 Message Date
Codex
ef225233f2 fix: reject session setup for an incompatible model provider 2026-11-02 08:48:32 +01:00
5 changed files with 187 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

@@ -12379,6 +12379,19 @@ export class ClaudeAcpAgent {
resolvedProvider.bedrockRegionPrefix,
)
: settingsAllowedModels;
// Alias-only catalogs do not identify every model the provider must serve.
if (
resolvedProvider?.listedModels !== undefined &&
initializationResult.models.length > 0 &&
settingsAllowedModels.length > 0 &&
settingsAllowedModels.every((model) => Boolean(model.resolvedModel?.trim())) &&
allowedModels.length === 0
) {
throw RequestError.internalError(
{ providerId: PROVIDER_ID },
"The selected provider offers no compatible Claude models. Choose a different provider and try again.",
);
}
const models = await getAvailableModels(
q,

View File

@@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.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 { acpProtocolRouter } from "../serve.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();
@@ -748,6 +799,38 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe("https://recovered.example");
});
it("reports incompatible provider setup through the v2 protocol router", async () => {
await agent.dispose();
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({
readable: toAgent.readable,
writable: toClient.writable,
});
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ data: [{ id: "gpt-5" }] })),
);
await v2
.client({ name: "provider-test" })
.connectWith({ readable: toClient.readable, writable: toAgent.writable }, async (client) => {
await client.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: { name: "provider-test", version: "1" },
capabilities: {},
});
await client.request(v2.methods.agent.providers.set, PROVIDER);
await expect(client.request(v2.methods.agent.session.new, { cwd })).rejects.toMatchObject({
code: -32603,
message: expect.stringContaining(
"The selected provider offers no compatible Claude models.",
),
});
expect(Object.keys(agent.sessions)).toEqual([]);
expect(lastQuery().close).toHaveBeenCalledOnce();
});
});
it("publishes the restored configuration through the v2 protocol router", async () => {
await agent.dispose();
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();

View File

@@ -797,9 +797,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({
@@ -811,7 +851,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

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