diff --git a/base-action/src/setup-claude-code-settings.ts b/base-action/src/setup-claude-code-settings.ts index 0fe68414f..a11475146 100644 --- a/base-action/src/setup-claude-code-settings.ts +++ b/base-action/src/setup-claude-code-settings.ts @@ -2,6 +2,21 @@ import { $ } from "bun"; import { homedir } from "os"; import { readFile } from "fs/promises"; +// Env vars that resolve a model alias (sonnet/opus/haiku) to a concrete +// model id or proxy preset. The Agent SDK's top-level call inherits these +// from process.env, but spawned sub-call subprocesses (Task tool, sub-agents) +// have historically dropped them and fallen back to the SDK's hardcoded +// default — e.g. routing to literal `claude-sonnet-4-6` even when the user +// opted into a non-Anthropic preset via ANTHROPIC_DEFAULT_SONNET_MODEL. +// Mirroring these into settings.env guarantees the CLI propagates them to +// every session, including sub-calls, via the documented settings layer. +// See: https://github.com/anthropics/claude-code-action/issues/1258 +const MODEL_ALIAS_ENV_VARS = [ + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", +] as const; + export async function setupClaudeCodeSettings( settingsInput?: string, homeDir?: string, @@ -63,6 +78,31 @@ export async function setupClaudeCodeSettings( settings.enableAllProjectMcpServers = true; console.log(`Updated settings with enableAllProjectMcpServers: true`); + // Forward model-alias env vars into settings.env so the CLI propagates them + // to every session (top-level + sub-calls). User-provided settings.env + // entries take precedence; we only fill in keys the user didn't already set. + const userEnv = + typeof settings.env === "object" && settings.env !== null + ? (settings.env as Record) + : undefined; + const mergedEnv: Record = { ...(userEnv ?? {}) }; + const injectedKeys: string[] = []; + for (const key of MODEL_ALIAS_ENV_VARS) { + const value = process.env[key]; + if (value && !(key in mergedEnv)) { + mergedEnv[key] = value; + injectedKeys.push(key); + } + } + if (injectedKeys.length > 0 || userEnv) { + settings.env = mergedEnv; + if (injectedKeys.length > 0) { + console.log( + `Forwarded model-alias env vars to settings.env: ${injectedKeys.join(", ")}`, + ); + } + } + await $`echo ${JSON.stringify(settings, null, 2)} > ${settingsPath}`.quiet(); console.log(`Settings saved successfully`); } diff --git a/base-action/test/setup-claude-code-settings.test.ts b/base-action/test/setup-claude-code-settings.test.ts index defe25149..7cef6de61 100644 --- a/base-action/test/setup-claude-code-settings.test.ts +++ b/base-action/test/setup-claude-code-settings.test.ts @@ -147,4 +147,107 @@ describe("setupClaudeCodeSettings", () => { expect(settings.newKey).toBe("newValue"); expect(settings.model).toBe("claude-opus-4-1-20250805"); }); + + describe("model-alias env var forwarding (issue #1258)", () => { + const MODEL_ENV_VARS = [ + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ] as const; + + let originalEnv: Record; + + beforeEach(() => { + originalEnv = {}; + for (const key of MODEL_ENV_VARS) { + originalEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of MODEL_ENV_VARS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + }); + + test("should mirror ANTHROPIC_DEFAULT_*_MODEL env vars into settings.env", async () => { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = + "@preset/minimax-minimax-m2-7-no-thinking"; + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "@preset/some-opus-preset"; + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "@preset/some-haiku-preset"; + + await setupClaudeCodeSettings(undefined, testHomeDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + const settings = JSON.parse(settingsContent); + + expect(settings.env).toEqual({ + ANTHROPIC_DEFAULT_SONNET_MODEL: + "@preset/minimax-minimax-m2-7-no-thinking", + ANTHROPIC_DEFAULT_OPUS_MODEL: "@preset/some-opus-preset", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "@preset/some-haiku-preset", + }); + }); + + test("should forward only the env vars that are set", async () => { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "sonnet-preset"; + + await setupClaudeCodeSettings(undefined, testHomeDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + const settings = JSON.parse(settingsContent); + + expect(settings.env).toEqual({ + ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet-preset", + }); + }); + + test("should not create settings.env when no model-alias env vars are set", async () => { + await setupClaudeCodeSettings(undefined, testHomeDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + const settings = JSON.parse(settingsContent); + + expect(settings.env).toBeUndefined(); + }); + + test("should preserve user-provided settings.env keys (user wins on conflict)", async () => { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "sonnet-from-env"; + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "opus-from-env"; + + const userSettings = JSON.stringify({ + env: { + ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet-from-user-settings", + CUSTOM_VAR: "custom-value", + }, + }); + + await setupClaudeCodeSettings(userSettings, testHomeDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + const settings = JSON.parse(settingsContent); + + expect(settings.env).toEqual({ + ANTHROPIC_DEFAULT_SONNET_MODEL: "sonnet-from-user-settings", + ANTHROPIC_DEFAULT_OPUS_MODEL: "opus-from-env", + CUSTOM_VAR: "custom-value", + }); + }); + + test("should ignore empty env var values", async () => { + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = ""; + + await setupClaudeCodeSettings(undefined, testHomeDir); + + const settingsContent = await readFile(settingsPath, "utf-8"); + const settings = JSON.parse(settingsContent); + + expect(settings.env).toBeUndefined(); + }); + }); });