Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion base-action/src/parse-sdk-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,50 @@ function unescapeShellMeta(s: string): string {
return s.replace(SHELL_META_UNESCAPE_RE, (c) => SHELL_META_UNESCAPE.get(c)!);
}

// Default model alias for the first-party Anthropic API when the caller
// specifies no model anywhere. An alias (resolved server-side to the current
// snapshot) is used instead of a dated model id so the action never inherits
// the SDK/CLI's bundled default, which can point at a retired model and return
// a 404 not_found_error. See issue #1416.
const DEFAULT_FIRST_PARTY_MODEL = "sonnet";

/**
* Whether a non-first-party provider is configured. Bedrock/Vertex/Foundry use
* provider-specific model id formats for which a first-party alias is invalid,
* so we must not inject a default for them.
*/
function isThirdPartyProvider(env: NodeJS.ProcessEnv): boolean {
return Boolean(
env.CLAUDE_CODE_USE_BEDROCK ||
env.CLAUDE_CODE_USE_VERTEX ||
env.CLAUDE_CODE_USE_FOUNDRY,
);
}

/**
* Resolve the model passed to the SDK.
* Precedence: explicit `options.model` > `--model` in claudeArgs (extraArgs) >
* first-party default alias. Returns undefined when a model is already supplied
* via claudeArgs (so the CLI flag wins) or when a third-party provider is in use
* (so the provider resolves its own default).
*/
function resolveModel(
optionModel: string | undefined,
extraArgs: Record<string, string | null>,
env: NodeJS.ProcessEnv,
): string | undefined {
const explicit = optionModel?.trim();
if (explicit) return explicit;

// A --model in claudeArgs is preserved in extraArgs and passed to the CLI;
// don't also set sdkOptions.model or it would be specified twice.
if (extraArgs["model"]) return undefined;

if (isThirdPartyProvider(env)) return undefined;

return DEFAULT_FIRST_PARTY_MODEL;
}

type McpConfig = {
mcpServers?: Record<string, unknown>;
};
Expand Down Expand Up @@ -295,7 +339,7 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
// Build SDK options - use merged tools from both direct options and claudeArgs
const sdkOptions: SdkOptions = {
// Direct options from ClaudeOptions inputs
model: options.model,
model: resolveModel(options.model, extraArgs, process.env),
maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined,
allowedTools:
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
Expand Down
84 changes: 84 additions & 0 deletions base-action/test/parse-sdk-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,4 +526,88 @@ describe("parseSdkOptions", () => {
}
});
});

describe("model defaulting", () => {
// Guard against leaking provider env vars between tests.
function withEnv(env: Record<string, string | undefined>, fn: () => void) {
const originalEnv = { ...process.env };
delete process.env.CLAUDE_CODE_USE_BEDROCK;
delete process.env.CLAUDE_CODE_USE_VERTEX;
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
Object.assign(process.env, env);
try {
fn();
} finally {
process.env = originalEnv;
}
}

test("should default to the first-party alias when no model is specified", () => {
// Regression for #1416: with no model anywhere, the action used to pass
// model: undefined and inherit the SDK/CLI's retired default (404).
withEnv({}, () => {
const result = parseSdkOptions({});
expect(result.sdkOptions.model).toBe("sonnet");
});
});

test("should preserve an explicit options.model", () => {
withEnv({}, () => {
const result = parseSdkOptions({ model: "claude-opus-4-7" });
expect(result.sdkOptions.model).toBe("claude-opus-4-7");
});
});

test("should trim whitespace from an explicit options.model", () => {
withEnv({}, () => {
const result = parseSdkOptions({ model: " claude-opus-4-7 " });
expect(result.sdkOptions.model).toBe("claude-opus-4-7");
});
});

test("should not set sdkOptions.model when --model is provided via claudeArgs", () => {
// The CLI flag in extraArgs wins; setting model here too would specify it twice.
withEnv({}, () => {
const result = parseSdkOptions({
claudeArgs: "--model claude-sonnet-4-6",
});
expect(result.sdkOptions.model).toBeUndefined();
expect(result.sdkOptions.extraArgs?.["model"]).toBe(
"claude-sonnet-4-6",
);
});
});

test("should not inject a default for Bedrock", () => {
withEnv({ CLAUDE_CODE_USE_BEDROCK: "1" }, () => {
const result = parseSdkOptions({});
expect(result.sdkOptions.model).toBeUndefined();
});
});

test("should not inject a default for Vertex", () => {
withEnv({ CLAUDE_CODE_USE_VERTEX: "1" }, () => {
const result = parseSdkOptions({});
expect(result.sdkOptions.model).toBeUndefined();
});
});

test("should not inject a default for Foundry", () => {
withEnv({ CLAUDE_CODE_USE_FOUNDRY: "1" }, () => {
const result = parseSdkOptions({});
expect(result.sdkOptions.model).toBeUndefined();
});
});

test("should preserve an explicit model even for third-party providers", () => {
withEnv({ CLAUDE_CODE_USE_BEDROCK: "1" }, () => {
const result = parseSdkOptions({
model: "anthropic.claude-3-5-sonnet-20241022-v2:0",
});
expect(result.sdkOptions.model).toBe(
"anthropic.claude-3-5-sonnet-20241022-v2:0",
);
});
});
});
});