mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
05f5d7f7c4
commit
e962c79f60
6 changed files with 199 additions and 24 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
71
src/agentMode/sdk/effortOption.test.ts
Normal file
71
src/agentMode/sdk/effortOption.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue