Compare commits

...

1 Commits

Author SHA1 Message Date
Codex
62fd669b87 fix: offer older provider family models when SDK defaults are absent 2026-11-10 15:21:43 +01:00
8 changed files with 297 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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