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 <vincentkoc@ieee.org>
This commit is contained in:
wirjo
2026-04-23 09:09:39 +10:00
committed by GitHub
parent f342da5fcc
commit 18507ed85f
9 changed files with 300 additions and 17 deletions

View File

@@ -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.

View File

@@ -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 () => {

View File

@@ -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,
};
}

View File

@@ -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();
});
});

View File

@@ -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<Api> {
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<Api>;
}
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,
}),
);
});
});

View File

@@ -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<Record<string, string> | undefined>
): Record<string, string> {
const merged: Record<string, string> = {};
for (const headers of headerSources) {
if (headers) {
Object.assign(merged, headers);
}
}
return merged;
}
function buildMantleAnthropicBaseOptions(
model: Model<Api>,
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<SimpleStreamOptions["reasoning"]>,
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,
});
};
}

View File

@@ -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;

View File

@@ -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" },

View File

@@ -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,
);