diff --git a/CHANGELOG.md b/CHANGELOG.md index 460dd2ad7f8..0b257f2fbe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Google/Gemini: normalize retired `google/gemini-3-pro-preview` and `google-gemini-cli/gemini-3-pro-preview` selections to `google/gemini-3.1-pro-preview` before they are written to model config. +- Amazon Bedrock: support `serviceTier` parameter for Bedrock models, configurable via `agents.defaults.params.serviceTier` or per-model in `agents.defaults.models`. Valid values: `default`, `flex`, `priority`, `reserved`. (#64512) Thanks @mobilinkd. - Control UI: read the Quick Settings exec policy badge from `tools.exec.security` instead of the non-schema `agents.defaults.exec.security` path, so configured `full`/`deny` values render accurately. Fixes #78311. Thanks @FriedBack. - Control UI/usage: add transcript-backed historical lineage rollups for rotated logical sessions, with current-instance vs historical-lineage scope controls and long-range presets so usage history stays visible after restarts and updates. Fixes #50701. Thanks @dev-gideon-llc and @BunsDev. - Agents/failover: harden state-aware lane suspension by persisting quota resume transitions, restoring configured lane concurrency, preserving non-quota failure reasons, and exporting model failover events through diagnostics OTLP. Thanks @BunsDev. diff --git a/docs/providers/bedrock.md b/docs/providers/bedrock.md index 7f2f6b8f0cc..6257e0c05fd 100644 --- a/docs/providers/bedrock.md +++ b/docs/providers/bedrock.md @@ -256,6 +256,49 @@ openclaw models list + + Some Bedrock models support a `service_tier` parameter to optimize for cost + or latency. The following tiers are available: + + | Tier | Description | + |------|-------------| + | `default` | Standard Bedrock tier | + | `flex` | Discounted processing for workloads that can tolerate longer latency | + | `priority` | Prioritized processing for latency-sensitive workloads | + | `reserved` | Reserved capacity for steady-state workloads | + + Set `serviceTier` (or `service_tier`) via `agents.defaults.params` for + Bedrock model requests, or per-model in + `agents.defaults.models[""].params`: + + ```json5 + { + agents: { + defaults: { + params: { + serviceTier: "flex", // applies to all models + }, + models: { + "amazon-bedrock/mistral.mistral-large-3-675b-instruct": { + params: { + serviceTier: "priority", // per-model override + }, + }, + }, + }, + }, + } + ``` + + Valid values are `default`, `flex`, `priority`, and `reserved`. Not all + models support all tiers — if an unsupported tier is requested, Bedrock will + return a validation error. Note: the error message is somewhat misleading; + it may say "The provided model identifier is invalid" rather than indicating + an unsupported service tier. If you see this error, check whether the model + supports the requested tier. + + + Bedrock rejects the `temperature` parameter for Claude Opus 4.7. OpenClaw omits `temperature` automatically for any Opus 4.7 Bedrock ref, including diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 4e8666bbb54..240b78a09b9 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -160,34 +160,28 @@ function makeAppInferenceProfileDescriptor(modelId: string): never { } as never; } -/** - * Call wrapStreamFn and then invoke the returned stream function, capturing - * the payload via the onPayload hook that streamWithPayloadPatch installs. - */ async function callWrappedStream( provider: RegisteredProviderPlugin, modelId: string, modelDescriptor: never, config?: OpenClawConfig, + extraParams?: Record, + payload: Record = {}, ): Promise> { const wrapped = provider.wrapStreamFn?.({ provider: "amazon-bedrock", modelId, config, streamFn: spyStreamFn, + ...(extraParams ? { extraParams } : {}), } as never); - // The wrapped stream returns the options object (from spyStreamFn). - // For guardrail-wrapped streams, streamWithPayloadPatch intercepts onPayload, - // so we need to invoke onPayload on the returned options to trigger the patch. const result = wrapped?.(modelDescriptor, { messages: [] } as never, {}) as unknown as Record< string, unknown >; - // If onPayload was installed by streamWithPayloadPatch, call it to apply the patch. if (typeof result?.onPayload === "function") { - const payload: Record = {}; await (result.onPayload as (p: Record, model: unknown) => Promise)( payload, modelDescriptor, @@ -719,6 +713,89 @@ describe("amazon-bedrock provider plugin", () => { }); }); + describe("service tier", () => { + const CONVERSE_MODEL_DESCRIPTOR = { + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + id: NON_ANTHROPIC_MODEL, + } as never; + + it("injects serviceTier for valid camelCase value ('flex')", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: "flex" }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: "flex" } }); + }); + + it("injects serviceTier for valid snake_case value ('priority')", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { service_tier: "priority" }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: "priority" } }); + }); + + it("injects serviceTier for all valid tier names", async () => { + const provider = await registerWithConfig(undefined); + for (const tier of ["flex", "priority", "default", "reserved"] as const) { + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: tier }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: tier } }); + } + }); + + it("does not inject serviceTier when value is invalid", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: "not-a-tier" }, + ); + expect(result).not.toHaveProperty("_capturedPayload"); + }); + + it("does not overwrite caller-provided serviceTier in payload", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + CONVERSE_MODEL_DESCRIPTOR, + runtimePluginConfig(undefined), + { serviceTier: "flex" }, + { serviceTier: { type: "priority" } }, + ); + expect(result._capturedPayload).toMatchObject({ serviceTier: { type: "priority" } }); + }); + + it("skips injection for non-converse API models", async () => { + const provider = await registerWithConfig(undefined); + const result = await callWrappedStream( + provider, + NON_ANTHROPIC_MODEL, + { api: "openai-completions", provider: "amazon-bedrock", id: NON_ANTHROPIC_MODEL } as never, + runtimePluginConfig(undefined), + { serviceTier: "flex" }, + ); + expect(result).not.toHaveProperty("_capturedPayload"); + }); + }); + describe("application inference profile cache point injection", () => { /** * Invoke wrapStreamFn with a payload containing system/messages, then diff --git a/extensions/amazon-bedrock/register.sync.runtime.ts b/extensions/amazon-bedrock/register.sync.runtime.ts index abef50b3578..01740101e44 100644 --- a/extensions/amazon-bedrock/register.sync.runtime.ts +++ b/extensions/amazon-bedrock/register.sync.runtime.ts @@ -34,6 +34,43 @@ type AmazonBedrockPluginConfig = { guardrail?: GuardrailConfig; }; +const BEDROCK_SERVICE_TIER_VALUES = ["flex", "priority", "default", "reserved"] as const; +type BedrockServiceTier = (typeof BEDROCK_SERVICE_TIER_VALUES)[number]; + +function isBedrockServiceTier(value: string): value is BedrockServiceTier { + return BEDROCK_SERVICE_TIER_VALUES.some((tier) => tier === value); +} + +function resolveBedrockServiceTier( + extraParams: Record | undefined, + warn: (message: string) => void, +): BedrockServiceTier | undefined { + const raw = extraParams?.serviceTier ?? extraParams?.service_tier; + if (typeof raw !== "string") { + return undefined; + } + const normalized = raw.trim().toLowerCase(); + if (isBedrockServiceTier(normalized)) { + return normalized; + } + warn(`ignoring invalid Bedrock service_tier param: ${raw}`); + return undefined; +} + +function createBedrockServiceTierWrapper( + underlying: StreamFn, + serviceTier: BedrockServiceTier, +): StreamFn { + return (model, context, options) => { + if (model.api !== "bedrock-converse-stream") { + return underlying(model, context, options); + } + return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => { + payloadObj.serviceTier ??= { type: serviceTier }; + }); + }; +} + function createGuardrailWrapStreamFn( innerWrapStreamFn: (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined, guardrailConfig: GuardrailConfig, @@ -484,13 +521,20 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void { }, resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env), ...anthropicByModelReplayHooks, - wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel }) => { + wrapStreamFn: ({ modelId, config, model, streamFn, thinkingLevel, extraParams }) => { const currentGuardrail = resolveCurrentPluginConfig(config)?.guardrail; - // Apply cache + guardrail wrapping. - const wrapped = - currentGuardrail?.guardrailIdentifier && currentGuardrail?.guardrailVersion + let wrapped = + (currentGuardrail?.guardrailIdentifier && currentGuardrail?.guardrailVersion ? createGuardrailWrapStreamFn(baseWrapStreamFn, currentGuardrail)({ modelId, streamFn }) - : baseWrapStreamFn({ modelId, streamFn }); + : baseWrapStreamFn({ modelId, streamFn })) ?? undefined; + + const serviceTier = resolveBedrockServiceTier(extraParams, (message) => + api.logger.warn(message), + ); + if (serviceTier && wrapped) { + wrapped = createBedrockServiceTierWrapper(wrapped, serviceTier); + } + const region = resolveBedrockRegion(config) ?? extractRegionFromBaseUrl(model?.baseUrl); const mayNeedCacheInjection = isBedrockAppInferenceProfile(modelId) && !piAiWouldInjectCachePoints(modelId);