mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
feat(claude): gate Mythos on advertised model identity
This commit is contained in:
@@ -62,11 +62,6 @@ const EXPECTED_CLAUDE_MODELS = [
|
||||
model: "Opus 5",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
{
|
||||
id: "claude-mythos-5-1",
|
||||
model: "Mythos 5.1",
|
||||
descriptionFragment: "Requires verified access",
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5-1",
|
||||
model: "Fable 5.1",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user