diff --git a/docs/providers.md b/docs/providers.md index 4609246922..298bee651b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -61,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code. +Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them. + Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers..paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability. diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 9f7430b103..5a5e1132a1 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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 } }, @@ -438,6 +446,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5", + "claude-mythos-5-1", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", @@ -472,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"); }, @@ -495,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 } }, @@ -509,6 +534,14 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }; expect(getThinkingIds("claude-opus-5")).toContain("ultracode"); + expect(getThinkingIds("claude-mythos-5-1")).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + "ultracode", + ]); expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode"); expect(getThinkingIds("claude-fable-5")).toContain("ultracode"); expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode"); @@ -1232,6 +1265,7 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], + ["Mythos 5.1", "claude-mythos-5-1", { type: "adaptive", display: "summarized" }, "high"], ["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined], ["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"], ["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined], diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b797ce2c94..6c73f1f643 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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 }; 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(); + 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 { diff --git a/packages/server/src/server/agent/providers/claude/model-discovery.ts b/packages/server/src/server/agent/providers/claude/model-discovery.ts new file mode 100644 index 0000000000..d74898472b --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/model-discovery.ts @@ -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; + runtimeSettings?: ProviderRuntimeSettings; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; +} + +type OpenDiscoveryQuery = ( + input: ClaudeQueryInput, + context: ClaudeQueryContext, +) => Pick; + +// 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 { + 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((resolve) => { + endInput = resolve; + }); + const input: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next: async () => { + await ended; + return { done: true, value: undefined }; + }, + }; + }, + }; + let query: ReturnType | 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 }); + } + } + } +} diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index 398fcb1ccd..45a5441109 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -9,6 +9,7 @@ interface ClaudeModelManifestEntry { description: string; defaultPriority?: number; minimumClaudeCodeVersion?: string; + requiresDiscovery?: boolean; contextWindowMaxTokens?: number; effortLevels?: readonly ClaudeEffortLevel[]; defaultThinkingOptionId?: ClaudeEffortLevel; @@ -57,6 +58,14 @@ export const CLAUDE_MODEL_MANIFEST = [ supportsThinkingDisabled: true, supportsFastMode: true, }, + { + 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, + }, { id: "claude-fable-5-1", label: "Fable 5.1", @@ -217,9 +226,16 @@ function buildThinkingOptions( return options; } -export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] { +export function getClaudeManifestModels( + claudeCodeVersion?: string, + discoveredModelIds?: ReadonlySet, +): 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( (selected, candidate) => @@ -359,7 +375,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const singleSegmentMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (singleSegmentMatch) { return normalizeSingleSegmentClaudeModelId( @@ -370,7 +386,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined) } const runtimeMatch = trimmed.match( - /^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, + /^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i, ); if (!runtimeMatch) { return null; @@ -403,7 +419,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } const runtimeMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i, ); if (runtimeMatch) { const normalizedModelId = normalizeMajorMinorClaudeModelId( @@ -418,7 +434,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): } const singleSegmentMatch = trimmed.match( - /claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, + /claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i, ); if (!singleSegmentMatch) { return null; diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index ed7db8841c..1ef8177bbf 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -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: "", + }, + ], }); } @@ -52,6 +62,7 @@ describe("getClaudeModels", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5-5", "claude-opus-5", + "claude-mythos-5-1", "claude-fable-5-1", "claude-fable-5", "claude-fable-5[1m]", @@ -87,6 +98,7 @@ describe("getClaudeModels", () => { new Map([ ["claude-opus-5-5", 1_000_000], ["claude-opus-5", 1_000_000], + ["claude-mythos-5-1", 1_000_000], ["claude-fable-5-1", 1_000_000], ["claude-fable-5", 1_000_000], ["claude-fable-5[1m]", 1_000_000], @@ -221,6 +233,118 @@ 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")).toEqual({ + ...findClaudeModel("claude-mythos-5-1"), + label: "claude-mythos-5-1", + description: "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]", @@ -298,6 +422,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { const client = new ClaudeAgentClient({ logger: createTestLogger(), resolveVersion: async () => "2.1.280", + discoverModels: async () => [], runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } }, }); @@ -724,3 +849,124 @@ describe("claudeManifestModelSupportsFastMode", () => { expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false); }); }); + +describe("Claude Mythos 5.1 catalog", () => { + it("offers one 1M model with high default effort and always-on thinking", () => { + const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos")); + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "claude-mythos-5-1", + label: "Mythos 5.1", + contextWindowMaxTokens: 1_000_000, + defaultThinkingOptionId: "high", + }); + expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + CLAUDE_ULTRACODE_THINKING_OPTION_ID, + ]); + expect( + models[0].thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id), + ).toEqual(["high"]); + expect(resolveClaudeDisabledThinkingForModel("claude-mythos-5-1")).toEqual({ + supported: false, + fallbackThinkingOptionId: "high", + }); + expect(claudeManifestModelSupportsFastMode("claude-mythos-5-1")).toBe(false); + expect(getClaudeModels().find((model) => model.isDefault)?.id).toBe("claude-opus-5-5"); + }); + + it.each([ + "claude-mythos-5-1[1m]", + "claude-mythos-5-1-20260901", + "claude-mythos-5-1-20260901[1m]", + "Mythos 5.1", + ])("resolves %s to the single catalog entry", (id) => { + expect(findClaudeModel(id)?.id).toBe("claude-mythos-5-1"); + }); + + it.each(["anthropic/claude-mythos-5-1", "us.anthropic.claude-mythos-5-1-v1:0"])( + "normalizes runtime identity without granting first-party capabilities: %s", + (id) => { + expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-1"); + expect(normalizeClaudeManifestModelId(id)).toBeNull(); + }, + ); +}); + +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> | 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); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 6b341fa9ea..39e0dcf1dd 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -52,8 +52,9 @@ export async function getClaudeModelsWithSettings( logger: Logger, configDir: string, claudeCodeVersion?: string, + discoveredModelIds: ReadonlySet = new Set(), ): Promise { - const hardcodedModels = getClaudeModels(claudeCodeVersion); + const hardcodedModels = getClaudeManifestModels(claudeCodeVersion, discoveredModelIds); const settingsModels = await readClaudeSettingsModels(logger, configDir); if (settingsModels.length === 0) { return hardcodedModels; @@ -70,7 +71,10 @@ export async function getClaudeModelsWithSettings( } continue; } - models.push(model); + const manifestModel = getClaudeModels(claudeCodeVersion).find( + (candidate) => candidate.id === model.id, + ); + models.push({ ...manifestModel, ...model }); } return models; diff --git a/packages/server/src/server/worktree-bootstrap.posix.test.ts b/packages/server/src/server/worktree-bootstrap.posix.test.ts index a0a796c83d..41de520d09 100644 --- a/packages/server/src/server/worktree-bootstrap.posix.test.ts +++ b/packages/server/src/server/worktree-bootstrap.posix.test.ts @@ -422,12 +422,12 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => { api: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('api-env.json.tmp', 'api-env.json'); setTimeout(()=>{}, 30000)\"", }, web: { type: "service", command: - "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"", + "node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('web-env.json.tmp', 'web-env.json'); setTimeout(()=>{}, 30000)\"", }, }, }), diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index c830fca9cc..9859fcc902 100644 --- a/plugins/claude-usage-source/server/stores.test.ts +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, expect, test } from "vitest"; import { discover, fetchUsage } from "./usage.js"; @@ -43,7 +44,6 @@ async function logins(options: Parameters[1]) { } async function database(path: string) { await mkdir((await import("node:path")).dirname(path), { recursive: true }); - const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(path); db.exec( "CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)", @@ -248,7 +248,6 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login const path = join(home, ".omp", "agent", "agent.db"); await database(path); const [input] = await logins(lookup()); - const { DatabaseSync } = await import("node:sqlite"); const db = new DatabaseSync(path); db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run( JSON.stringify({ access: "fixture-rotated-omp", accountId: "omp-account", expires: 2000 }),