From 18507ed85fa9094255555f7d553d089791da57cb Mon Sep 17 00:00:00 2001 From: wirjo Date: Thu, 23 Apr 2026 09:09:39 +1000 Subject: [PATCH] feat(amazon-bedrock-mantle): add Claude Opus 4.7 via per-model Anthropic Messages API override (#68730) * feat(amazon-bedrock-mantle): add Claude Opus 4.7 via Anthropic auth * fix(amazon-bedrock-mantle): keep Opus 4.7 transport-safe * fix(amazon-bedrock-mantle): restore anthropic base url helper * fix(auto-reply): apply runtime auth to conversation labels --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../amazon-bedrock-mantle/discovery.test.ts | 9 +- extensions/amazon-bedrock-mantle/discovery.ts | 32 +++++- .../amazon-bedrock-mantle/index.test.ts | 33 +++++- .../mantle-anthropic.runtime.test.ts | 101 +++++++++++++++++ .../mantle-anthropic.runtime.ts | 106 ++++++++++++++++++ .../register.sync.runtime.ts | 7 +- .../conversation-label-generator.test.ts | 19 ++-- .../reply/conversation-label-generator.ts | 9 +- 9 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts create mode 100644 extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd05888d94..fda5bfa3cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Providers/Amazon Bedrock Mantle: add Claude Opus 4.7 through Mantle's Anthropic Messages route with provider-owned bearer-auth streaming, so the model is actually callable without treating AWS bearer tokens like Anthropic API keys. Thanks @wirjo. - OpenAI/Responses: use OpenAI's native `web_search` tool automatically for direct OpenAI Responses models when web search is enabled and no managed search provider is pinned; explicit providers such as Brave keep the managed `web_search` tool. - ACPX: add an explicit `openClawToolsMcpBridge` option that injects a core OpenClaw MCP server for selected built-in tools, starting with `cron`. - Providers/GPT-5: move the GPT-5 prompt overlay into the shared provider runtime so compatible GPT-5 models receive the same behavior and heartbeat guidance through OpenAI, OpenRouter, OpenCode, Codex, and other GPT providers; add `agents.defaults.promptOverlays.gpt5.personality` as the global friendly-style toggle while keeping the OpenAI plugin setting as a fallback. diff --git a/extensions/amazon-bedrock-mantle/discovery.test.ts b/extensions/amazon-bedrock-mantle/discovery.test.ts index 544c86339e2..acf5fb0a4dc 100644 --- a/extensions/amazon-bedrock-mantle/discovery.test.ts +++ b/extensions/amazon-bedrock-mantle/discovery.test.ts @@ -379,7 +379,14 @@ describe("bedrock mantle discovery", () => { expect(provider?.api).toBe("openai-completions"); expect(provider?.auth).toBe("api-key"); expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK"); - expect(provider?.models).toHaveLength(1); + expect(provider?.models).toHaveLength(2); + expect(provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7")).toMatchObject( + { + api: "anthropic-messages", + baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic", + reasoning: false, + }, + ); }); it("returns null when no auth is available", async () => { diff --git a/extensions/amazon-bedrock-mantle/discovery.ts b/extensions/amazon-bedrock-mantle/discovery.ts index 62151e2326a..5a18bbd0715 100644 --- a/extensions/amazon-bedrock-mantle/discovery.ts +++ b/extensions/amazon-bedrock-mantle/discovery.ts @@ -43,6 +43,10 @@ function mantleEndpoint(region: string): string { return `https://bedrock-mantle.${region}.api.aws`; } +function mantleAnthropicBaseUrl(region: string): string { + return `https://bedrock-mantle.${region}.api.aws/anthropic`; +} + function isSupportedRegion(region: string): boolean { return (MANTLE_SUPPORTED_REGIONS as readonly string[]).includes(region); } @@ -166,7 +170,6 @@ export async function resolveMantleRuntimeBearerToken(params: { expiresAt: refreshed?.expiresAt ?? now + IAM_TOKEN_TTL_MS, }; } - /** Reset the IAM token cache (for testing). */ export function resetIamTokenCacheForTest(): void { iamTokenCache.clear(); @@ -343,12 +346,37 @@ export async function resolveImplicitMantleProvider(params: { log.debug?.("Mantle provider resolved", { region, modelCount: models.length }); + // Append Claude models available on Mantle's Anthropic Messages endpoint. + // Opus 4.7 currently needs the provider-owned bearer-auth path here, but we + // keep reasoning off until the underlying Anthropic transport learns Opus 4.7 + // adaptive thinking semantics. + const anthropicBaseUrl = mantleAnthropicBaseUrl(region); + const claudeModels: ModelDefinitionConfig[] = [ + { + id: "anthropic.claude-opus-4-7", + name: "Claude Opus 4.7", + api: "anthropic-messages" as const, + baseUrl: anthropicBaseUrl, + reasoning: false, + input: ["text", "image"], + cost: { + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }, + contextWindow: 1_000_000, + maxTokens: 128_000, + }, + ]; + const allModels = [...models, ...claudeModels]; + return { baseUrl: `${mantleEndpoint(region)}/v1`, api: "openai-completions", auth: "api-key", apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : MANTLE_IAM_TOKEN_MARKER, - models, + models: allModels, }; } diff --git a/extensions/amazon-bedrock-mantle/index.test.ts b/extensions/amazon-bedrock-mantle/index.test.ts index d72c6a88c13..72675640f89 100644 --- a/extensions/amazon-bedrock-mantle/index.test.ts +++ b/extensions/amazon-bedrock-mantle/index.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js"; import bedrockMantlePlugin from "./index.js"; describe("amazon-bedrock-mantle provider plugin", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + it("registers with correct provider ID and label", async () => { const provider = await registerSingleProviderPlugin(bedrockMantlePlugin); expect(provider.id).toBe("amazon-bedrock-mantle"); @@ -20,5 +24,32 @@ describe("amazon-bedrock-mantle provider plugin", () => { expect( provider.classifyFailoverReason?.({ errorMessage: "some other error" } as never), ).toBeUndefined(); + expect( + provider.classifyFailoverReason?.({ errorMessage: "overloaded_error" } as never), + ).toBe("overloaded"); + }); + + it("provides a custom stream only for Mantle Anthropic models", async () => { + const provider = await registerSingleProviderPlugin(bedrockMantlePlugin); + + expect( + typeof provider.createStreamFn?.({ + provider: "amazon-bedrock-mantle", + modelId: "anthropic.claude-opus-4-7", + model: { + api: "anthropic-messages", + }, + } as never), + ).toBe("function"); + + expect( + provider.createStreamFn?.({ + provider: "amazon-bedrock-mantle", + modelId: "openai.gpt-oss-120b", + model: { + api: "openai-completions", + }, + } as never), + ).toBeUndefined(); }); }); diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts new file mode 100644 index 00000000000..dea56c920ee --- /dev/null +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts @@ -0,0 +1,101 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + AnthropicConstructor: vi.fn(function MockAnthropic(options: unknown) { + return { options }; + }), + streamAnthropic: vi.fn(), +})); + +vi.mock("@anthropic-ai/sdk", () => ({ + default: mocks.AnthropicConstructor, +})); + +vi.mock("@mariozechner/pi-ai/anthropic", () => ({ + streamAnthropic: mocks.streamAnthropic, +})); + +import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js"; + +function createTestModel(): Model { + return { + id: "anthropic.claude-opus-4-7", + name: "Claude Opus 4.7", + provider: "amazon-bedrock-mantle", + api: "anthropic-messages", + baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic", + headers: { + "X-Test": "model-header", + }, + reasoning: false, + input: ["text", "image"], + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 1_000_000, + maxTokens: 128_000, + } as Model; +} + +describe("createMantleAnthropicStreamFn", () => { + it("uses authToken bearer auth for Mantle Anthropic requests", () => { + const stream = { kind: "anthropic-stream" }; + const model = createTestModel(); + const context = { messages: [] }; + mocks.streamAnthropic.mockReturnValue(stream); + + const result = createMantleAnthropicStreamFn()(model, context, { + apiKey: "bedrock-bearer-token", + headers: { + "X-Caller": "caller-header", + }, + }); + + expect(result).toBe(stream); + expect(mocks.AnthropicConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: null, + authToken: "bedrock-bearer-token", + baseURL: "https://bedrock-mantle.us-east-1.api.aws/anthropic", + defaultHeaders: expect.objectContaining({ + accept: "application/json", + "anthropic-beta": "fine-grained-tool-streaming-2025-05-14", + "X-Test": "model-header", + "X-Caller": "caller-header", + }), + }), + ); + expect(mocks.streamAnthropic).toHaveBeenCalledWith( + model, + context, + expect.objectContaining({ + client: expect.objectContaining({ + options: expect.objectContaining({ + authToken: "bedrock-bearer-token", + }), + }), + thinkingEnabled: false, + }), + ); + }); + + it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => { + const model = createTestModel(); + const context = { messages: [] }; + mocks.streamAnthropic.mockReturnValue({ kind: "anthropic-stream" }); + + void createMantleAnthropicStreamFn()(model, context, { + apiKey: "bedrock-bearer-token", + temperature: 0.2, + reasoning: "high", + }); + + expect(mocks.streamAnthropic).toHaveBeenCalledWith( + model, + context, + expect.objectContaining({ + temperature: undefined, + thinkingEnabled: false, + }), + ); + }); +}); diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts new file mode 100644 index 00000000000..0bc9ae65690 --- /dev/null +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts @@ -0,0 +1,106 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Api, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; +import { streamAnthropic } from "@mariozechner/pi-ai/anthropic"; + +const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14"; + +function requiresDefaultSampling(modelId: string): boolean { + return modelId.includes("claude-opus-4-7"); +} + +function mergeHeaders( + ...headerSources: Array | undefined> +): Record { + const merged: Record = {}; + for (const headers of headerSources) { + if (headers) { + Object.assign(merged, headers); + } + } + return merged; +} + +function buildMantleAnthropicBaseOptions( + model: Model, + options: SimpleStreamOptions | undefined, + apiKey: string, +) { + return { + temperature: requiresDefaultSampling(model.id) ? undefined : options?.temperature, + maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32_000), + signal: options?.signal, + apiKey, + cacheRetention: options?.cacheRetention, + sessionId: options?.sessionId, + onPayload: options?.onPayload, + maxRetryDelayMs: options?.maxRetryDelayMs, + metadata: options?.metadata, + }; +} + +function adjustMaxTokensForThinking( + baseMaxTokens: number, + modelMaxTokens: number, + reasoningLevel: NonNullable, + customBudgets?: SimpleStreamOptions["thinkingBudgets"], +): { maxTokens: number; thinkingBudget: number } { + const defaultBudgets = { + minimal: 1024, + low: 2048, + medium: 8192, + high: 16384, + xhigh: 16384, + } as const; + const budgets = { ...defaultBudgets, ...customBudgets }; + const minOutputTokens = 1024; + let thinkingBudget = budgets[reasoningLevel]; + const maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens); + if (maxTokens <= thinkingBudget) { + thinkingBudget = Math.max(0, maxTokens - minOutputTokens); + } + return { maxTokens, thinkingBudget }; +} + +export function createMantleAnthropicStreamFn(): StreamFn { + return (model, context, options) => { + const apiKey = options?.apiKey ?? ""; + const client = new Anthropic({ + apiKey: null, + authToken: apiKey, + baseURL: model.baseUrl, + dangerouslyAllowBrowser: true, + defaultHeaders: mergeHeaders( + { + accept: "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": MANTLE_ANTHROPIC_BETA, + }, + model.headers, + options?.headers, + ), + }); + const base = buildMantleAnthropicBaseOptions(model, options, apiKey); + if (!options?.reasoning || requiresDefaultSampling(model.id)) { + return streamAnthropic(model as Model<"anthropic-messages">, context, { + ...base, + client, + thinkingEnabled: false, + }); + } + + const adjusted = adjustMaxTokensForThinking( + base.maxTokens || 0, + model.maxTokens, + options.reasoning, + options.thinkingBudgets, + ); + return streamAnthropic(model as Model<"anthropic-messages">, context, { + ...base, + client, + maxTokens: adjusted.maxTokens, + thinkingEnabled: true, + thinkingBudgetTokens: adjusted.thinkingBudget, + }); + }; +} diff --git a/extensions/amazon-bedrock-mantle/register.sync.runtime.ts b/extensions/amazon-bedrock-mantle/register.sync.runtime.ts index b98ab4e5cd5..1948226b9b3 100644 --- a/extensions/amazon-bedrock-mantle/register.sync.runtime.ts +++ b/extensions/amazon-bedrock-mantle/register.sync.runtime.ts @@ -1,9 +1,10 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js"; import { mergeImplicitMantleProvider, resolveImplicitMantleProvider, - resolveMantleRuntimeBearerToken, resolveMantleBearerToken, + resolveMantleRuntimeBearerToken, } from "./discovery.js"; export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void { @@ -38,13 +39,15 @@ export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void { apiKey, env, }), + createStreamFn: ({ model }) => + model.api === "anthropic-messages" ? createMantleAnthropicStreamFn() : undefined, matchesContextOverflowError: ({ errorMessage }) => /context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage), classifyFailoverReason: ({ errorMessage }) => { if (/rate_limit|too many requests|429/i.test(errorMessage)) { return "rate_limit"; } - if (/overloaded|503/i.test(errorMessage)) { + if (/overloaded|503|service.*unavailable/i.test(errorMessage)) { return "overloaded"; } return undefined; diff --git a/src/auto-reply/reply/conversation-label-generator.test.ts b/src/auto-reply/reply/conversation-label-generator.test.ts index 674e0feb422..7ca2748bd59 100644 --- a/src/auto-reply/reply/conversation-label-generator.test.ts +++ b/src/auto-reply/reply/conversation-label-generator.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const completeSimple = vi.hoisted(() => vi.fn()); -const getApiKeyForModel = vi.hoisted(() => vi.fn()); +const getRuntimeAuthForModel = vi.hoisted(() => vi.fn()); const requireApiKey = vi.hoisted(() => vi.fn()); const resolveDefaultModelForAgent = vi.hoisted(() => vi.fn()); const resolveModelAsync = vi.hoisted(() => vi.fn()); @@ -16,10 +16,7 @@ vi.mock("@mariozechner/pi-ai", async () => { }; }); -vi.mock("../../agents/model-auth.js", () => ({ - getApiKeyForModel, - requireApiKey, -})); +vi.mock("../../agents/model-auth.js", () => ({ requireApiKey })); vi.mock("../../agents/model-selection.js", () => ({ resolveDefaultModelForAgent, @@ -33,12 +30,16 @@ vi.mock("../../agents/simple-completion-transport.js", () => ({ prepareModelForSimpleCompletion, })); +vi.mock("../../plugins/runtime/runtime-model-auth.runtime.js", () => ({ + getRuntimeAuthForModel, +})); + import { generateConversationLabel } from "./conversation-label-generator.js"; describe("generateConversationLabel", () => { beforeEach(() => { completeSimple.mockReset(); - getApiKeyForModel.mockReset(); + getRuntimeAuthForModel.mockReset(); requireApiKey.mockReset(); resolveDefaultModelForAgent.mockReset(); resolveModelAsync.mockReset(); @@ -51,7 +52,7 @@ describe("generateConversationLabel", () => { modelRegistry: {}, }); prepareModelForSimpleCompletion.mockImplementation(({ model }) => model); - getApiKeyForModel.mockResolvedValue({ apiKey: "resolved-key", mode: "api-key" }); + getRuntimeAuthForModel.mockResolvedValue({ apiKey: "resolved-key", mode: "api-key" }); requireApiKey.mockReturnValue("resolved-key"); completeSimple.mockResolvedValue({ content: [{ type: "text", text: "Topic label" }], @@ -77,10 +78,10 @@ describe("generateConversationLabel", () => { "/tmp/agents/billing/agent", {}, ); - expect(getApiKeyForModel).toHaveBeenCalledWith({ + expect(getRuntimeAuthForModel).toHaveBeenCalledWith({ model: { provider: "openai" }, cfg: {}, - agentDir: "/tmp/agents/billing/agent", + workspaceDir: "/tmp/agents/billing/agent", }); expect(prepareModelForSimpleCompletion).toHaveBeenCalledWith({ model: { provider: "openai" }, diff --git a/src/auto-reply/reply/conversation-label-generator.ts b/src/auto-reply/reply/conversation-label-generator.ts index b127bcd9aef..63057c7b6e0 100644 --- a/src/auto-reply/reply/conversation-label-generator.ts +++ b/src/auto-reply/reply/conversation-label-generator.ts @@ -1,10 +1,11 @@ import { completeSimple, type TextContent } from "@mariozechner/pi-ai"; -import { getApiKeyForModel, requireApiKey } from "../../agents/model-auth.js"; +import { requireApiKey } from "../../agents/model-auth.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; import { resolveModelAsync } from "../../agents/pi-embedded-runner/model.js"; import { prepareModelForSimpleCompletion } from "../../agents/simple-completion-transport.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; +import { getRuntimeAuthForModel } from "../../plugins/runtime/runtime-model-auth.runtime.js"; const DEFAULT_MAX_LABEL_LENGTH = 128; const TIMEOUT_MS = 15_000; @@ -43,7 +44,11 @@ export async function generateConversationLabel( const completionModel = prepareModelForSimpleCompletion({ model: resolved.model, cfg }); const apiKey = requireApiKey( - await getApiKeyForModel({ model: completionModel, cfg, agentDir }), + await getRuntimeAuthForModel({ + model: completionModel, + cfg, + workspaceDir: agentDir, + }), modelRef.provider, );