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=<id> 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) <noreply@anthropic.com>

* 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.<id>.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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Logan Yang 2026-06-09 23:12:59 -07:00 committed by GitHub
parent 05f5d7f7c4
commit e962c79f60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 199 additions and 24 deletions

View file

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

View file

@ -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<Record<BackendId, { envOverrides?: Record<string, string> }>>
| 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

View file

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

View file

@ -600,19 +600,21 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
*/
private ensureModelCatalog(): Promise<ModelInfo[]> {
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;
}

View file

@ -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<string, string> };
}
).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();
});
});

View file

@ -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<string, string> | 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<string, string> | 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<string, string>
): Promise<ModelInfo[]> {
// eslint-disable-next-line require-yield
const noopPrompt = (async function* (): AsyncIterable<SDKUserMessage> {
await new Promise<void>(() => {});
})();
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);