mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-12 04:12:45 +00:00
Compare commits
1 Commits
main
...
fix/provid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62fd669b87 |
@@ -507,6 +507,55 @@ export function applyAvailableModelsAllowlist(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** User policy and launch env for replacing an unavailable SDK family default. */
|
||||
export type GatewayFamilyFallback = {
|
||||
settings: Settings;
|
||||
env: Record<string, string | undefined>;
|
||||
deniedModels: readonly string[];
|
||||
};
|
||||
|
||||
function gatewayFamilyFallback(
|
||||
model: ModelInfo,
|
||||
listedModelIds: readonly string[],
|
||||
regionPrefix: string | undefined,
|
||||
policy: GatewayFamilyFallback,
|
||||
): ModelInfo | undefined {
|
||||
// An explicit picker allowlist or family override owns this choice. Never
|
||||
// replace it with a model inferred from a provider listing.
|
||||
const family = model.value;
|
||||
if (!MODEL_FAMILY_ALIASES.has(family) || policy.settings.availableModels !== undefined) return;
|
||||
const key = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`;
|
||||
if (policy.settings.env?.[key] || policy.env[key]) return;
|
||||
const pattern = new RegExp(`^claude-${family}-(\\d+)(?:-(\\d+))?$`);
|
||||
const version = (id: string) => {
|
||||
const match = normalizeModelId(id).match(pattern);
|
||||
return match ? [Number(match[1]), Number(match[2] ?? 0)] : undefined;
|
||||
};
|
||||
const sdkVersion = model.resolvedModel && version(model.resolvedModel);
|
||||
if (!sdkVersion) return;
|
||||
const candidates = listedModelIds.flatMap((id) => {
|
||||
const candidate = version(id);
|
||||
const prefix = bedrockRegionPrefix(id);
|
||||
if (
|
||||
!candidate ||
|
||||
(regionPrefix !== undefined && prefix !== regionPrefix) ||
|
||||
isDeniedModel(id, policy.deniedModels) ||
|
||||
candidate[0] > sdkVersion[0] ||
|
||||
(candidate[0] === sdkVersion[0] && candidate[1] >= sdkVersion[1])
|
||||
)
|
||||
return [];
|
||||
return [{ id, version: candidate }];
|
||||
});
|
||||
candidates.sort(
|
||||
(a, b) =>
|
||||
b.version[0] - a.version[0] || b.version[1] - a.version[1] || a.id.localeCompare(b.id),
|
||||
);
|
||||
const id = candidates[0]?.id;
|
||||
// The older model's capabilities are unknown. Do not copy the newer SDK
|
||||
// default's effort, fast-mode or context-window metadata.
|
||||
return id ? { value: id, resolvedModel: id, displayName: id, description: "" } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the SDK models the `providers/set` gateway listed (`listGatewayModels`,
|
||||
* `listBedrockModels`). A row matches when its `resolvedModel` (or
|
||||
@@ -522,17 +571,21 @@ export function applyAvailableModelsAllowlist(
|
||||
* it is checked as far as the alias allows: `default` is kept, and a family
|
||||
* alias such as `opus` is kept when the gateway lists a model of that family.
|
||||
*
|
||||
* When no row matches by its model (a `default` row kept only for lacking a
|
||||
* `resolvedModel` does not count), the gateway serves none of the SDK's
|
||||
* models, and nothing is left: the session offers no model.
|
||||
* With `fallback`, an unavailable family alias can offer the highest older
|
||||
* provider version as an exact ID. Explicit family overrides and allowlists
|
||||
* prevent that replacement; unknown/newer versions are never inferred.
|
||||
*
|
||||
* When no row names a listed model after replacement (a `default` row kept
|
||||
* only for lacking a `resolvedModel` does not count), the session offers no model.
|
||||
*/
|
||||
export function narrowToGatewayModels(
|
||||
models: ModelInfo[],
|
||||
listedModelIds: readonly string[],
|
||||
regionPrefix?: string,
|
||||
fallback?: GatewayFamilyFallback,
|
||||
): ModelInfo[] {
|
||||
const listed = new Set(listedModelIds.map(normalizeModelId).filter(Boolean));
|
||||
const narrowed = models.filter((model) => {
|
||||
const matches = (model: ModelInfo): boolean => {
|
||||
const modelId = model.resolvedModel ?? model.value;
|
||||
const prefix = bedrockRegionPrefix(modelId);
|
||||
if (regionPrefix !== undefined && prefix !== undefined && prefix !== regionPrefix) {
|
||||
@@ -543,6 +596,19 @@ export function narrowToGatewayModels(
|
||||
if (model.resolvedModel !== undefined) return false;
|
||||
if (model.value === "default") return true;
|
||||
return MODEL_FAMILY_ALIASES.has(id) && [...listed].some((entry) => entry.includes(`-${id}`));
|
||||
};
|
||||
const matched = models.filter(matches);
|
||||
const narrowed = models.flatMap((model) => {
|
||||
if (matches(model)) return [model];
|
||||
const replacement =
|
||||
fallback && gatewayFamilyFallback(model, listedModelIds, regionPrefix, fallback);
|
||||
return replacement &&
|
||||
!matched.some(
|
||||
(row) =>
|
||||
normalizeModelId(row.resolvedModel ?? row.value) === normalizeModelId(replacement.value),
|
||||
)
|
||||
? [replacement]
|
||||
: [];
|
||||
});
|
||||
return narrowed.some((model) => listed.has(normalizeModelId(model.resolvedModel ?? model.value)))
|
||||
? narrowed
|
||||
|
||||
@@ -320,6 +320,120 @@ describe("narrowToGatewayModels", () => {
|
||||
expect(narrowed.map((m) => m.value)).toEqual(["sonnet", "sonnet[1m]"]);
|
||||
});
|
||||
|
||||
describe("older provider family defaults", () => {
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-sonnet-5-5",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5-5",
|
||||
displayName: "Sonnet",
|
||||
description: "Sonnet 5.5",
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{ value: "haiku", resolvedModel: "claude-haiku-5-5", displayName: "Haiku", description: "" },
|
||||
];
|
||||
const policy = { settings: {}, env: {}, deniedModels: [] };
|
||||
|
||||
it.each([
|
||||
["claude-sonnet-5", undefined],
|
||||
["eu.anthropic.claude-sonnet-5-v1:0", "eu"],
|
||||
["anthropic/claude-sonnet-5:batch", undefined],
|
||||
])("offers the exact older ID %s without the newer capabilities", (id, region) => {
|
||||
const narrowed = narrowToGatewayModels(models, [id, "claude-haiku-5-5"], region, policy);
|
||||
expect(narrowed[0]).toEqual({
|
||||
value: id,
|
||||
resolvedModel: id,
|
||||
displayName: id,
|
||||
description: "",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a newer generation", ["claude-sonnet-6"], policy],
|
||||
["an unknown model", ["claude-sonnet-custom"], policy],
|
||||
["a misleading prefix", ["anthropic/openai/claude-sonnet-5"], policy],
|
||||
[
|
||||
"an explicit family env",
|
||||
["claude-sonnet-5"],
|
||||
{ ...policy, env: { ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-5-5" } },
|
||||
],
|
||||
[
|
||||
"a settings env override",
|
||||
["claude-sonnet-5"],
|
||||
{ ...policy, settings: { env: { ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-5-5" } } },
|
||||
],
|
||||
[
|
||||
"an explicit allowlist",
|
||||
["claude-sonnet-5"],
|
||||
{ ...policy, settings: { availableModels: ["sonnet"] } },
|
||||
],
|
||||
["a denied version", ["claude-sonnet-5"], { ...policy, deniedModels: ["claude-sonnet-5"] }],
|
||||
])("does not infer %s", (_name, ids, configured) => {
|
||||
expect(narrowToGatewayModels(models, ids, undefined, configured)).toEqual([]);
|
||||
});
|
||||
|
||||
it("chooses the highest older version independently of listing order", () => {
|
||||
expect(
|
||||
narrowToGatewayModels(
|
||||
models,
|
||||
["claude-sonnet-4-6", "claude-sonnet-5"],
|
||||
undefined,
|
||||
policy,
|
||||
)[0].value,
|
||||
).toBe("claude-sonnet-5");
|
||||
});
|
||||
|
||||
it("keeps an existing older SDK row instead of inferring a duplicate", () => {
|
||||
const older = {
|
||||
value: "claude-sonnet-5",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet 5",
|
||||
description: "",
|
||||
supportsFastMode: true,
|
||||
};
|
||||
expect(
|
||||
narrowToGatewayModels([...models, older], ["claude-sonnet-5"], undefined, policy),
|
||||
).toEqual([older]);
|
||||
});
|
||||
|
||||
it("uses the new provider listing without retaining a previous fallback", () => {
|
||||
expect(narrowToGatewayModels(models, ["claude-sonnet-5"], undefined, policy)[0].value).toBe(
|
||||
"claude-sonnet-5",
|
||||
);
|
||||
expect(narrowToGatewayModels(models, ["claude-sonnet-4-6"], undefined, policy)[0].value).toBe(
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
expect(
|
||||
narrowToGatewayModels(models, ["claude-haiku-5-5"], undefined, policy).map(
|
||||
(row) => row.value,
|
||||
),
|
||||
).toEqual(["haiku"]);
|
||||
});
|
||||
|
||||
it("does not move a listed resumed model to the SDK family default", async () => {
|
||||
const narrowed = narrowToGatewayModels(models, ["claude-sonnet-5"], undefined, policy);
|
||||
const setModel = vi.fn();
|
||||
const state = await getAvailableModels(
|
||||
{ setModel } as unknown as Query,
|
||||
narrowed,
|
||||
models,
|
||||
{ getSettings: () => ({}) },
|
||||
logger,
|
||||
true,
|
||||
"resumed",
|
||||
"claude-sonnet-5",
|
||||
true,
|
||||
);
|
||||
expect(state.currentModelId).toBe("claude-sonnet-5");
|
||||
expect(setModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves no model when the listing matches none", () => {
|
||||
expect(narrowToGatewayModels(MODELS, ["gpt-5", "gemini-3-pro"])).toEqual([]);
|
||||
expect(narrowToGatewayModels(MODELS, [])).toEqual([]);
|
||||
|
||||
@@ -651,6 +651,43 @@ describe("providers", () => {
|
||||
expect(setModel).toHaveBeenCalledWith("sonnet");
|
||||
});
|
||||
|
||||
it("starts on an older provider Sonnet when the SDK family default is absent", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
const setModel = mockQueryWithModels(mockQuery, [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-sonnet-5-5",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5-5",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "haiku",
|
||||
resolvedModel: "claude-haiku-5-5",
|
||||
displayName: "Haiku",
|
||||
description: "",
|
||||
},
|
||||
]);
|
||||
stubListing(["claude-sonnet-5", "claude-haiku-5-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(modelOptionValues(response)).toEqual(["claude-sonnet-5", "haiku"]);
|
||||
expect(response.configOptions?.find((o: any) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-sonnet-5",
|
||||
);
|
||||
expect(setModel).toHaveBeenCalledWith("claude-sonnet-5");
|
||||
});
|
||||
|
||||
it("fails providers/set and keeps the current provider when the listing fails", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
mockQueryWithModels(mockQuery);
|
||||
|
||||
@@ -520,6 +520,52 @@ describe("ACP v2 core", () => {
|
||||
expect(settings).toMatchObject({ apiKeyHelper: "", env: { ANTHROPIC_BASE_URL: baseUrl } });
|
||||
});
|
||||
|
||||
it("starts on an older provider Sonnet when the SDK family default is absent", async () => {
|
||||
sdk.models = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-sonnet-5-5",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5-5",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
{ value: "haiku", resolvedModel: "claude-haiku-5-5", displayName: "Haiku", description: "" },
|
||||
];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ data: [{ id: "claude-sonnet-5" }, { id: "claude-haiku-5-5" }] }),
|
||||
),
|
||||
),
|
||||
);
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "test", version: "1" },
|
||||
capabilities: {},
|
||||
});
|
||||
await agent.request(v2.methods.agent.providers.set, {
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
const response = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
expect(response.configOptions?.find((option) => option.configId === "model")).toMatchObject({
|
||||
currentValue: "claude-sonnet-5",
|
||||
options: [{ value: "claude-sonnet-5" }, { value: "haiku" }],
|
||||
});
|
||||
expect(sdk.processes[0].setModel).toHaveBeenCalledWith("claude-sonnet-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a provider change of live sessions", () => {
|
||||
const setProvider = (agent: v2.ClientContext) =>
|
||||
agent.request(v2.methods.agent.providers.set, {
|
||||
|
||||
@@ -10457,6 +10457,15 @@ export class ClaudeAcpAgent {
|
||||
settingsAllowedModels,
|
||||
resolvedProvider.listedModels,
|
||||
resolvedProvider.bedrockRegionPrefix,
|
||||
{
|
||||
settings: {
|
||||
...settingsManager.getSettings(),
|
||||
...configuredSettingsObject,
|
||||
env: { ...settingsManager.getSettings().env, ...configuredSettingsObject?.env },
|
||||
},
|
||||
env,
|
||||
deniedModels: settingsManager.getManagedDeniedModels(),
|
||||
},
|
||||
)
|
||||
: settingsAllowedModels;
|
||||
|
||||
|
||||
@@ -859,6 +859,7 @@ class V2Agent implements AgentHandle, SessionConnection {
|
||||
resumed,
|
||||
sessionId,
|
||||
this.logger,
|
||||
{ env: options.env, settings: configured },
|
||||
);
|
||||
} catch (error) {
|
||||
// Claude Code refused a fresh session's model pin.
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
validatedGatewayAuthRequest,
|
||||
withListedModels,
|
||||
} from "../providers.js";
|
||||
import { narrowToGatewayModels } from "../session-model.js";
|
||||
import { narrowToGatewayModels, type GatewayFamilyFallback } from "../session-model.js";
|
||||
import { v2AuthMethod } from "./initialize.js";
|
||||
|
||||
/** The route of a session that starts now. */
|
||||
@@ -57,7 +57,7 @@ export interface SessionRouting {
|
||||
*/
|
||||
settings?: Settings;
|
||||
/** The models the session offers: those the provider's gateway listed. */
|
||||
models(models: ModelInfo[]): ModelInfo[];
|
||||
models(models: ModelInfo[], fallback?: GatewayFamilyFallback): ModelInfo[];
|
||||
/** Whether {@link models} narrows the models to a gateway's list. */
|
||||
narrowsModels: boolean;
|
||||
}
|
||||
@@ -244,9 +244,9 @@ export class V2Auth {
|
||||
return {
|
||||
env,
|
||||
...(config ? { settings: { apiKeyHelper: "", env } } : {}),
|
||||
models: (models) =>
|
||||
models: (models, fallback) =>
|
||||
config?.listedModels
|
||||
? narrowToGatewayModels(models, config.listedModels, config.bedrockRegionPrefix)
|
||||
? narrowToGatewayModels(models, config.listedModels, config.bedrockRegionPrefix, fallback)
|
||||
: models,
|
||||
narrowsModels: config?.listedModels !== undefined,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ModelInfo,
|
||||
type Options,
|
||||
type Query,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { NewSessionMeta } from "../new-session-meta.js";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
aliasResumedModelRow,
|
||||
applyAvailableModelsAllowlist,
|
||||
getAvailableModels,
|
||||
type GatewayFamilyFallback,
|
||||
type SessionModelState,
|
||||
withCurrentModelInfo,
|
||||
} from "../session-model.js";
|
||||
@@ -62,11 +64,15 @@ export async function sessionModels(
|
||||
query: Query,
|
||||
sdkModels: ModelInfo[],
|
||||
settings: SettingsManager,
|
||||
narrow: (models: ModelInfo[]) => ModelInfo[],
|
||||
narrow: (models: ModelInfo[], fallback?: GatewayFamilyFallback) => ModelInfo[],
|
||||
narrowedByProvider: boolean,
|
||||
resumed: { model?: string } | undefined,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
launch: {
|
||||
env?: Record<string, string | undefined>;
|
||||
settings?: Settings;
|
||||
} = {},
|
||||
): Promise<{
|
||||
models: SessionModelState;
|
||||
modelInfos: ModelInfo[];
|
||||
@@ -84,7 +90,15 @@ export async function sessionModels(
|
||||
settings.getManagedDeniedModels(),
|
||||
)
|
||||
: listed;
|
||||
const narrowed = narrow(allowed);
|
||||
const narrowed = narrow(allowed, {
|
||||
settings: {
|
||||
...settings.getSettings(),
|
||||
...launch.settings,
|
||||
env: { ...settings.getSettings().env, ...launch.settings?.env },
|
||||
},
|
||||
env: launch.env ?? process.env,
|
||||
deniedModels: settings.getManagedDeniedModels(),
|
||||
});
|
||||
const models = await getAvailableModels(
|
||||
query,
|
||||
narrowed,
|
||||
|
||||
Reference in New Issue
Block a user