feat(claude): gate Mythos on advertised model identity

This commit is contained in:
Mohamed Boudra
2026-10-08 20:27:02 +02:00
parent 593e844392
commit 0f3f6ce92e
8 changed files with 355 additions and 9 deletions
@@ -426,6 +426,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
try {
const client = new ClaudeAgentClient({
logger,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
@@ -473,6 +481,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
try {
const client = new ClaudeAgentClient({
logger,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
resolveVersion: async () => {
throw new Error("unrecognized version output");
},
@@ -496,6 +512,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
try {
const client = new ClaudeAgentClient({
logger,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
@@ -76,6 +76,7 @@ import {
type ClaudeProviderOptions,
} from "./options.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { discoverClaudeModels } from "./model-discovery.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import {
realClaudeRewindSdk,
@@ -412,6 +413,7 @@ export interface ClaudeContentChunk {
}
interface ClaudeAgentClientOptions {
discoverModels?: typeof discoverClaudeModels;
defaults?: { agents?: Record<string, AgentDefinition> };
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
@@ -1514,6 +1516,7 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
}
export class ClaudeAgentClient implements AgentClient {
private readonly discoverModels: typeof discoverClaudeModels;
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1526,6 +1529,7 @@ export class ClaudeAgentClient implements AgentClient {
private readonly rewindSdk: ClaudeRewindSdk;
constructor(options: ClaudeAgentClientOptions) {
this.discoverModels = options.discoverModels ?? discoverClaudeModels;
this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings;
@@ -1608,8 +1612,35 @@ export class ClaudeAgentClient implements AgentClient {
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
}
const env = this.buildProviderEnv();
let discoveredModelIds = new Set<string>();
try {
const advertisedModels = await runProviderRefreshActivity(context, "models", () =>
this.discoverModels({
resolveBinary: this.resolveBinary,
runtimeSettings: this.runtimeSettings,
env,
signal: context?.signal,
}),
);
discoveredModelIds = new Set(
advertisedModels
.map((model) => normalizeClaudeRuntimeModelId(model.resolvedModel ?? model.value))
.filter((id): id is string => id !== null),
);
} catch (error) {
context?.signal.throwIfAborted();
this.logger.warn(
{ err: error },
"Claude model discovery failed; omitting discovery-required models",
);
}
const models = await runProviderRefreshActivity(context, "settings", () =>
getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion),
getClaudeModelsWithSettings(
this.logger,
claudeConfigDir(env),
claudeCodeVersion,
discoveredModelIds,
),
);
const modeCatalog = claudeModeCatalog(env);
return {
@@ -0,0 +1,89 @@
import type { ChildProcess } from "node:child_process";
import os from "node:os";
import type { ModelInfo, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { raceProviderRefreshAbort } from "../../provider-refresh-deadline.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { claudeQuery, type ClaudeQueryInput, type ClaudeQueryContext } from "./query.js";
interface ClaudeModelDiscoveryOptions {
resolveBinary: () => Promise<string>;
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
signal?: AbortSignal;
}
type OpenDiscoveryQuery = (
input: ClaudeQueryInput,
context: ClaudeQueryContext,
) => Pick<Query, "supportedModels" | "close">;
// The curated catalog survives a failed or slow probe. This process never receives
// a prompt and must not persist a session or launch user MCP servers/hooks.
export async function discoverClaudeModels(
options: ClaudeModelDiscoveryOptions,
openQuery: OpenDiscoveryQuery = claudeQuery,
): Promise<ModelInfo[]> {
const controller = new AbortController();
const signal = options.signal
? AbortSignal.any([options.signal, controller.signal])
: controller.signal;
const timer = setTimeout(
() => controller.abort(new Error("Claude model discovery timed out")),
5_000,
);
let endInput!: () => void;
const ended = new Promise<void>((resolve) => {
endInput = resolve;
});
const input: AsyncIterable<SDKUserMessage> = {
[Symbol.asyncIterator]() {
return {
next: async () => {
await ended;
return { done: true, value: undefined };
},
};
},
};
let query: ReturnType<OpenDiscoveryQuery> | undefined;
let child: ChildProcess | undefined;
try {
signal.throwIfAborted();
const binary = await raceProviderRefreshAbort(signal, options.resolveBinary());
query = openQuery(
{
prompt: input,
options: {
pathToClaudeCodeExecutable: binary,
cwd: os.homedir(),
env: options.env,
persistSession: false,
tools: [],
mcpServers: {},
strictMcpConfig: true,
settingSources: ["user"],
settings: { disableAllHooks: true },
abortController: controller,
},
},
{
runtimeSettings: options.runtimeSettings,
onChildProcess: (process) => {
child = process;
},
},
);
return await raceProviderRefreshAbort(signal, query.supportedModels());
} finally {
clearTimeout(timer);
endInput();
try {
query?.close();
} finally {
if (child) {
await terminateWithTreeKill(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 200 });
}
}
}
}
@@ -9,6 +9,7 @@ interface ClaudeModelManifestEntry {
description: string;
defaultPriority?: number;
minimumClaudeCodeVersion?: string;
requiresDiscovery?: boolean;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
defaultThinkingOptionId?: ClaudeEffortLevel;
@@ -60,6 +61,7 @@ export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-mythos-5-1",
label: "Mythos 5.1",
requiresDiscovery: true,
description: "Mythos 5.1 · Requires verified access",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
@@ -224,9 +226,16 @@ function buildThinkingOptions(
return options;
}
export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] {
export function getClaudeManifestModels(
claudeCodeVersion?: string,
discoveredModelIds?: ReadonlySet<string>,
): AgentModelDefinition[] {
const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter(
(model) => isModelAvailableInClaudeCode(model, claudeCodeVersion),
(model) =>
isModelAvailableInClaudeCode(model, claudeCodeVersion) &&
(discoveredModelIds === undefined ||
!("requiresDiscovery" in model && model.requiresDiscovery) ||
discoveredModelIds.has(model.id)),
);
const defaultModel = availableModels.reduce<ClaudeModelManifestEntry | undefined>(
(selected, candidate) =>
@@ -13,6 +13,8 @@ import {
parseClaudeCodeVersion,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { discoverClaudeModels } from "./model-discovery.js";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
const createdClaudeConfigDirs: string[] = [];
@@ -43,6 +45,14 @@ function createCatalogClient(claudeCodeVersion = "2.1.293"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
});
}
@@ -223,6 +233,116 @@ describe("getClaudeModels", () => {
});
describe("ClaudeAgentClient.fetchCatalog", () => {
it.each([
{ value: "mythos", resolvedModel: "claude-mythos-5-1", expected: true },
{ value: "claude-mythos-5-1", expected: true },
{ value: "claude-mythos-5-1[1m]", expected: true },
{ value: "global.anthropic.claude-mythos-5-1", expected: true },
{ value: "mythos", expected: false },
{ value: "mythos", resolvedModel: "claude-mythos-5-2", expected: false },
{ value: "claude-mythos-5-1", resolvedModel: "claude-fable-5-1", expected: false },
])(
"uses advertised identity $value / $resolvedModel to gate Mythos",
async ({ value, resolvedModel, expected }) => {
const configDir = await createClaudeConfigDir({});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => [
{ value, resolvedModel, displayName: "Untrusted label", description: "" },
],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual(
expected ? findClaudeModel("claude-mythos-5-1") : undefined,
);
},
);
it("preserves the curated catalog and explicitly configured models after discovery failure", async () => {
const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" });
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => {
throw new Error("discovery unavailable");
},
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual(
getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"),
);
expect(models.find((model) => model.id === "claude-mythos-5-1")?.description).toBe(
"From Claude settings.json model",
);
});
it("omits unconfirmed Mythos after a failed probe and retries on refresh", async () => {
const configDir = await createClaudeConfigDir({});
let fail = true;
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => {
if (fail) throw new Error("discovery unavailable");
return [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
];
},
});
const options = { scope: "workspace" as const, cwd: configDir, force: true };
expect(
(await client.fetchCatalog(options)).models.some((model) => model.id === "claude-mythos-5-1"),
).toBe(false);
fail = false;
expect(
(await client.fetchCatalog(options)).models.find((model) => model.id === "claude-mythos-5-1"),
).toEqual(findClaudeModel("claude-mythos-5-1"));
});
it("keeps the curated catalog when discovery does not advertise Mythos", async () => {
const configDir = await createClaudeConfigDir({});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => [
{
value: "opus",
resolvedModel: "claude-opus-5-5",
displayName: "Opus",
description: "Latest",
},
],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.map((model) => model.id)).toEqual(
getClaudeModels("2.1.293")
.filter((model) => model.id !== "claude-mythos-5-1")
.map((model) => model.id),
);
});
it("appends concrete models from Claude settings.json", async () => {
const configDir = await createClaudeConfigDir({
model: "us.anthropic.claude-opus-4-7[1m]",
@@ -300,6 +420,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.280",
discoverModels: async () => [],
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
});
@@ -773,3 +894,77 @@ describe("Claude Mythos 5.1 catalog", () => {
},
);
});
describe("Claude model discovery process", () => {
it("does not send a prompt or persist a session and closes after discovery", async () => {
let closed = false;
let promptEnded: Promise<IteratorResult<unknown>> | undefined;
const advertised: ModelInfo[] = [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
];
const models = await discoverClaudeModels(
{ resolveBinary: async () => "/claude", env: { CLAUDE_CONFIG_DIR: "/provider-config" } },
(input) => {
expect(input.options).toMatchObject({
pathToClaudeCodeExecutable: "/claude",
env: { CLAUDE_CONFIG_DIR: "/provider-config" },
persistSession: false,
tools: [],
mcpServers: {},
strictMcpConfig: true,
settingSources: ["user"],
settings: { disableAllHooks: true },
});
if (typeof input.prompt === "string") throw new Error("unexpected inference prompt");
promptEnded = input.prompt[Symbol.asyncIterator]().next();
return {
supportedModels: async () => advertised,
close: () => {
closed = true;
},
};
},
);
expect(models).toEqual(advertised);
expect(closed).toBe(true);
await expect(promptEnded).resolves.toEqual({ done: true, value: undefined });
});
it("times out and closes an unresponsive discovery process", async () => {
let closed = false;
await expect(
discoverClaudeModels({ resolveBinary: async () => "/claude", env: {} }, () => ({
supportedModels: () => new Promise(() => {}),
close: () => {
closed = true;
},
})),
).rejects.toThrow("Claude model discovery timed out");
expect(closed).toBe(true);
}, 10_000);
it("closes a stalled probe when its refresh is canceled", async () => {
const controller = new AbortController();
const failure = new Error("refresh canceled");
let closed = false;
const result = discoverClaudeModels(
{ resolveBinary: async () => "/claude", env: {}, signal: controller.signal },
() => ({
supportedModels: () => {
controller.abort(failure);
return new Promise(() => {});
},
close: () => {
closed = true;
},
}),
);
await expect(result).rejects.toBe(failure);
expect(closed).toBe(true);
});
});
@@ -52,8 +52,9 @@ export async function getClaudeModelsWithSettings(
logger: Logger,
configDir: string,
claudeCodeVersion?: string,
discoveredModelIds: ReadonlySet<string> = new Set(),
): Promise<AgentModelDefinition[]> {
const hardcodedModels = getClaudeModels(claudeCodeVersion);
const hardcodedModels = getClaudeManifestModels(claudeCodeVersion, discoveredModelIds);
const settingsModels = await readClaudeSettingsModels(logger, configDir);
if (settingsModels.length === 0) {
return hardcodedModels;