mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:00:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
106
extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts
Normal file
106
extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user