diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts index cf6d61a4d4d3..3bfda0bc1315 100644 --- a/extensions/amazon-bedrock/stream.runtime.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -167,7 +167,22 @@ describe("Bedrock profile endpoint resolution", () => { }); describe("Bedrock thinking effort mapping", () => { - it("caps max effort at high for Claude Sonnet 4.6", () => { + it("forces adaptive thinking for mandatory Claude models when callers omit reasoning", () => { + const model = bedrockModel({ + id: "anthropic.claude-sonnet-4-6-v1:0", + name: "Claude Sonnet 4.6", + reasoning: true, + }); + const options = testing.resolveSimpleBedrockOptions(model, {}); + + expect(options.reasoning).toBe("high"); + expect(testing.buildAdditionalModelRequestFields(model, options)).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "high" }, + }); + }); + + it("clamps max effort for Claude models without native max support", () => { expect( testing.mapThinkingLevelToEffort( bedrockModel({ diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index 883eabc89353..6f517bf1354a 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -53,6 +53,7 @@ import { type ToolResultMessage, } from "openclaw/plugin-sdk/llm"; import { + isClaudeAdaptiveThinkingDefaultModelId, resolveClaudeFable5ModelIdentity, resolveClaudeModelIdentity, supportsClaudeAdaptiveThinking, @@ -351,29 +352,38 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp model: Model<"bedrock-converse-stream">, context: Context, options?: SimpleStreamOptions, -) => { +) => streamBedrock(model, context, resolveSimpleBedrockOptions(model, options)); + +function resolveSimpleBedrockOptions( + model: Model<"bedrock-converse-stream">, + options?: SimpleStreamOptions, +): BedrockOptions { const base = buildBaseOptions(model, options, undefined); if (usesClaudeFable5BedrockContract(model)) { - return streamBedrock(model, context, { + return { ...base, reasoning: options?.reasoning ?? "high", thinkingBudgets: options?.thinkingBudgets, - } satisfies BedrockOptions); + } satisfies BedrockOptions; } if (!options?.reasoning) { - return streamBedrock(model, context, { + const reasoning = + isAnthropicClaudeModel(model) && requiresMandatoryAdaptiveThinking(model) + ? "high" + : undefined; + return { ...base, - reasoning: undefined, - } satisfies BedrockOptions); + reasoning, + } satisfies BedrockOptions; } if (isAnthropicClaudeModel(model)) { if (supportsAdaptiveThinking(model)) { - return streamBedrock(model, context, { + return { ...base, reasoning: options.reasoning, thinkingBudgets: options.thinkingBudgets, - } satisfies BedrockOptions); + } satisfies BedrockOptions; } // Undefined means the caller did not request an output cap; let the helper use the model cap. @@ -385,7 +395,7 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp options.thinkingBudgets, ); - return streamBedrock(model, context, { + return { ...base, maxTokens: adjusted.maxTokens, reasoning: options.reasoning, @@ -393,15 +403,15 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp ...options.thinkingBudgets, [clampReasoning(options.reasoning)!]: adjusted.thinkingBudget, }, - } satisfies BedrockOptions); + } satisfies BedrockOptions; } - return streamBedrock(model, context, { + return { ...base, reasoning: options.reasoning, thinkingBudgets: options.thinkingBudgets, - } satisfies BedrockOptions); -}; + } satisfies BedrockOptions; +} function handleContentBlockStart( event: ContentBlockStartEvent, @@ -565,6 +575,14 @@ function supportsAdaptiveThinking(model: Model<"bedrock-converse-stream">): bool ); } +function requiresMandatoryAdaptiveThinking(model: Model<"bedrock-converse-stream">): boolean { + const profileModelId = resolveClaudeProfileNameModelId(model.name); + return ( + isClaudeAdaptiveThinkingDefaultModelId(resolveClaudeModelIdentity(model)) || + (profileModelId ? isClaudeAdaptiveThinkingDefaultModelId(profileModelId) : false) + ); +} + function supportsNativeXhighEffort(model: Model<"bedrock-converse-stream">): boolean { const profileModelId = resolveClaudeProfileNameModelId(model.name); return ( @@ -1071,9 +1089,11 @@ function createImageBlock(mimeType: string, data: string) { /** Test-only hooks for Bedrock runtime conversion and endpoint policy. */ export const testing = { + buildAdditionalModelRequestFields, convertMessages, getConfiguredBedrockRegion, hasConfiguredBedrockProfile, mapThinkingLevelToEffort, + resolveSimpleBedrockOptions, shouldUseExplicitBedrockEndpoint, }; diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index b9ad29985728..6463be8c91b7 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -1307,11 +1307,11 @@ describe("microsoft-foundry plugin", () => { const provider = result.configPatch?.models?.providers?.["microsoft-foundry"] as | Record | undefined; - expect(provider).toMatchObject({ - authHeader: true, - apiKey: null, - headers: null, - }); + expect(provider?.authHeader).toBe(true); + expect(Object.hasOwn(provider ?? {}, "apiKey")).toBe(true); + expect(Object.hasOwn(provider ?? {}, "headers")).toBe(true); + expect(provider?.apiKey).toBeUndefined(); + expect(provider?.headers).toBeUndefined(); }); it.each([ diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts index 92fe24bc8423..34117f424b87 100644 --- a/extensions/microsoft-foundry/shared.ts +++ b/extensions/microsoft-foundry/shared.ts @@ -111,8 +111,8 @@ type FoundryModelCapabilities = { }; type FoundryProviderConfigPatch = Omit & { - apiKey?: SecretInput | null; - headers?: Record | null; + apiKey?: SecretInput | undefined; + headers?: Record | undefined; }; function normalizeModelInput(input?: unknown): Array<"text" | "image"> { @@ -481,7 +481,7 @@ function buildFoundryProviderConfig( : {}), } : isEntraIdAuth - ? { authHeader: true, apiKey: null, headers: null } + ? { authHeader: true, apiKey: undefined, headers: undefined } : {}), models: deployments.map((deployment) => { const capabilities = resolveFoundryModelCapabilities( diff --git a/src/plugins/provider-auth-choice-helpers.test.ts b/src/plugins/provider-auth-choice-helpers.test.ts index c84846a1bfcc..aa92cec2cde6 100644 --- a/src/plugins/provider-auth-choice-helpers.test.ts +++ b/src/plugins/provider-auth-choice-helpers.test.ts @@ -118,6 +118,43 @@ describe("applyProviderAuthConfigPatch", () => { }); }); + it("deletes provider auth fields marked undefined by auth patches", () => { + const baseLocal = { + models: { + providers: { + "microsoft-foundry": { + baseUrl: "https://example.services.ai.azure.com/openai/v1", + api: "anthropic-messages", + authHeader: false, + apiKey: "FOUNDRY_API_KEY", + headers: { "api-key": "FOUNDRY_API_KEY" }, + models: [], + }, + }, + }, + } satisfies OpenClawConfig; + const patch = { + models: { + providers: { + "microsoft-foundry": { + authHeader: true, + apiKey: undefined, + headers: undefined, + }, + }, + }, + }; + + const next = applyProviderAuthConfigPatch(baseLocal, patch); + const provider = next.models?.providers?.["microsoft-foundry"] as + | Record + | undefined; + + expect(provider).toMatchObject({ authHeader: true }); + expect(provider).not.toHaveProperty("apiKey"); + expect(provider).not.toHaveProperty("headers"); + }); + it("normalizes retired Google Gemini model refs from provider config patches", () => { const patch = { agents: { diff --git a/src/plugins/provider-auth-choice-helpers.ts b/src/plugins/provider-auth-choice-helpers.ts index c2137d27e444..1bc3e631cbd5 100644 --- a/src/plugins/provider-auth-choice-helpers.ts +++ b/src/plugins/provider-auth-choice-helpers.ts @@ -94,6 +94,22 @@ function mergeConfigPatch(base: T, patch: unknown): T { return next as T; } +function deleteUndefinedPatchLeaves(target: T, patch: unknown): T { + if (!isPlainRecord(target) || !isPlainRecord(patch)) { + return target; + } + + const targetRecord = target as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + delete targetRecord[key]; + continue; + } + deleteUndefinedPatchLeaves(targetRecord[key], value); + } + return target; +} + function normalizeAgentModelConfigForWrite(value: unknown): unknown { if (typeof value === "string") { return normalizeAgentModelRefForConfig(value); @@ -259,7 +275,9 @@ export function applyProviderAuthConfigPatch( patch: unknown, options?: { replaceDefaultModels?: boolean }, ): OpenClawConfig { - const merged = normalizeConfigModelRefsForWrite(mergeConfigPatch(cfg, patch)); + const merged = normalizeConfigModelRefsForWrite( + deleteUndefinedPatchLeaves(mergeConfigPatch(cfg, patch), patch), + ); if (!options?.replaceDefaultModels || !isPlainRecord(patch)) { return merged; }