From e962c79f601e05ca44d7f984d29d6e2a75c31390 Mon Sep 17 00:00:00 2001 From: Logan Yang Date: Tue, 9 Jun 2026 23:12:59 -0700 Subject: [PATCH] Surface a custom model set via ANTHROPIC_MODEL in the Claude model picker (#2588) * feat(agent-mode): surface a custom ANTHROPIC_MODEL as a selectable Claude model Setting ANTHROPIC_MODEL= in the Claude backend's env overrides had no effect on the model picker: the picker is built only from the SDK's advertised catalog, so a free-form id had nowhere to surface. Read ANTHROPIC_MODEL from the backend's env overrides and merge it into the catalog as a synthetic entry, so it flows through model discovery into the curation list and chat picker. Selecting it sends the id as options.model; it is also honored as the persisted default seed. A custom id that shadows a built-in is deduped (catalog returned by the same reference). Closes #149 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(agent-mode): restart the affected backend when its env overrides change An env-overrides edit only reached the live or warm backend process on the next spawn/probe: nothing watched agentMode.backends..envOverrides, so the picker kept serving the pre-edit cached catalog until an unrelated restart or a new session recomputed it. With the Claude SDK now folding a custom ANTHROPIC_MODEL into its catalog, that staleness became user-visible. Subscribe to settings changes and restart the backend whose env-overrides record actually changed (order-independent key, so unrelated settings writes and key reordering are no-ops). The editor debounces commits and the manager/preloader coalesce rapid restarts into one re-probe, matching the existing provider-config subscription. Addresses the Codex P2 review finding on #2588. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(agent-mode): surface ANTHROPIC_MODEL via the SDK's native catalog Per review (zeroliu): avoid maintaining a second source of truth for Claude models. Verified empirically that the `claude` CLI already reflects a user-set `ANTHROPIC_MODEL` into its init handshake `init.models` (with full effort metadata) when the env var is present in the probe's environment. The custom model never appeared only because `probeClaudeSdkCatalog` spawned the init probe with `pathToClaudeCodeExecutable` alone and no `env`, so the override never reached it. Fix: thread the backend's `envOverrides` into the probe (merged onto `process.env`, mirroring the prompt path). The model now surfaces natively as a first-class catalog entry, including effort levels. Drops the synthetic catalog injection (`customModelFromEnv` / `mergeCustomModel` / `effectiveCatalog`). Scopes the plugin-lifetime catalog cache by an order-independent env-overrides key so editing `ANTHROPIC_MODEL` invalidates a stale entry; the next `ensureModelCatalog()` re-probes with the new env. The env-change restart wiring from the prior commit still triggers that re-probe and the picker refresh. Tests: env-scoped cache + probe env-passing in effortOption.test.ts; backend threading of env overrides into the probe in ClaudeSdkBackendProcess.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/agentMode/backends/claude/descriptor.ts | 2 +- src/agentMode/index.ts | 36 +++++++++- .../sdk/ClaudeSdkBackendProcess.test.ts | 37 ++++++++++ src/agentMode/sdk/ClaudeSdkBackendProcess.ts | 18 ++--- src/agentMode/sdk/effortOption.test.ts | 71 +++++++++++++++++++ src/agentMode/sdk/effortOption.ts | 59 +++++++++++---- 6 files changed, 199 insertions(+), 24 deletions(-) create mode 100644 src/agentMode/sdk/effortOption.test.ts diff --git a/src/agentMode/backends/claude/descriptor.ts b/src/agentMode/backends/claude/descriptor.ts index 3795f5d4..3b919efa 100644 --- a/src/agentMode/backends/claude/descriptor.ts +++ b/src/agentMode/backends/claude/descriptor.ts @@ -52,7 +52,7 @@ const claudeWire: ModelWireCodec = { provider: "anthropic", }), effortConfigFor: (baseModelId: string): BackendConfigOption | null => { - const catalog = getCachedSdkCatalog(); + const catalog = getCachedSdkCatalog(getSettings().agentMode?.backends?.claude?.envOverrides); if (!catalog) return null; const modelInfo = catalog.find((m) => m.value === baseModelId); if (!modelInfo) return null; diff --git a/src/agentMode/index.ts b/src/agentMode/index.ts index 35a10b96..7ca046d6 100644 --- a/src/agentMode/index.ts +++ b/src/agentMode/index.ts @@ -2,7 +2,7 @@ import { type App, Platform } from "obsidian"; import type CopilotPlugin from "@/main"; import { logError } from "@/logger"; import { DEFAULT_SKILLS_FOLDER } from "@/constants"; -import { getSettings, subscribeToSettingsChange } from "@/settings/model"; +import { getSettings, subscribeToSettingsChange, type CopilotSettings } from "@/settings/model"; import { subscribeToSystemPromptChange } from "@/system-prompts/state"; import { buildAgentSystemPrompt } from "./backends/shared/agentSystemPrompt"; import { backendRegistry, listBackendDescriptors } from "./backends/registry"; @@ -109,6 +109,19 @@ function backendSystemPromptKey(_backendId: BackendId): string { return buildAgentSystemPrompt(); } +/** + * Order-independent dedup key for a backend's env overrides, so the restart + * subscription fires iff the effective record actually changes (not on key + * reordering or unrelated settings writes). + */ +function backendEnvOverridesKey(settings: CopilotSettings, backendId: BackendId): string { + const backends = settings.agentMode?.backends as + | Partial }>> + | undefined; + const env = backends?.[backendId]?.envOverrides ?? {}; + return JSON.stringify(Object.entries(env).sort(([a], [b]) => a.localeCompare(b))); +} + /** * Single seam between the plugin host (`main.ts`) and Agent Mode. Initialises * the SkillManager singleton, wires the default permission prompter into a @@ -220,6 +233,27 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen } }; subscribeToSystemPromptChange(restartSystemPromptAffected); + // Env overrides are baked into subprocess spawn env, and the Claude SDK + // folds them into its model catalog (a custom `ANTHROPIC_MODEL` becomes a + // picker entry). Either way an edit only reaches the live or warm process on + // a fresh spawn/probe, so restart the backend whose record actually changed. + // The editor debounces commits and the manager/preloader coalesce rapid + // restarts, so a burst of edits folds into one re-probe (same rationale as + // the provider-config subscription above). + subscribeToSettingsChange((prev, next) => { + for (const descriptor of listBackendDescriptors()) { + if ( + backendEnvOverridesKey(prev, descriptor.id) === backendEnvOverridesKey(next, descriptor.id) + ) { + continue; + } + void manager + .restartBackend(descriptor.id, "env overrides changed") + .catch((error) => + logError(`[AgentMode] restart after env overrides change failed: ${descriptor.id}`, error) + ); + } + }); // Seed the plugin-shipped builtin skills into the canonical folder, then run // discovery so the pass picks them up and fans them out to the agent dirs. // The Plus relay skills are always seeded; the Miyo vault-search skill is diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts index 9def039e..498b1936 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.test.ts @@ -548,6 +548,43 @@ describe("ClaudeSdkBackendProcess.newSession dynamic catalog", () => { }); }); +describe("ANTHROPIC_MODEL env override reaches the catalog probe", () => { + beforeEach(() => { + queryMock.mockReset(); + createSdkMcpServerMock.mockClear(); + }); + + it("threads the backend's env overrides into the probe on a cold cache", async () => { + // Cold module cache forces a real probe; the SDK reflects ANTHROPIC_MODEL + // into init.models itself, so the env just has to reach the probe. + (getCachedSdkCatalog as jest.Mock).mockReturnValue(undefined); + const initializationResult = jest.fn().mockResolvedValue({ models: FAKE_CATALOG }); + queryMock.mockReturnValue({ + initializationResult, + interrupt: jest.fn().mockResolvedValue(undefined), + }); + + const proc = new ClaudeSdkBackendProcess({ + pathToClaudeCodeExecutable: "/usr/local/bin/claude", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + app: { vault: {} } as any, + clientVersion: "1.2.3", + descriptor: fakeDescriptor(), + getEnvOverrides: () => ({ ANTHROPIC_MODEL: "claude-fable-5" }), + }); + + await proc.newSession({ cwd: "/vault", mcpServers: [] }); + + const probeCall = queryMock.mock.calls[0][0] as { + options: { pathToClaudeCodeExecutable: string; env?: Record }; + }; + expect(probeCall.options.pathToClaudeCodeExecutable).toBe("/usr/local/bin/claude"); + expect(probeCall.options.env?.ANTHROPIC_MODEL).toBe("claude-fable-5"); + // Child env is process.env plus the overrides, not a bare override map. + expect(probeCall.options.env?.PATH).toBe(process.env.PATH); + }); +}); + function errorResultMessage(errors: string[]): SDKMessage { return { type: "result", diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts index 66cc8a79..c022ac3a 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts @@ -600,19 +600,21 @@ export class ClaudeSdkBackendProcess implements BackendProcess { */ private ensureModelCatalog(): Promise { if (this.cachedModels) return Promise.resolve(this.cachedModels); - const fromCache = getCachedSdkCatalog(); + const envOverrides = this.opts.getEnvOverrides?.(); + const fromCache = getCachedSdkCatalog(envOverrides); if (fromCache && fromCache.length > 0) { this.cachedModels = fromCache; return Promise.resolve(fromCache); } if (this.cachedModelsProbe) return this.cachedModelsProbe; - const probePromise = probeClaudeSdkCatalog(this.opts.pathToClaudeCodeExecutable).then( - (models) => { - if (models.length > 0) this.cachedModels = models; - else this.cachedModelsProbe = null; - return models; - } - ); + const probePromise = probeClaudeSdkCatalog( + this.opts.pathToClaudeCodeExecutable, + envOverrides + ).then((models) => { + if (models.length > 0) this.cachedModels = models; + else this.cachedModelsProbe = null; + return models; + }); this.cachedModelsProbe = probePromise; return probePromise; } diff --git a/src/agentMode/sdk/effortOption.test.ts b/src/agentMode/sdk/effortOption.test.ts new file mode 100644 index 00000000..7d117c57 --- /dev/null +++ b/src/agentMode/sdk/effortOption.test.ts @@ -0,0 +1,71 @@ +const queryMock = jest.fn(); +jest.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: (...args: unknown[]) => queryMock(...args), +})); + +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import { getCachedSdkCatalog, probeClaudeSdkCatalog } from "./effortOption"; + +const CATALOG: ModelInfo[] = [{ value: "claude-x", displayName: "Claude X", description: "test" }]; + +function fakeProbe(models: ModelInfo[]) { + return { + initializationResult: jest.fn().mockResolvedValue({ models }), + interrupt: jest.fn().mockResolvedValue(undefined), + }; +} + +beforeEach(() => queryMock.mockReset()); + +describe("probeClaudeSdkCatalog env passing", () => { + it("merges env overrides onto process.env so the CLI can reflect ANTHROPIC_MODEL", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + const models = await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "m-custom" }); + expect(models).toBe(CATALOG); + const opts = ( + queryMock.mock.calls[0][0] as { + options: { pathToClaudeCodeExecutable: string; env?: Record }; + } + ).options; + expect(opts.pathToClaudeCodeExecutable).toBe("/bin/claude"); + expect(opts.env?.ANTHROPIC_MODEL).toBe("m-custom"); + // process.env is preserved (Options.env replaces the child env wholesale). + expect(opts.env?.PATH).toBe(process.env.PATH); + }); + + it("omits options.env entirely when there are no overrides", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + await probeClaudeSdkCatalog("/bin/claude", undefined); + const opts = (queryMock.mock.calls[0][0] as { options: { env?: unknown } }).options; + expect(opts.env).toBeUndefined(); + }); +}); + +describe("getCachedSdkCatalog is scoped to the probe's env overrides", () => { + it("serves the cache only for a matching env key, order-independent", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + await probeClaudeSdkCatalog("/bin/claude", { A: "1", B: "2" }); + expect(getCachedSdkCatalog({ B: "2", A: "1" })).toBe(CATALOG); + expect(getCachedSdkCatalog({ A: "1", B: "9" })).toBeNull(); + expect(getCachedSdkCatalog(undefined)).toBeNull(); + }); + + it("a fresh probe under a new env replaces the cached entry", async () => { + queryMock.mockReturnValue(fakeProbe(CATALOG)); + await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "first" }); + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "first" })).toBe(CATALOG); + + const CATALOG2: ModelInfo[] = [{ value: "y", displayName: "Y", description: "t" }]; + queryMock.mockReturnValue(fakeProbe(CATALOG2)); + await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "second" }); + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "second" })).toBe(CATALOG2); + // Single-slot, env-scoped: the prior env no longer hits. + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "first" })).toBeNull(); + }); + + it("leaves the cache unwritten when the probe returns no models", async () => { + queryMock.mockReturnValue(fakeProbe([])); + await probeClaudeSdkCatalog("/bin/claude", { ANTHROPIC_MODEL: "empty-case" }); + expect(getCachedSdkCatalog({ ANTHROPIC_MODEL: "empty-case" })).toBeNull(); + }); +}); diff --git a/src/agentMode/sdk/effortOption.ts b/src/agentMode/sdk/effortOption.ts index c7803b11..ec4f5b2a 100644 --- a/src/agentMode/sdk/effortOption.ts +++ b/src/agentMode/sdk/effortOption.ts @@ -3,6 +3,7 @@ import { query, type EffortLevel, type ModelInfo, + type Options, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { logWarn } from "@/logger"; @@ -54,14 +55,31 @@ export function resolveSeedModelId( } /** - * Plugin-lifetime cache of the SDK's model catalog, shared across every - * `ClaudeSdkBackendProcess` instance so opening a chat doesn't re-spawn - * the `claude` CLI to read the model list. + * Order-independent key for the env overrides that influence which models the + * `claude` CLI advertises — chiefly `ANTHROPIC_MODEL`, which the CLI reflects + * into its init handshake. Used to scope the catalog cache so editing the + * override invalidates a stale entry. Mirrors `backendEnvOverridesKey` in + * `agentMode/index.ts`. */ -let cachedSdkCatalog: ModelInfo[] | null = null; +function catalogEnvKey(envOverrides: Record | undefined): string { + const entries = Object.entries(envOverrides ?? {}).sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); +} -export function getCachedSdkCatalog(): ModelInfo[] | null { - return cachedSdkCatalog; +/** + * Plugin-lifetime cache of the SDK's model catalog, shared across every + * `ClaudeSdkBackendProcess` instance so opening a chat doesn't re-spawn the + * `claude` CLI to read the model list. Keyed by the env overrides passed to + * the probe: editing `ANTHROPIC_MODEL` yields a different key, so the stale + * entry is ignored and the next `ensureModelCatalog()` re-probes with the new + * env (the backend is also restarted on the same settings change). + */ +let cachedSdkCatalog: { key: string; models: ModelInfo[] } | null = null; + +export function getCachedSdkCatalog( + envOverrides: Record | undefined +): ModelInfo[] | null { + return cachedSdkCatalog?.key === catalogEnvKey(envOverrides) ? cachedSdkCatalog.models : null; } /** @@ -69,28 +87,41 @@ export function getCachedSdkCatalog(): ModelInfo[] | null { * handshake — which carries the catalog of models the bundled `claude` * CLI advertises (per-model `supportsEffort` + `supportedEffortLevels`). * + * `envOverrides` (the user's Claude env-override box) is merged onto + * `process.env` for the probe. This is what surfaces a custom + * `ANTHROPIC_MODEL`: the CLI reflects the override into `init.models` as a + * first-class entry with full effort metadata, so no synthetic entry is + * needed and there's a single source of truth. + * * The SDK requires streaming-input mode to expose `initializationResult()`, * so we feed it a generator that never yields and tear the query down * via `interrupt()` once the handshake completes. Failures resolve to * an empty array (logged) so callers can degrade gracefully. * - * Successful, non-empty probes update the module-level cache so a later - * `getCachedSdkCatalog()` returns hot data without re-probing. + * Successful, non-empty probes update the module-level cache (keyed by + * `envOverrides`) so a later `getCachedSdkCatalog()` returns hot data without + * re-probing. */ export async function probeClaudeSdkCatalog( - pathToClaudeCodeExecutable: string + pathToClaudeCodeExecutable: string, + envOverrides?: Record ): Promise { // eslint-disable-next-line require-yield const noopPrompt = (async function* (): AsyncIterable { await new Promise(() => {}); })(); - const probe = query({ - prompt: noopPrompt, - options: { pathToClaudeCodeExecutable }, - }); + const options: Options = { pathToClaudeCodeExecutable }; + if (envOverrides && Object.keys(envOverrides).length > 0) { + // Options.env replaces (not merges with) the child env, so include + // process.env to preserve PATH and friends — same as the prompt path. + options.env = { ...process.env, ...envOverrides }; + } + const probe = query({ prompt: noopPrompt, options }); try { const init = await probe.initializationResult(); - if (init.models.length > 0) cachedSdkCatalog = init.models; + if (init.models.length > 0) { + cachedSdkCatalog = { key: catalogEnvKey(envOverrides), models: init.models }; + } return init.models; } catch (e) { logWarn("[AgentMode] Claude SDK init probe failed", e);