feat(models): add Claude Sonnet 5 support (#98254)

* feat(models): add Claude Sonnet 5 support

Co-authored-by: Ariel Bravy <ariel@vortexradar.com>

* fix(models): align Sonnet 5 validation baselines

* fix(models): satisfy Sonnet 5 CI contracts

* fix(models): enforce Sonnet 5 provider contracts

* docs(changelog): defer Sonnet 5 release note

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Ariel Bravy <ariel@vortexradar.com>
This commit is contained in:
Vortex Openclaw
2026-07-07 00:05:35 +01:00
committed by GitHub
co-authored by Peter Steinberger Ariel Bravy
parent 93eaf5affb
commit 1f6ddb5df0
62 changed files with 1563 additions and 260 deletions
+2 -1
View File
@@ -5779,6 +5779,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Distribution
- H2: Surface
- H2: Claude Fable 5
- H2: Claude Sonnet 5
## plugins/reference/anthropic.md
@@ -7239,7 +7240,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H2: Usage and cost tracking
- H2: Getting started
- H2: Thinking defaults (Claude Fable 5, 4.8, and 4.6)
- H2: Thinking defaults (Claude Sonnet 5, Fable 5, 4.8, and 4.6)
- H2: Safety refusal fallback (Claude Fable 5)
- H3: Why this exists
- H3: How it works
@@ -26,4 +26,16 @@ Use `anthropic-vertex/claude-fable-5` where the model is available in your Googl
Fable 5 always uses adaptive thinking and defaults to `high` effort. `/think off` and
`/think minimal` use `low` effort because the model does not support disabling thinking.
## Claude Sonnet 5
Use `anthropic-vertex/claude-sonnet-5` with Vertex's `global`, `us`, or `eu`
endpoint. Sonnet 5 defaults to adaptive thinking at `high` effort and supports
`/think off` or the native `/think xhigh|max` levels. OpenClaw publishes its
1,000,000-token context window and 128,000-token output limit automatically.
Catalog pricing follows Vertex's introductory global rate of `$2/$10` per
million input/output tokens through August 31, 2026, then `$3/$15` from
September 1. The `us` and `eu` multi-region endpoints use Vertex's documented
10% premium.
<!-- openclaw-plugin-reference:manual-end -->
+14 -4
View File
@@ -185,7 +185,15 @@ OpenClaw release:
</Tab>
</Tabs>
## Thinking defaults (Claude Fable 5, 4.8, and 4.6)
## Thinking defaults (Claude Sonnet 5, Fable 5, 4.8, and 4.6)
`anthropic/claude-sonnet-5` uses adaptive thinking at `high` effort by default.
Use `/think off` to disable thinking, or `/think xhigh|max` for the model's
higher native effort levels. OpenClaw omits manual thinking budgets, custom
sampling parameters, assistant prefills, and Priority Tier for Sonnet 5 because
Anthropic does not support those request features on this model.
The catalog uses Anthropic's introductory `$2/$10` input/output pricing through
August 31, 2026; standard `$3/$15` pricing begins September 1, 2026.
`anthropic/claude-fable-5` always uses adaptive thinking and defaults to `high`
effort. Anthropic does not allow thinking to be disabled for this model, so
@@ -406,15 +414,17 @@ OpenClaw supports Anthropic's prompt caching feature for API-key auth.
</Accordion>
<Accordion title="1M context window">
Anthropic's 1M context window is GA on Claude 4.x models with adaptive
thinking: Opus 4.8, Opus 4.7, Opus 4.6, and Sonnet 4.6. OpenClaw sizes those
models at 1,048,576 tokens automatically, no `params.context1m` needed:
Claude Sonnet 5 has an exact 1,000,000-token input window and supports up to
128,000 output tokens. Anthropic's 1M context window is also GA on Claude 4.x
models with adaptive thinking: Opus 4.8, Opus 4.7, Opus 4.6, and Sonnet 4.6.
OpenClaw sizes these models automatically, no `params.context1m` needed:
```json5
{
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-5": {},
"anthropic/claude-opus-4-6": {},
},
},
+28 -16
View File
@@ -1,8 +1,9 @@
---
summary: "Use Amazon Bedrock Mantle (OpenAI-compatible) models with OpenClaw"
summary: "Use Amazon Bedrock Mantle OpenAI-compatible and Claude Messages models with OpenClaw"
read_when:
- You want to use Bedrock Mantle hosted OSS models with OpenClaw
- You need the Mantle OpenAI-compatible endpoint for GPT-OSS, Qwen, Kimi, or GLM
- You want to use Claude Sonnet 5 through Amazon Bedrock Mantle
title: "Amazon Bedrock Mantle"
---
@@ -10,14 +11,14 @@ OpenClaw includes a bundled **Amazon Bedrock Mantle** provider that connects to
the Mantle OpenAI-compatible endpoint. Mantle hosts open-source and
third-party models (GPT-OSS, Qwen, Kimi, GLM, and similar) through a standard
`/v1/chat/completions` surface backed by Bedrock infrastructure. Mantle also
exposes two Anthropic Claude models through an Anthropic Messages route.
exposes Anthropic Claude models through an Anthropic Messages route.
| Property | Value |
| -------------- | ---------------------------------------------------------------------------------------------- |
| Provider ID | `amazon-bedrock-mantle` |
| API | `openai-completions` for discovered OSS models, `anthropic-messages` for the two Claude models |
| Auth | Explicit `AWS_BEARER_TOKEN_BEDROCK` or IAM credential-chain bearer-token generation |
| Default region | `us-east-1` (override with `AWS_REGION` or `AWS_DEFAULT_REGION`) |
| Property | Value |
| -------------- | -------------------------------------------------------------------------------------- |
| Provider ID | `amazon-bedrock-mantle` |
| API | `openai-completions` for discovered OSS models, `anthropic-messages` for Claude models |
| Auth | Explicit `AWS_BEARER_TOKEN_BEDROCK` or IAM credential-chain bearer-token generation |
| Default region | `us-east-1` (override with `AWS_REGION` or `AWS_DEFAULT_REGION`) |
## Getting started
@@ -138,6 +139,11 @@ If you prefer explicit config instead of auto-discovery:
}
```
An explicit non-empty `models` list is authoritative and replaces every
discovered row, including the Claude rows below. Omit `models` to retain the
automatic Mantle catalog, or include the complete Claude model entries you
want to use.
## Advanced configuration
<AccordionGroup>
@@ -155,16 +161,22 @@ If you prefer explicit config instead of auto-discovery:
continue to work normally.
</Accordion>
<Accordion title="Claude Opus 4.7 and Claude Mythos Preview via the Anthropic Messages route">
OpenClaw always appends two Claude models to the Mantle catalog after
successful discovery, regardless of what `/v1/models` returns:
`amazon-bedrock-mantle/anthropic.claude-opus-4-7` (Claude Opus 4.7) and
<Accordion title="Claude via the Anthropic Messages route">
When automatic discovery owns the model list, OpenClaw appends three Claude
models after a successful lookup, regardless of what `/v1/models` returns:
`amazon-bedrock-mantle/anthropic.claude-sonnet-5` (Claude Sonnet 5),
`amazon-bedrock-mantle/anthropic.claude-opus-4-7` (Claude Opus 4.7), and
`amazon-bedrock-mantle/anthropic.claude-mythos-preview` (Claude Mythos
Preview). Both use the `anthropic-messages` API surface and stream through
Preview). They use the `anthropic-messages` API surface and stream through
the same bearer-authenticated Anthropic-compatible endpoint
(`<mantle-base>/anthropic`), so the AWS bearer token is not treated like an
Anthropic API key.
Claude Sonnet 5 always uses adaptive thinking and defaults to `high`
effort. `/think off` and `/think minimal` map to `low` because the Mantle
route cannot disable thinking. OpenClaw also omits custom temperature for
Sonnet 5 requests.
Claude Mythos Preview always requests reasoning, defaulting to `high`
effort when no `/think` level is set (mapped from `xhigh`/`max` down to
`high`, and `minimal` up to `low`). Opus 4.7 on Mantle streams without
@@ -172,9 +184,9 @@ If you prefer explicit config instead of auto-discovery:
since Opus 4.7 does not accept sampling overrides on this route; Mythos
Preview accepts a `temperature` override normally.
These two models are not configurable through `models.providers["amazon-bedrock-mantle"].models`
entries — they are always added by discovery when it succeeds, and are
only removed by disabling discovery entirely.
A non-empty explicit `models.providers["amazon-bedrock-mantle"].models`
list replaces the complete discovered catalog. Omit that list when you
want these built-in Claude rows.
</Accordion>
+19 -3
View File
@@ -308,9 +308,9 @@ openclaw models list
```
Valid values are `default`, `flex`, `priority`, and `reserved`. Claude
Fable 5 only supports the `default` tier; OpenClaw warns and ignores
`flex`, `priority`, or `reserved` requested for that model. For other
models, not every model supports every tier -- an unsupported tier
Fable 5 and Sonnet 5 only support the `default` tier; OpenClaw warns and
ignores `flex`, `priority`, or `reserved` requested for those models. For
other models, not every model supports every tier -- an unsupported tier
returns a Bedrock validation error, and the error message can be
misleading (for example "The provided model identifier is invalid"
rather than naming the tier as the problem). If you see this error, check
@@ -347,6 +347,22 @@ openclaw models list
</Accordion>
<Accordion title="Claude Sonnet 5">
AWS documents Sonnet 5 for both the
[`bedrock-runtime` and `bedrock-mantle` endpoints](https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-5.html).
OpenClaw recognizes the Bedrock foundation model
`anthropic.claude-sonnet-5` and regional or global inference profiles such
as `us.anthropic.claude-sonnet-5`. It applies the 1,000,000-token context
window, 128,000-token output limit, image input, native effort levels,
prompt caching, and refusal-safe streaming.
Bedrock keeps adaptive thinking enabled for Sonnet 5. OpenClaw defaults to
`high`; `/think off` and `/think minimal` map to `low` because this route
cannot disable thinking. Custom temperature and forced tool choice values
are omitted while adaptive thinking is active.
</Accordion>
<Accordion title="Guardrails">
You can apply [Amazon Bedrock Guardrails](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html)
to all Bedrock model invocations by adding a `guardrail` object to the
@@ -520,7 +520,18 @@ 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(3);
expect(provider?.models).toHaveLength(4);
const sonnet = provider?.models?.find((model) => model.id === "anthropic.claude-sonnet-5");
expect(sonnet).toMatchObject({
api: "anthropic-messages",
reasoning: true,
params: { canonicalModelId: "claude-sonnet-5" },
input: ["text", "image"],
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
});
const opus = provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7");
expect(opus?.api).toBe("anthropic-messages");
expect(opus?.reasoning).toBe(false);
@@ -537,6 +548,31 @@ describe("bedrock mantle discovery", () => {
});
});
it("rolls Claude Sonnet 5 to standard pricing on September 1, 2026", async () => {
vi.useFakeTimers();
vi.setSystemTime(Date.UTC(2026, 8, 1));
try {
const mockFetch = vi.fn().mockResolvedValue(
modelDiscoveryResponse({
data: [{ id: "anthropic.claude-sonnet-5", object: "model" }],
}),
);
const provider = await resolveImplicitMantleProvider({
env: {
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
AWS_REGION: "us-east-1",
} as NodeJS.ProcessEnv,
fetchFn: mockFetch as unknown as typeof fetch,
});
expect(
provider?.models?.find((model) => model.id === "anthropic.claude-sonnet-5")?.cost,
).toEqual({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 });
} finally {
vi.useRealTimers();
}
});
it("returns null when no auth is available", async () => {
const tokenProviderFactory = vi.fn(() => {
throw new Error("no credentials");
+41 -1
View File
@@ -29,9 +29,29 @@ const DEFAULT_MAX_TOKENS = 4096;
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
const MANTLE_DISCOVERY_TIMEOUT_MS = 30_000;
const MANTLE_DISCOVERY_RESPONSE_MAX_BYTES = 4 * 1024 * 1024;
// Bedrock's introductory Sonnet 5 rate expires at the documented UTC month boundary.
const SONNET_5_STANDARD_PRICING_START_MS = Date.UTC(2026, 8, 1);
const SONNET_5_PROMOTIONAL_COST = {
input: 2,
output: 10,
cacheRead: 0.2,
cacheWrite: 2.5,
};
const SONNET_5_STANDARD_COST = {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
};
/** Config auth marker meaning Mantle should mint runtime bearer tokens from IAM. */
export const MANTLE_IAM_TOKEN_MARKER = "__amazon_bedrock_mantle_iam__";
export function resolveMantleSonnet5Cost(nowMs: number = Date.now()) {
return nowMs >= SONNET_5_STANDARD_PRICING_START_MS
? SONNET_5_STANDARD_COST
: SONNET_5_PROMOTIONAL_COST;
}
// ---------------------------------------------------------------------------
// Mantle region & endpoint helpers
// ---------------------------------------------------------------------------
@@ -413,6 +433,21 @@ export async function resolveImplicitMantleProvider(params: {
// keep reasoning off until the underlying Anthropic transport learns Opus 4.7
// adaptive thinking semantics.
const claudeModels: ModelDefinitionConfig[] = [
{
id: "anthropic.claude-sonnet-5",
name: "Claude Sonnet 5",
api: "anthropic-messages" as const,
reasoning: true,
params: { canonicalModelId: "claude-sonnet-5" },
input: ["text", "image"],
mediaInput: {
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
},
cost: resolveMantleSonnet5Cost(),
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
},
{
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
@@ -440,7 +475,12 @@ export async function resolveImplicitMantleProvider(params: {
maxTokens: 128_000,
},
];
const allModels = [...models, ...claudeModels];
// Replace generic discovery rows so first-match lookup sees exact Claude metadata.
const exactClaudeModelIds = new Set(claudeModels.map((model) => model.id));
const allModels = [
...models.filter((model) => !exactClaudeModelIds.has(model.id)),
...claudeModels,
];
return {
baseUrl: `${mantleEndpoint(region)}/v1`,
@@ -85,4 +85,38 @@ describe("amazon-bedrock-mantle provider plugin", () => {
} as never),
).toBeUndefined();
});
it("refreshes Sonnet 5 pricing during runtime normalization", async () => {
vi.useFakeTimers();
vi.setSystemTime(Date.UTC(2026, 8, 1));
try {
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
const normalized = provider.normalizeResolvedModel?.({
provider: "amazon-bedrock-mantle",
modelId: "anthropic.claude-sonnet-5",
model: {
id: "anthropic.claude-sonnet-5",
name: "Claude Sonnet 5",
api: "anthropic-messages",
provider: "amazon-bedrock-mantle",
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/anthropic",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
contextWindow: 1_000_000,
maxTokens: 128_000,
params: { canonicalModelId: "claude-sonnet-5" },
},
} as never);
expect(normalized?.cost).toEqual({
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
});
} finally {
vi.useRealTimers();
}
});
});
@@ -134,6 +134,39 @@ describe("createMantleAnthropicStreamFn", () => {
expect(streamOptions.effort).toBe("high");
});
it.each([
{ reasoning: undefined, effort: "high" },
{ reasoning: "off" as const, effort: "low" },
])("keeps Sonnet 5 adaptive for reasoning=$reasoning", ({ reasoning, effort }) => {
const model = createTestModel({
id: "anthropic.claude-sonnet-5",
name: "Claude Sonnet 5",
reasoning: true,
params: { canonicalModelId: "claude-sonnet-5" },
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
maxTokens: 128_000,
});
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(
model,
{ messages: [] },
{
apiKey: "bedrock-bearer-token",
reasoning,
temperature: 0.2,
},
);
expect(firstStreamOptions(deps)).toMatchObject({
thinkingEnabled: true,
effort,
maxTokens: 128_000,
});
expect(firstStreamOptions(deps)).not.toHaveProperty("temperature");
});
it("clamps unsupported Mythos Preview max effort to high", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
@@ -166,10 +199,14 @@ describe("createMantleAnthropicStreamFn", () => {
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, { messages: [] }, {
apiKey: "bedrock-bearer-token",
reasoning: "minimal",
});
void createMantleAnthropicStreamFn(deps)(
model,
{ messages: [] },
{
apiKey: "bedrock-bearer-token",
reasoning: "minimal",
},
);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
@@ -4,7 +4,13 @@
*/
import Anthropic from "@anthropic-ai/sdk";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { stream, type Model, type SimpleStreamOptions } from "openclaw/plugin-sdk/llm";
import {
stream,
type Model,
type SimpleStreamOptions,
type ThinkingLevel,
} from "openclaw/plugin-sdk/llm";
import { resolveClaudeSonnet5ModelIdentity } from "openclaw/plugin-sdk/provider-model-shared";
const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14";
type AnthropicOptions = ConstructorParameters<typeof Anthropic>[0];
@@ -22,8 +28,12 @@ export function resolveMantleAnthropicBaseUrl(baseUrl: string): string {
return `${trimmed}/anthropic`;
}
function requiresDefaultSampling(modelId: string): boolean {
return modelId.includes("claude-opus-4-7");
function isClaudeSonnet5Model(model: Model): boolean {
return resolveClaudeSonnet5ModelIdentity(model) !== undefined;
}
function requiresDefaultSampling(model: Model): boolean {
return model.id.includes("claude-opus-4-7") || isClaudeSonnet5Model(model);
}
function isClaudeMythosPreviewModel(model: Model): boolean {
@@ -43,19 +53,39 @@ function resolveMantleReasoning(
model: Model,
options: SimpleStreamOptions | undefined,
): NonNullable<SimpleStreamOptions["reasoning"]> | undefined {
if (requiresDefaultSampling(model.id)) {
if (model.id.includes("claude-opus-4-7")) {
return undefined;
}
const reasoning = options?.reasoning ?? (isClaudeMythosPreviewModel(model) ? "high" : undefined);
const sonnet5 = isClaudeSonnet5Model(model);
const reasoning =
options?.reasoning ?? (isClaudeMythosPreviewModel(model) || sonnet5 ? "high" : undefined);
if (sonnet5) {
return reasoning === "off" || reasoning === "minimal" ? "low" : reasoning;
}
if (!isClaudeMythosPreviewModel(model)) {
return reasoning;
}
if (reasoning === "off") {
return "high";
}
if (reasoning === "minimal") {
return "low";
}
return reasoning === "xhigh" || reasoning === "max" ? "high" : reasoning;
}
function mapSonnet5Effort(
reasoning: NonNullable<SimpleStreamOptions["reasoning"]>,
): "low" | "medium" | "high" | "xhigh" | "max" {
if (reasoning === "minimal" || reasoning === "low") {
return "low";
}
if (reasoning === "medium" || reasoning === "xhigh" || reasoning === "max") {
return reasoning;
}
return "high";
}
function mergeHeaders(
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> {
@@ -74,8 +104,10 @@ function buildMantleAnthropicBaseOptions(
apiKey: string,
) {
return {
temperature: requiresDefaultSampling(model.id) ? undefined : options?.temperature,
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32_000),
...(requiresDefaultSampling(model) ? {} : { temperature: options?.temperature }),
maxTokens:
options?.maxTokens ||
(isClaudeSonnet5Model(model) ? model.maxTokens : Math.min(model.maxTokens, 32_000)),
signal: options?.signal,
apiKey,
cacheRetention: options?.cacheRetention,
@@ -89,7 +121,7 @@ function buildMantleAnthropicBaseOptions(
function adjustMaxTokensForThinking(
baseMaxTokens: number,
modelMaxTokens: number,
reasoningLevel: NonNullable<SimpleStreamOptions["reasoning"]>,
reasoningLevel: ThinkingLevel,
customBudgets?: SimpleStreamOptions["thinkingBudgets"],
): { maxTokens: number; thinkingBudget: number } {
const defaultBudgets = {
@@ -139,7 +171,8 @@ export function createMantleAnthropicStreamFn(deps?: {
// The client API is the same, but the SDK class private field makes types nominal.
const streamClient = client as unknown as Anthropic;
const reasoning = resolveMantleReasoning(model, options);
if (!reasoning) {
const sonnet5 = isClaudeSonnet5Model(model);
if (!reasoning || reasoning === "off") {
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
@@ -147,6 +180,15 @@ export function createMantleAnthropicStreamFn(deps?: {
});
}
if (sonnet5) {
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
thinkingEnabled: true,
effort: mapSonnet5Effort(reasoning),
});
}
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
model.maxTokens,
@@ -4,12 +4,14 @@
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginApi, ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import { resolveClaudeSonnet5ModelIdentity } from "openclaw/plugin-sdk/provider-model-shared";
import {
mergeImplicitMantleProvider,
resolveImplicitMantleProvider,
resolveMantleBearerToken,
resolveMantleRuntimeBearerToken,
resolveMantleSonnet5Cost,
} from "./discovery.js";
import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js";
@@ -19,6 +21,28 @@ type BedrockMantlePluginConfig = {
};
};
function normalizeMantleResolvedModel(params: {
modelId: string;
model: ProviderRuntimeModel;
}): ProviderRuntimeModel | undefined {
if (
resolveClaudeSonnet5ModelIdentity({ id: params.modelId, params: params.model.params }) ===
undefined
) {
return undefined;
}
const cost = resolveMantleSonnet5Cost();
if (
params.model.cost.input === cost.input &&
params.model.cost.output === cost.output &&
params.model.cost.cacheRead === cost.cacheRead &&
params.model.cost.cacheWrite === cost.cacheWrite
) {
return undefined;
}
return { ...params.model, cost };
}
/** Register the Amazon Bedrock Mantle provider with OpenClaw. */
export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
const providerId = "amazon-bedrock-mantle";
@@ -65,6 +89,8 @@ export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
apiKey,
env,
}),
normalizeResolvedModel: ({ modelId, model }) =>
normalizeMantleResolvedModel({ modelId, model }),
createStreamFn: ({ model }) =>
model.api === "anthropic-messages" ? createMantleAnthropicStreamFn() : undefined,
matchesContextOverflowError: ({ errorMessage }) =>
+3 -3
View File
@@ -2,7 +2,7 @@
* Stream option extensions and prompt-cache policy for Amazon Bedrock models.
* Provider registration and runtime streaming share these contracts.
*/
import type { StreamOptions, ThinkingBudgets, ThinkingLevel } from "openclaw/plugin-sdk/llm";
import type { ModelThinkingLevel, StreamOptions, ThinkingBudgets } from "openclaw/plugin-sdk/llm";
/** How Bedrock thinking output should be displayed to users. */
export type BedrockThinkingDisplay = "summarized" | "omitted";
@@ -12,7 +12,7 @@ export interface BedrockOptions extends StreamOptions {
region?: string;
profile?: string;
toolChoice?: "auto" | "any" | "none" | { type: "tool"; name: string };
reasoning?: ThinkingLevel;
reasoning?: ModelThinkingLevel;
thinkingBudgets?: ThinkingBudgets;
interleavedThinking?: boolean;
thinkingDisplay?: BedrockThinkingDisplay;
@@ -41,7 +41,7 @@ export function supportsBedrockPromptCaching(modelId: string, modelName?: string
if (candidates.some((s) => s.includes("-4-"))) {
return true;
}
if (candidates.some((s) => s.includes("claude-fable-5"))) {
if (candidates.some((s) => s.includes("claude-fable-5") || s.includes("claude-sonnet-5"))) {
return true;
}
if (candidates.some((s) => s.includes("claude-3-7-sonnet"))) {
@@ -198,6 +198,36 @@ describe("bedrock discovery", () => {
});
});
it("applies the Sonnet 5 contract to inference-profile-only discovery", async () => {
sendMock.mockResolvedValueOnce({ modelSummaries: [] }).mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "global.anthropic.claude-sonnet-5",
inferenceProfileName: "Global Claude Sonnet 5",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-5",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expectModelFields(models[0], {
id: "global.anthropic.claude-sonnet-5",
reasoning: true,
input: ["text", "image"],
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
params: { canonicalModelId: "claude-sonnet-5" },
});
});
it("skips Mythos Preview inference profiles because Mantle owns that route", async () => {
sendMock
.mockResolvedValueOnce({
+20 -3
View File
@@ -21,6 +21,7 @@ import type {
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
supportsClaudeAdaptiveThinking,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
@@ -60,6 +61,8 @@ const DEFAULT_MAX_TOKENS = 4096;
const KNOWN_CONTEXT_WINDOWS: Record<string, number> = {
// Anthropic Claude
"anthropic.claude-fable-5": 1_000_000,
// AWS publishes Sonnet 5 on both bedrock-runtime (Invoke/Converse) and Mantle.
"anthropic.claude-sonnet-5": 1_000_000,
"anthropic.claude-3-7-sonnet-20250219-v1:0": 200_000,
"anthropic.claude-opus-4-8": 1_000_000,
"anthropic.claude-opus-4-7": 1_000_000,
@@ -137,7 +140,10 @@ function resolveKnownContextWindow(modelId: string): number | undefined {
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
const candidates = [modelId, stripped];
for (const candidate of candidates) {
if (resolveClaudeFable5ModelIdentity({ id: candidate })) {
if (
resolveClaudeFable5ModelIdentity({ id: candidate }) ||
resolveClaudeSonnet5ModelIdentity({ id: candidate })
) {
return 1_000_000;
}
if (/(?:^|[/.:])anthropic\.claude-opus-4[.-]8(?:$|[-.:/])/i.test(candidate)) {
@@ -171,7 +177,17 @@ function resolveKnownThinkingLevelMap(
}
function resolveKnownMaxTokens(modelId: string): number | undefined {
return resolveClaudeFable5ModelIdentity({ id: modelId }) ? 128_000 : undefined;
return resolveClaudeFable5ModelIdentity({ id: modelId }) ||
resolveClaudeSonnet5ModelIdentity({ id: modelId })
? 128_000
: undefined;
}
function resolveKnownInput(modelId: string): ModelDefinitionConfig["input"] | undefined {
return resolveClaudeFable5ModelIdentity({ id: modelId }) ||
resolveClaudeSonnet5ModelIdentity({ id: modelId })
? ["text", "image"]
: undefined;
}
const DEFAULT_COST = {
@@ -473,6 +489,7 @@ function resolveInferenceProfiles(
const knownThinkingLevelMap = resolveKnownThinkingLevelMap(
baseModelId ?? profile.inferenceProfileId,
);
const contractModelId = baseModelId ?? profile.inferenceProfileId;
const canonicalClaudeId = resolveClaudeModelIdentity({ id: baseModelId });
discovered.push({
@@ -481,7 +498,7 @@ function resolveInferenceProfiles(
reasoning:
baseModel?.reasoning ??
supportsClaudeAdaptiveThinking({ id: baseModelId ?? profile.inferenceProfileId }),
input: baseModel?.input ?? ["text"],
input: baseModel?.input ?? resolveKnownInput(contractModelId) ?? ["text"],
cost: baseModel?.cost ?? DEFAULT_COST,
contextWindow:
baseModel?.contextWindow ??
+39 -30
View File
@@ -416,6 +416,7 @@ describe("amazon-bedrock provider plugin", () => {
it("recognizes direct Fable model refs as prompt-cache eligible", () => {
expect(supportsBedrockPromptCaching("us.anthropic.claude-fable-5")).toBe(true);
expect(supportsBedrockPromptCaching("global.anthropic.claude-sonnet-5")).toBe(true);
});
it("owns Anthropic-style replay policy for Claude Bedrock models", async () => {
@@ -1118,37 +1119,45 @@ describe("amazon-bedrock provider plugin", () => {
expect(result).not.toHaveProperty("capturedPayload");
});
it("omits unsupported service tiers for Fable", async () => {
const provider = await registerWithConfig(undefined);
const result = await callWrappedStream(
provider,
"us.anthropic.claude-fable-5",
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "us.anthropic.claude-fable-5",
} as never,
runtimePluginConfig(undefined),
{ serviceTier: "flex" },
);
expect(result).not.toHaveProperty("capturedPayload");
});
it.each(["fable", "sonnet"])(
"omits unsupported service tiers for Claude %s 5",
async (family) => {
const provider = await registerWithConfig(undefined);
const modelId = `us.anthropic.claude-${family}-5`;
const result = await callWrappedStream(
provider,
modelId,
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: modelId,
} as never,
runtimePluginConfig(undefined),
{ serviceTier: "flex" },
);
expect(result).not.toHaveProperty("capturedPayload");
},
);
it("keeps the standard service tier for Fable", async () => {
const provider = await registerWithConfig(undefined);
const result = await callWrappedStream(
provider,
"us.anthropic.claude-fable-5",
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "us.anthropic.claude-fable-5",
} as never,
runtimePluginConfig(undefined),
{ serviceTier: "default" },
);
expectPayloadServiceTier(result, "default");
});
it.each(["fable", "sonnet"])(
"keeps the standard service tier for Claude %s 5",
async (family) => {
const provider = await registerWithConfig(undefined);
const modelId = `us.anthropic.claude-${family}-5`;
const result = await callWrappedStream(
provider,
modelId,
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: modelId,
} as never,
runtimePluginConfig(undefined),
{ serviceTier: "default" },
);
expectPayloadServiceTier(result, "default");
},
);
it("does not overwrite caller-provided serviceTier in payload", async () => {
const provider = await registerWithConfig(undefined);
@@ -15,6 +15,7 @@ import {
normalizeProviderId,
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
} from "openclaw/plugin-sdk/provider-model-shared";
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream-shared";
import { refreshAwsSharedConfigCacheForBedrock } from "./aws-credential-refresh.js";
@@ -544,6 +545,7 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
const currentGuardrail = currentPluginConfig?.guardrail;
const modelRef = { id: modelId, params: model?.params };
const fable5 = resolveClaudeFable5ModelIdentity(modelRef) !== undefined;
const sonnet5 = resolveClaudeSonnet5ModelIdentity(modelRef) !== undefined;
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
const opus47OrNewer =
isOpus47OrNewerBedrockModelRef(modelId) || isOpus47OrNewerBedrockModelRef(canonicalModelId);
@@ -564,8 +566,11 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
api.logger.warn(message),
);
if (serviceTier && wrapped) {
if (fable5 && serviceTier !== "default") {
api.logger.warn(`ignoring unsupported Fable 5 Bedrock service tier: ${serviceTier}`);
if ((fable5 || sonnet5) && serviceTier !== "default") {
const modelLabel = fable5 ? "Fable 5" : "Sonnet 5";
api.logger.warn(
`ignoring unsupported ${modelLabel} Bedrock service tier: ${serviceTier}`,
);
} else {
wrapped = createBedrockServiceTierWrapper(wrapped, serviceTier);
}
@@ -167,6 +167,31 @@ describe("Bedrock profile endpoint resolution", () => {
});
describe("Bedrock thinking effort mapping", () => {
it.each([
{ reasoning: undefined, expected: "high" },
{ reasoning: "off" as const, expected: "low" },
])("keeps Sonnet 5 adaptive for reasoning=$reasoning", ({ reasoning, expected }) => {
const model = bedrockModel({
id: "us.anthropic.claude-sonnet-5",
name: "Claude Sonnet 5",
reasoning: true,
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
});
const options = testing.resolveSimpleBedrockOptions(model, { reasoning });
expect(options).toMatchObject({ maxTokens: 128_000, reasoning: expected });
expect(testing.buildAdditionalModelRequestFields(model, options)).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: expected },
});
expect(testing.buildAdditionalModelRequestFields(model, { reasoning })).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: expected },
});
});
it("does not force adaptive thinking for optional Claude models when callers omit reasoning", () => {
const model = bedrockModel({
id: "anthropic.claude-sonnet-4-6-v1:0",
+52 -14
View File
@@ -55,6 +55,7 @@ import {
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeXhighEffort,
} from "openclaw/plugin-sdk/provider-model-shared";
@@ -73,6 +74,16 @@ function usesClaudeFable5BedrockContract(model: Model<"bedrock-converse-stream">
return resolveClaudeFable5ModelIdentity(model) !== undefined;
}
function usesClaudeSonnet5BedrockContract(model: Model<"bedrock-converse-stream">): boolean {
return resolveClaudeSonnet5ModelIdentity(model) !== undefined;
}
function usesClaudeStreamingRefusalBedrockContract(
model: Model<"bedrock-converse-stream">,
): boolean {
return usesClaudeFable5BedrockContract(model) || usesClaudeSonnet5BedrockContract(model);
}
function readBedrockStopDetails(fields: DocumentType | undefined): unknown {
if (!fields || typeof fields !== "object" || Array.isArray(fields)) {
return undefined;
@@ -81,7 +92,7 @@ function readBedrockStopDetails(fields: DocumentType | undefined): unknown {
return record.stop_details ?? record.stopDetails;
}
function normalizeFableToolChoice(
function normalizeAdaptiveClaudeToolChoice(
toolChoice: BedrockOptions["toolChoice"],
): BedrockOptions["toolChoice"] {
if (toolChoice === "any" || (typeof toolChoice === "object" && toolChoice?.type === "tool")) {
@@ -133,9 +144,9 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
const blocks = output.content as Block[];
const fable5 = usesClaudeFable5BedrockContract(model);
// Fable classifiers may refuse after partial output. Hold every event until
// Claude classifiers may refuse after partial output. Hold every event until
// messageStop proves the response is safe to expose.
const refusalBuffer = fable5
const refusalBuffer = usesClaudeStreamingRefusalBedrockContract(model)
? createDeferredEventBuffer<AssistantMessageEvent>(stream, () =>
notifyLlmRequestActivity(options.signal),
)
@@ -231,10 +242,14 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
},
toolConfig: convertToolConfig(
context.tools,
fable5 ? normalizeFableToolChoice(options.toolChoice) : options.toolChoice,
fable5 || sendsAdaptiveThinking
? normalizeAdaptiveClaudeToolChoice(options.toolChoice)
: options.toolChoice,
),
additionalModelRequestFields,
...(fable5 ? { additionalModelResponseFieldPaths: ["/stop_details"] } : {}),
...(usesClaudeStreamingRefusalBedrockContract(model)
? { additionalModelResponseFieldPaths: ["/stop_details"] }
: {}),
...(options.requestMetadata !== undefined && { requestMetadata: options.requestMetadata }),
};
const nextCommandInput = await options?.onPayload?.(commandInput, model);
@@ -372,11 +387,11 @@ function resolveSimpleBedrockOptions(
options?: SimpleStreamOptions,
): BedrockOptions {
const base = buildBaseOptions(model, options, undefined);
if (usesClaudeFable5BedrockContract(model)) {
if (usesClaudeFable5BedrockContract(model) || usesClaudeSonnet5BedrockContract(model)) {
return {
...base,
maxTokens: resolveAdaptiveBedrockMaxTokens(model, base.maxTokens),
reasoning: options?.reasoning ?? "high",
reasoning: options?.reasoning === "off" ? "low" : (options?.reasoning ?? "high"),
thinkingBudgets: options?.thinkingBudgets,
} satisfies BedrockOptions;
}
@@ -394,6 +409,10 @@ function resolveSimpleBedrockOptions(
} satisfies BedrockOptions;
}
if (options.reasoning === "off") {
return { ...base, reasoning: "off" } satisfies BedrockOptions;
}
if (isAnthropicClaudeModel(model)) {
if (supportsAdaptiveThinking(model)) {
return {
@@ -581,7 +600,7 @@ function resolveClaudeProfileNameModelId(modelName?: string): string | undefined
if (!normalized.includes("claude")) {
return undefined;
}
const family = /(?:fable-5|mythos-preview|opus-4-(?:6|7|8)|sonnet-4-6)(?:$|-)/.exec(
const family = /(?:fable-5|mythos-preview|opus-4-(?:6|7|8)|sonnet-(?:5|4-6))(?:$|-)/.exec(
normalized,
)?.[0];
return family ? `claude-${family.replace(/-$/, "")}` : undefined;
@@ -603,7 +622,9 @@ function supportsAdaptiveThinking(model: Model<"bedrock-converse-stream">): bool
supportsClaudeAdaptiveThinking(model) ||
supportsClaudeAdaptiveThinking({ id: profileModelId }) ||
isClaudeMythosPreviewModelId(resolveClaudeModelIdentity(model)) ||
isClaudeMythosPreviewModelId(profileModelId)
isClaudeMythosPreviewModelId(profileModelId) ||
usesClaudeSonnet5BedrockContract(model) ||
resolveClaudeSonnet5ModelIdentity({ id: profileModelId }) !== undefined
);
}
@@ -611,7 +632,9 @@ function requiresMandatoryAdaptiveThinking(model: Model<"bedrock-converse-stream
const profileModelId = resolveClaudeProfileNameModelId(model.name);
return (
isClaudeMythosPreviewModelId(resolveClaudeModelIdentity(model)) ||
isClaudeMythosPreviewModelId(profileModelId)
isClaudeMythosPreviewModelId(profileModelId) ||
usesClaudeSonnet5BedrockContract(model) ||
resolveClaudeSonnet5ModelIdentity({ id: profileModelId }) !== undefined
);
}
@@ -1037,8 +1060,23 @@ function buildAdditionalModelRequestFields(
model: Model<"bedrock-converse-stream">,
options: BedrockOptions,
): DocumentType | undefined {
// Bedrock keeps Fable/Sonnet 5 adaptive. Preserve the public `off` control by
// lowering effort instead of silently falling back to the route's high default.
const mandatoryAdaptiveThinking =
usesClaudeFable5BedrockContract(model) || requiresMandatoryAdaptiveThinking(model);
const reasoning =
options.reasoning === "off"
? usesClaudeFable5BedrockContract(model) || usesClaudeSonnet5BedrockContract(model)
? "low"
: mandatoryAdaptiveThinking
? "high"
: "off"
: (options.reasoning ?? (mandatoryAdaptiveThinking ? "high" : undefined));
if (reasoning === "off") {
return undefined;
}
if (
!options.reasoning ||
!reasoning ||
(!model.reasoning &&
!usesClaudeFable5BedrockContract(model) &&
!supportsAdaptiveThinking(model))
@@ -1055,7 +1093,7 @@ function buildAdditionalModelRequestFields(
const result: Record<string, unknown> = supportsAdaptiveThinking(model)
? {
thinking: { type: "adaptive", ...(display !== undefined ? { display } : {}) },
output_config: { effort: mapThinkingLevelToEffort(model, options.reasoning) },
output_config: { effort: mapThinkingLevelToEffort(model, reasoning) },
}
: (() => {
const defaultBudgets: Record<ThinkingLevel, number> = {
@@ -1068,8 +1106,8 @@ function buildAdditionalModelRequestFields(
};
// Custom budgets override defaults (xhigh not in ThinkingBudgets, use high)
const level = options.reasoning === "xhigh" ? "high" : options.reasoning;
const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[options.reasoning];
const level = reasoning === "xhigh" ? "high" : reasoning;
const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[reasoning];
return {
thinking: {
+13 -2
View File
@@ -9,6 +9,7 @@ import type {
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
} from "openclaw/plugin-sdk/provider-model-shared";
const BASE_CLAUDE_THINKING_LEVELS = [
@@ -58,6 +59,7 @@ export function isLatestAdaptiveBedrockModelRef(
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
return (
resolveClaudeFable5ModelIdentity(modelRef) !== undefined ||
resolveClaudeSonnet5ModelIdentity(modelRef) !== undefined ||
[modelId, canonicalModelId].some(
(candidate) =>
isOpus47OrNewerBedrockModelRef(candidate) || isMythosPreviewBedrockModelRef(candidate),
@@ -70,7 +72,10 @@ export function supportsBedrockNativeMaxEffort(
modelId: string,
params?: Record<string, unknown>,
): boolean {
if (resolveClaudeFable5ModelIdentity({ id: modelId, params })) {
if (
resolveClaudeFable5ModelIdentity({ id: modelId, params }) ||
resolveClaudeSonnet5ModelIdentity({ id: modelId, params })
) {
return true;
}
const canonicalModelId = resolveClaudeModelIdentity({ id: modelId, params });
@@ -88,6 +93,9 @@ export function resolveBedrockNativeThinkingLevelMap(
if (resolveClaudeFable5ModelIdentity(modelRef)) {
return { off: "low", minimal: "low", xhigh: "xhigh", max: "max" };
}
if (resolveClaudeSonnet5ModelIdentity(modelRef)) {
return { off: "low", minimal: "low", xhigh: "xhigh", max: "max" };
}
if (!supportsBedrockNativeMaxEffort(modelId, params)) {
return undefined;
}
@@ -106,7 +114,10 @@ export function resolveBedrockClaudeThinkingProfile(
const trimmed = modelId.trim();
const canonicalModelId = resolveClaudeModelIdentity({ id: trimmed, params });
const modelRefs = [trimmed, canonicalModelId];
if (resolveClaudeFable5ModelIdentity({ id: trimmed, params })) {
if (
resolveClaudeFable5ModelIdentity({ id: trimmed, params }) ||
resolveClaudeSonnet5ModelIdentity({ id: trimmed, params })
) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "xhigh" }, { id: "adaptive" }, { id: "max" }],
defaultLevel: "high",
+81
View File
@@ -15,6 +15,7 @@ vi.mock("./api.js", async (importOriginal) => {
});
import anthropicVertexPlugin from "./index.js";
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
describe("anthropic-vertex provider plugin", () => {
beforeEach(() => {
@@ -89,10 +90,90 @@ describe("anthropic-vertex provider plugin", () => {
xhigh: "xhigh",
max: "max",
});
expect(result.provider.models[1]?.thinkingLevelMap).toEqual({ xhigh: "xhigh", max: "max" });
expect(result.provider.models[2]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" });
expect(result.provider.models[3]?.thinkingLevelMap).toEqual({ xhigh: null, max: "max" });
});
it.each(["global", "us", "eu"])("publishes Sonnet 5 for the %s endpoint", (region) => {
const provider = buildAnthropicVertexProvider({
env: { GOOGLE_CLOUD_LOCATION: region },
nowMs: Date.UTC(2026, 7, 31),
});
expect(provider.models.map((model) => model.id)).toContain("claude-sonnet-5");
});
it.each([
{
region: "global",
nowMs: Date.UTC(2026, 7, 31),
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
},
{
region: "us",
nowMs: Date.UTC(2026, 7, 31),
cost: { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 },
},
{
region: "global",
nowMs: Date.UTC(2026, 8, 1),
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
},
{
region: "eu",
nowMs: Date.UTC(2026, 8, 1),
cost: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
},
])("uses the documented Sonnet 5 pricing for $region", ({ region, nowMs, cost }) => {
const provider = buildAnthropicVertexProvider({
env: { GOOGLE_CLOUD_LOCATION: region },
nowMs,
});
expect(provider.models.find((model) => model.id === "claude-sonnet-5")).toMatchObject({
cost,
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
});
it("refreshes Sonnet 5 pricing during runtime normalization", async () => {
vi.useFakeTimers();
vi.setSystemTime(Date.UTC(2026, 8, 1));
try {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic-vertex",
modelId: "claude-sonnet-5",
model: {
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
api: "anthropic-messages",
provider: "anthropic-vertex",
baseUrl: "https://us-aiplatform.googleapis.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 },
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
},
} as never);
expect(normalized?.cost).toEqual({
input: 3.3,
output: 16.5,
cacheRead: 0.33,
cacheWrite: 4.125,
});
} finally {
vi.useRealTimers();
}
});
it("owns Anthropic-style replay policy", async () => {
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
+83 -13
View File
@@ -7,15 +7,32 @@ import type {
ModelDefinitionConfig,
ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import { resolveClaudeFable5ModelIdentity } from "openclaw/plugin-sdk/provider-model-shared";
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeSonnet5ModelIdentity,
} from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveAnthropicVertexRegion } from "./region.js";
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexRegion } from "./region.js";
/** Default Anthropic Vertex model used for implicit provider catalogs. */
export const ANTHROPIC_VERTEX_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000;
const ANTHROPIC_VERTEX_FABLE_MAX_TOKENS = 128_000;
// Vertex's introductory rate expires at the documented UTC month boundary.
const SONNET_5_STANDARD_PRICING_START_MS = Date.UTC(2026, 8, 1);
const SONNET_5_SUPPORTED_REGIONS = new Set(["global", "us", "eu"]);
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
const SONNET_5_COST = {
promotional: {
global: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
multiRegion: { input: 2.2, output: 11, cacheRead: 0.22, cacheWrite: 2.75 },
},
standard: {
global: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
multiRegion: { input: 3.3, output: 16.5, cacheRead: 0.33, cacheWrite: 4.125 },
},
} as const;
function buildAnthropicVertexModel(params: {
id: string;
name: string;
@@ -23,6 +40,7 @@ function buildAnthropicVertexModel(params: {
input: ModelDefinitionConfig["input"];
cost: ModelDefinitionConfig["cost"];
maxTokens: number;
mediaInput?: ModelDefinitionConfig["mediaInput"];
thinkingLevelMap?: ModelDefinitionConfig["thinkingLevelMap"];
}): ModelDefinitionConfig {
return {
@@ -33,11 +51,44 @@ function buildAnthropicVertexModel(params: {
cost: params.cost,
contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
maxTokens: params.maxTokens,
...(params.mediaInput ? { mediaInput: params.mediaInput } : {}),
...(params.thinkingLevelMap ? { thinkingLevelMap: params.thinkingLevelMap } : {}),
};
}
function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] {
function resolveSonnet5Cost(
region: string,
nowMs: number = Date.now(),
): ModelDefinitionConfig["cost"] | undefined {
const normalizedRegion = normalizeLowercaseStringOrEmpty(region);
if (!SONNET_5_SUPPORTED_REGIONS.has(normalizedRegion)) {
return undefined;
}
const pricingPeriod = nowMs >= SONNET_5_STANDARD_PRICING_START_MS ? "standard" : "promotional";
return normalizedRegion === "global"
? SONNET_5_COST[pricingPeriod].global
: SONNET_5_COST[pricingPeriod].multiRegion;
}
function buildAnthropicVertexCatalog(region: string, nowMs: number): ModelDefinitionConfig[] {
const sonnet5Cost = resolveSonnet5Cost(region, nowMs);
const sonnet5 = sonnet5Cost
? [
buildAnthropicVertexModel({
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
reasoning: true,
input: ["text", "image"],
cost: sonnet5Cost,
maxTokens: 128_000,
mediaInput: {
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
},
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
}),
]
: [];
return [
buildAnthropicVertexModel({
id: "claude-fable-5",
@@ -48,6 +99,7 @@ function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] {
maxTokens: ANTHROPIC_VERTEX_FABLE_MAX_TOKENS,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
}),
...sonnet5,
buildAnthropicVertexModel({
id: "claude-opus-4-8",
name: "Claude Opus 4.8",
@@ -78,34 +130,50 @@ function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] {
];
}
/** Restore required Fable metadata after explicit catalog models replace the implicit row. */
/** Restore required generation metadata after explicit models replace an implicit row. */
export function normalizeAnthropicVertexResolvedModel(
modelId: string,
model: ProviderRuntimeModel,
): ProviderRuntimeModel | undefined {
if (!resolveClaudeFable5ModelIdentity({ id: modelId, params: model.params })) {
const ref = { id: modelId, params: model.params };
const fable5 = resolveClaudeFable5ModelIdentity(ref) !== undefined;
const sonnet5 = resolveClaudeSonnet5ModelIdentity(ref) !== undefined;
if (!fable5 && !sonnet5) {
return undefined;
}
const input: ProviderRuntimeModel["input"] = model.input.includes("image")
? model.input
: [...model.input, "image"];
const thinkingLevelMap = {
off: "low",
minimal: "low",
const nativeThinkingLevelMap = {
...(fable5 ? { off: "low" as const, minimal: "low" as const } : {}),
xhigh: "xhigh",
max: "max",
};
const thinkingLevelMap = {
...nativeThinkingLevelMap,
...model.thinkingLevelMap,
};
const nativeThinkingLevelsMatch =
model.thinkingLevelMap?.xhigh === "xhigh" &&
model.thinkingLevelMap.max === "max" &&
(!fable5 || (model.thinkingLevelMap.off === "low" && model.thinkingLevelMap.minimal === "low"));
const cost = sonnet5
? resolveSonnet5Cost(resolveAnthropicVertexClientRegion({ baseUrl: model.baseUrl }))
: undefined;
const costMatches =
!cost ||
(model.cost.input === cost.input &&
model.cost.output === cost.output &&
model.cost.cacheRead === cost.cacheRead &&
model.cost.cacheWrite === cost.cacheWrite);
if (
model.reasoning &&
input === model.input &&
model.contextWindow === ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW &&
model.contextTokens === ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW &&
(model.maxTokens ?? 0) >= ANTHROPIC_VERTEX_FABLE_MAX_TOKENS &&
model.thinkingLevelMap?.off === "low" &&
model.thinkingLevelMap.minimal === "low" &&
model.thinkingLevelMap.xhigh === "xhigh" &&
model.thinkingLevelMap.max === "max"
nativeThinkingLevelsMatch &&
costMatches
) {
return undefined;
}
@@ -117,12 +185,14 @@ export function normalizeAnthropicVertexResolvedModel(
contextTokens: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
maxTokens: Math.max(model.maxTokens ?? 0, ANTHROPIC_VERTEX_FABLE_MAX_TOKENS),
thinkingLevelMap,
...(cost ? { cost } : {}),
};
}
/** Build the implicit Anthropic Vertex provider config for the current env. */
export function buildAnthropicVertexProvider(params?: {
env?: NodeJS.ProcessEnv;
nowMs?: number;
}): ModelProviderConfig {
const region = resolveAnthropicVertexRegion(params?.env);
const baseUrl =
@@ -134,6 +204,6 @@ export function buildAnthropicVertexProvider(params?: {
baseUrl,
api: "anthropic-messages",
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
models: buildAnthropicVertexCatalog(),
models: buildAnthropicVertexCatalog(region, params?.nowMs ?? Date.now()),
};
}
@@ -253,6 +253,29 @@ describe("createAnthropicVertexStreamFn", () => {
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it.each([
{ reasoning: undefined, thinkingEnabled: true, effort: "high" },
{ reasoning: "off" as const, thinkingEnabled: false, effort: undefined },
])(
"supports Sonnet 5 reasoning=$reasoning on Vertex",
({ reasoning, thinkingEnabled, effort }) => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-sonnet-5", maxTokens: 128_000 });
void streamFn(model, { messages: [] }, { reasoning, temperature: 0.7 });
const options = streamTransportOptions(streamAnthropicMock);
expect(options).toMatchObject({ thinkingEnabled, maxTokens: 128_000 });
expect(options).not.toHaveProperty("temperature");
if (effort) {
expect(options.effort).toBe(effort);
} else {
expect(options).not.toHaveProperty("effort");
}
},
);
it("uses Mythos 5's mandatory adaptive Vertex contract by default", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
+17 -4
View File
@@ -14,6 +14,7 @@ import {
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
@@ -54,6 +55,10 @@ function isClaudeFable5Model(modelId: string): boolean {
return resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined;
}
function isClaudeSonnet5Model(modelId: string): boolean {
return resolveClaudeSonnet5ModelIdentity({ id: modelId }) !== undefined;
}
function isClaudeMythos5Model(modelId: string): boolean {
return /(?:^|-)claude-mythos-5(?=$|[^a-z0-9])/.test(resolveClaudeModelIdentity({ id: modelId }));
}
@@ -149,12 +154,18 @@ export function createAnthropicVertexStreamFn(
});
const contractModelId = resolveClaudeModelIdentity(model);
const fable5 = isClaudeFable5Model(contractModelId);
const sonnet5 = isClaudeSonnet5Model(contractModelId);
const mandatoryAdaptiveThinking = fable5 || isClaudeMythos5Model(contractModelId);
const requestedReasoning = options?.reasoning;
const reasoning =
(options?.reasoning as ModelThinkingLevel | undefined) ??
(mandatoryAdaptiveThinking ? "high" : undefined);
requestedReasoning === "off" && mandatoryAdaptiveThinking
? fable5
? "low"
: "high"
: (requestedReasoning ?? (mandatoryAdaptiveThinking || sonnet5 ? "high" : undefined));
const adaptiveThinking =
mandatoryAdaptiveThinking || Boolean(reasoning && supportsAdaptiveThinking(contractModelId));
mandatoryAdaptiveThinking ||
Boolean(reasoning && reasoning !== "off" && supportsAdaptiveThinking(contractModelId));
const temperature =
adaptiveThinking ||
isClaudeOpus47OrNewerModel(contractModelId) ||
@@ -177,7 +188,9 @@ export function createAnthropicVertexStreamFn(
metadata: options?.metadata,
};
if (reasoning) {
if (reasoning === "off") {
opts.thinkingEnabled = false;
} else if (reasoning) {
if (supportsAdaptiveThinking(contractModelId)) {
opts.thinkingEnabled = true;
opts.effort = mapAnthropicAdaptiveEffort(
+1 -1
View File
@@ -7,7 +7,7 @@ import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_MODEL_ALIASES } from "./cli-constants
const DEFAULT_CLAUDE_MODEL_BY_FAMILY: Record<string, string> = {
opus: "claude-opus-4-8",
sonnet: "claude-sonnet-4-6",
sonnet: "claude-sonnet-5",
haiku: "claude-haiku-4-5",
};
+8 -2
View File
@@ -7,16 +7,22 @@ import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-
// Claude CLI auth is subscription-backed, so catalog rows only need picker metadata.
const CLAUDE_CLI_DEFAULT_CONTEXT_WINDOW = 200_000;
const CLAUDE_CLI_CONTEXT_WINDOWS: Record<string, number> = {
"claude-opus-4-8": 1_048_576,
"claude-sonnet-5": 1_000_000,
};
const CLAUDE_CLI_MODEL_LABELS: Record<string, string> = {
"claude-opus-4-8": "Claude Opus 4.8 (Claude CLI)",
"claude-opus-4-7": "Claude Opus 4.7 (Claude CLI)",
"claude-opus-4-6": "Claude Opus 4.6 (Claude CLI)",
"claude-sonnet-5": "Claude Sonnet 5 (Claude CLI)",
"claude-sonnet-4-6": "Claude Sonnet 4.6 (Claude CLI)",
};
function resolveClaudeCliImageMediaInput(id: string): ModelCatalogEntry["mediaInput"] {
const maxSidePx = id === "claude-opus-4-8" || id === "claude-opus-4-7" ? 2576 : 1568;
const maxSidePx =
id === "claude-opus-4-8" || id === "claude-opus-4-7" || id === "claude-sonnet-5" ? 2576 : 1568;
return {
image: {
maxSidePx,
@@ -53,7 +59,7 @@ export function buildClaudeCliCatalogEntries(): ModelCatalogEntry[] {
reasoning: true,
input: ["text", "image"],
mediaInput: resolveClaudeCliImageMediaInput(id),
contextWindow: id === "claude-opus-4-8" ? 1_048_576 : CLAUDE_CLI_DEFAULT_CONTEXT_WINDOW,
contextWindow: CLAUDE_CLI_CONTEXT_WINDOWS[id] ?? CLAUDE_CLI_DEFAULT_CONTEXT_WINDOW,
};
});
}
+3
View File
@@ -9,6 +9,7 @@ export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opu
/** Default Claude CLI models allowed when setup seeds the model allowlist. */
export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [
CLAUDE_CLI_DEFAULT_MODEL_REF,
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-5`,
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`,
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`,
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`,
@@ -24,6 +25,8 @@ export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
"claude-opus-4-7": "claude-opus-4-7",
"claude-opus-4-6": "claude-opus-4-6",
sonnet: "sonnet",
"sonnet-5": "claude-sonnet-5",
"claude-sonnet-5": "claude-sonnet-5",
"sonnet-4.6": "claude-sonnet-4-6",
"claude-sonnet-4-6": "claude-sonnet-4-6",
haiku: "haiku",
+4 -1
View File
@@ -14,7 +14,10 @@ import {
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-constants.js";
const ANTHROPIC_PROVIDER_API = "anthropic-messages";
const ANTHROPIC_API_KEY_DEFAULT_ALLOWLIST_REFS = ["anthropic/claude-sonnet-4-6"] as const;
const ANTHROPIC_API_KEY_DEFAULT_ALLOWLIST_REFS = [
"anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-4-6",
] as const;
function normalizeProviderId(provider: string): string {
const normalized = normalizeLowercaseStringOrEmpty(provider);
+134 -1
View File
@@ -23,6 +23,7 @@ vi.mock("./cli-auth-seam.js", () => {
};
});
import { buildClaudeCliCatalogEntries } from "./cli-catalog.js";
import anthropicPlugin from "./index.js";
beforeEach(() => {
@@ -91,6 +92,19 @@ describe("anthropic provider replay hooks", () => {
});
});
it("publishes Claude Sonnet 5 CLI metadata without downgrading its API contract", () => {
expect(
buildClaudeCliCatalogEntries().find((model) => model.id === "claude-sonnet-5"),
).toMatchObject({
id: "claude-sonnet-5",
name: "Claude Sonnet 5 (Claude CLI)",
contextWindow: 1_000_000,
mediaInput: {
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
},
});
});
it("owns native reasoning output mode for Claude transports", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
@@ -223,7 +237,7 @@ describe("anthropic provider replay hooks", () => {
).toBe("short");
});
it("backfills Sonnet into API-key agent model allowlists", async () => {
it("backfills current Sonnet models into API-key agent model allowlists", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const next = provider.applyConfigDefaults?.({
@@ -248,6 +262,7 @@ describe("anthropic provider replay hooks", () => {
const models = next?.agents?.defaults?.models;
expectModelParams(models, "anthropic/claude-opus-4-6", { cacheRetention: "short" });
expectModelParams(models, "anthropic/claude-sonnet-5", { cacheRetention: "short" });
expectModelParams(models, "anthropic/claude-sonnet-4-6", { cacheRetention: "short" });
});
@@ -626,6 +641,124 @@ describe("anthropic provider replay hooks", () => {
).toBe(false);
});
it("resolves Claude Sonnet 5 with its exact API contract", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const resolved = provider.resolveDynamicModel?.({
provider: "anthropic",
modelId: "claude-sonnet-5",
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext);
expectFields(resolved, {
provider: "anthropic",
id: "claude-sonnet-5",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
const profile = provider.resolveThinkingProfile?.({
provider: "anthropic",
modelId: "claude-sonnet-5",
} as never);
expect(levelIds(profile)).toStrictEqual([
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
]);
expect(requireRecord(profile, "Sonnet 5 thinking profile").defaultLevel).toBe("high");
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId: "claude-sonnet-5",
model: {
...(resolved as ProviderRuntimeModel),
reasoning: false,
contextWindow: 200_000,
contextTokens: 200_000,
maxTokens: 64_000,
},
} as never);
expectFields(normalized, {
reasoning: true,
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
});
});
it("rolls Claude Sonnet 5 to standard pricing on September 1, 2026", async () => {
vi.useFakeTimers();
vi.setSystemTime(Date.UTC(2026, 8, 1));
try {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const model = {
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
provider: "anthropic",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
} as ProviderRuntimeModel;
expect(
provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId: model.id,
model,
} as never)?.cost,
).toEqual({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 });
expect(
provider.resolveDynamicModel?.({
provider: "anthropic",
modelId: "claude-sonnet-5-20260901",
modelRegistry: createModelRegistry([]),
} as ProviderResolveDynamicModelContext)?.cost,
).toEqual({ input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 });
} finally {
vi.useRealTimers();
}
});
it("does not apply direct API pricing to Claude CLI Sonnet 5", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const model = {
id: "claude-sonnet-5",
name: "Claude Sonnet 5 (Claude CLI)",
provider: "claude-cli",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
contextTokens: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
} as ProviderRuntimeModel;
expect(
provider.normalizeResolvedModel?.({
provider: "claude-cli",
modelId: model.id,
model,
} as never)?.cost,
).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
});
it("resolves dated modern Claude refs without discovery templates", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
+29
View File
@@ -15,6 +15,17 @@
"providers": {
"claude-cli": {
"models": [
{
"id": "claude-sonnet-5",
"name": "Claude Sonnet 5 (Claude CLI)",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
},
"contextWindow": 1000000,
"maxTokens": 128000
},
{
"id": "claude-opus-4-8",
"name": "Claude Opus 4.8 (Claude CLI)",
@@ -83,6 +94,22 @@
"max": "max"
}
},
{
"id": "claude-sonnet-5",
"name": "Claude Sonnet 5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
},
"cost": { "input": 2, "output": 10, "cacheRead": 0.2, "cacheWrite": 2.5 },
"contextWindow": 1000000,
"maxTokens": 128000,
"thinkingLevelMap": {
"xhigh": "xhigh",
"max": "max"
}
},
{
"id": "claude-opus-4-8",
"name": "Claude Opus 4.8",
@@ -167,6 +194,8 @@
"opus-4.8": "claude-opus-4-8",
"opus": "claude-opus-4-8",
"opus-4.6": "claude-opus-4-6",
"sonnet-5": "claude-sonnet-5",
"sonnet": "claude-sonnet-5",
"sonnet-4.6": "claude-sonnet-4-6"
}
}
@@ -20,6 +20,13 @@ type AnthropicManifest = {
};
contextWindow?: number;
maxTokens?: number;
cost?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
};
thinkingLevelMap?: Record<string, string | null>;
}>;
};
};
@@ -32,6 +39,23 @@ const manifest = JSON.parse(
) as AnthropicManifest;
describe("Anthropic plugin manifest", () => {
it("publishes the exact Claude Sonnet 5 API contract", () => {
const models = manifest.modelCatalog?.providers?.anthropic?.models ?? [];
expect(models.find((model) => model.id === "claude-sonnet-5")).toEqual({
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
reasoning: true,
input: ["text", "image"],
mediaInput: {
image: { maxSidePx: 2576, preferredSidePx: 2576, tokenMode: "provider" },
},
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
});
it("resolves both official Claude Haiku 4.5 API identifiers from the static catalog", () => {
expect(manifest.modelCatalog?.discovery?.anthropic).toBe("static");
+68 -14
View File
@@ -30,6 +30,7 @@ import {
type ProviderPlugin,
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
resolveClaudeThinkingProfile,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
@@ -62,8 +63,22 @@ const ANTHROPIC_OPUS_48_DOT_MODEL_ID = "claude-opus-4.8";
const ANTHROPIC_OPUS_47_MODEL_ID = "claude-opus-4-7";
const ANTHROPIC_OPUS_47_DOT_MODEL_ID = "claude-opus-4.7";
const ANTHROPIC_GA_1M_CONTEXT_TOKENS = 1_048_576;
const ANTHROPIC_FABLE_CONTEXT_TOKENS = 1_000_000;
const ANTHROPIC_EXACT_1M_CONTEXT_TOKENS = 1_000_000;
const ANTHROPIC_MODERN_MAX_OUTPUT_TOKENS = 128_000;
// Anthropic's introductory rate expires at the documented UTC month boundary.
const ANTHROPIC_SONNET_5_STANDARD_PRICING_START_MS = Date.UTC(2026, 8, 1);
const ANTHROPIC_SONNET_5_PROMOTIONAL_COST = {
input: 2,
output: 10,
cacheRead: 0.2,
cacheWrite: 2.5,
};
const ANTHROPIC_SONNET_5_STANDARD_COST = {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
};
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS = [
@@ -79,6 +94,12 @@ const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
`If you want a direct API billing path instead, use ${formatCliCommand("openclaw models auth login --provider anthropic --method api-key --set-default")} or ${formatCliCommand("openclaw models auth login --provider anthropic --method cli --set-default")}.`,
] as const;
function resolveAnthropicSonnet5Cost(nowMs: number = Date.now()) {
return nowMs >= ANTHROPIC_SONNET_5_STANDARD_PRICING_START_MS
? ANTHROPIC_SONNET_5_STANDARD_COST
: ANTHROPIC_SONNET_5_PROMOTIONAL_COST;
}
const CLAUDE_CLI_CANONICAL_ALLOWLIST_REFS = CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS.map((ref) =>
ref.startsWith(`${CLAUDE_CLI_BACKEND_ID}/`)
? `anthropic/${ref.slice(CLAUDE_CLI_BACKEND_ID.length + 1)}`
@@ -286,7 +307,9 @@ function buildAnthropicForwardCompatModel(
input: ["text", "image"],
cost: isAnthropicFable5Model(trimmedModelId)
? { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
: isAnthropicSonnet5Model(trimmedModelId) && provider === PROVIDER_ID
? resolveAnthropicSonnet5Cost()
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: resolveAnthropicFixedContextWindow(trimmedModelId) ?? 200_000,
maxTokens: isAnthropic128kOutputModel(trimmedModelId)
? ANTHROPIC_MODERN_MAX_OUTPUT_TOKENS
@@ -347,22 +370,26 @@ function isAnthropicFable5Model(modelId: string): boolean {
return resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined;
}
function isAnthropicSonnet5Model(modelId: string): boolean {
return resolveClaudeSonnet5ModelIdentity({ id: modelId }) !== undefined;
}
function resolveAnthropicFixedContextWindow(modelId: string): number | undefined {
if (isAnthropicFable5Model(modelId)) {
return ANTHROPIC_FABLE_CONTEXT_TOKENS;
if (isAnthropicFable5Model(modelId) || isAnthropicSonnet5Model(modelId)) {
return ANTHROPIC_EXACT_1M_CONTEXT_TOKENS;
}
return isAnthropicGa1MModel(modelId) ? ANTHROPIC_GA_1M_CONTEXT_TOKENS : undefined;
}
function isAnthropic128kOutputModel(modelId: string): boolean {
if (isAnthropicFable5Model(modelId)) {
if (isAnthropicFable5Model(modelId) || isAnthropicSonnet5Model(modelId)) {
return true;
}
return /^claude-opus-4-8(?=$|[^a-z0-9])/.test(resolveClaudeModelIdentity({ id: modelId }));
}
function isAnthropicOpus47OrNewerModel(modelId: string): boolean {
return supportsClaudeNativeXhighEffort({ id: modelId }) && !isAnthropicFable5Model(modelId);
function isAnthropicLargeImageModel(modelId: string): boolean {
return supportsClaudeNativeXhighEffort({ id: modelId });
}
function isAnthropicMythosPreviewModel(modelId: string): boolean {
@@ -425,7 +452,9 @@ function applyAnthropicFixedContextWindow(params: {
if (hasConfiguredModelContextOverride(params.config, params.provider, params.modelId)) {
return undefined;
}
const exactContextWindow = isAnthropicFable5Model(params.contractModelId);
const exactContextWindow =
isAnthropicFable5Model(params.contractModelId) ||
isAnthropicSonnet5Model(params.contractModelId);
const nextContextWindow = exactContextWindow
? fixedContextWindow
: Math.max(params.model.contextWindow ?? 0, fixedContextWindow);
@@ -468,7 +497,7 @@ function applyAnthropicThinkingLevelMap(params: {
model: ProviderRuntimeModel;
}): ProviderRuntimeModel | undefined {
const fable5 = isAnthropicFable5Model(params.modelId);
const nativeXhigh = fable5 || isAnthropicOpus47OrNewerModel(params.modelId);
const nativeXhigh = fable5 || supportsClaudeNativeXhighEffort({ id: params.modelId });
if (!supportsAnthropicNativeMaxEffort(params.modelId)) {
return undefined;
}
@@ -512,9 +541,7 @@ function resolveAnthropicImageMediaInput(modelId: string, modelName?: string) {
return undefined;
}
const refs = [modelId, modelName].filter((value): value is string => typeof value === "string");
const largeImageModel = refs.some(
(ref) => isAnthropicFable5Model(ref) || isAnthropicOpus47OrNewerModel(ref),
);
const largeImageModel = refs.some((ref) => isAnthropicLargeImageModel(ref));
return {
image: {
maxSidePx: largeImageModel ? 2576 : 1568,
@@ -540,6 +567,25 @@ function applyAnthropicImageInputCapability(params: {
};
}
function applyAnthropicSonnet5Cost(params: {
modelId: string;
model: ProviderRuntimeModel;
}): ProviderRuntimeModel | undefined {
if (!isAnthropicSonnet5Model(params.modelId)) {
return undefined;
}
const cost = resolveAnthropicSonnet5Cost();
if (
params.model.cost.input === cost.input &&
params.model.cost.output === cost.output &&
params.model.cost.cacheRead === cost.cacheRead &&
params.model.cost.cacheWrite === cost.cacheWrite
) {
return undefined;
}
return { ...params.model, cost };
}
function normalizeAnthropicResolvedModel(
ctx: ProviderNormalizeResolvedModelContext,
): ProviderRuntimeModel | undefined {
@@ -554,7 +600,8 @@ function normalizeAnthropicResolvedModel(
return undefined;
}
const contractModel =
isAnthropicFable5Model(contractModelId) && !ctx.model.reasoning
(isAnthropicFable5Model(contractModelId) || isAnthropicSonnet5Model(contractModelId)) &&
!ctx.model.reasoning
? { ...ctx.model, reasoning: true }
: ctx.model;
const imageCapableModel =
@@ -594,7 +641,14 @@ function normalizeAnthropicResolvedModel(
contractModelId,
model: thinkingLevelModel,
}) ?? thinkingLevelModel;
return contextWindowModel === ctx.model ? undefined : contextWindowModel;
const pricingModel =
normalizeLowercaseStringOrEmpty(ctx.provider) === PROVIDER_ID
? (applyAnthropicSonnet5Cost({
modelId: contractModelId,
model: contextWindowModel,
}) ?? contextWindowModel)
: contextWindowModel;
return pricingModel === ctx.model ? undefined : pricingModel;
}
function buildAnthropicAuthDoctorHint(params: {
@@ -114,6 +114,11 @@ describe("anthropic stream wrappers", () => {
expect(captured.payload?.service_tier).toBeUndefined();
});
it("skips unsupported service_tier for Claude Sonnet 5", () => {
const captured = runComposedAnthropicProviderStream("sk-ant-api-123", "claude-sonnet-5");
expect(captured.payload?.service_tier).toBeUndefined();
});
it("composes the anthropic provider stream chain from extra params", () => {
const captured = runComposedAnthropicProviderStream("sk-ant-api-123");
expect(captured.headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
+13 -3
View File
@@ -5,7 +5,10 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { streamSimple } from "openclaw/plugin-sdk/llm";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { resolveClaudeFable5ModelIdentity } from "openclaw/plugin-sdk/provider-model-shared";
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeSonnet5ModelIdentity,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
applyAnthropicPayloadPolicyToParams,
composeProviderStreamWrappers,
@@ -47,7 +50,10 @@ type AnthropicServiceTier = "auto" | "standard_only";
type DynamicFastMode = boolean | (() => boolean | undefined);
function isAnthropic1MModel(modelId: string): boolean {
if (resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined) {
if (
resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined ||
resolveClaudeSonnet5ModelIdentity({ id: modelId }) !== undefined
) {
return true;
}
const normalized = normalizeLowercaseStringOrEmpty(modelId);
@@ -184,7 +190,11 @@ export function createAnthropicServiceTierWrapper(
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (isAnthropicOAuthApiKey(options?.apiKey)) {
// Sonnet 5 does not support Priority Tier; omit service_tier entirely.
if (
isAnthropicOAuthApiKey(options?.apiKey) ||
resolveClaudeSonnet5ModelIdentity(model) !== undefined
) {
return underlying(model, context, options);
}
+1 -1
View File
@@ -487,7 +487,7 @@ function resolveGoogleThinkingConfig(
}
return normalizeGoogleThinkingConfig(model.id, config);
}
if (!options?.reasoning) {
if (!options?.reasoning || options.reasoning === "off") {
return getDisabledThinkingConfig(model.id);
}
if (isAdaptiveReasoningLevel(options.reasoning)) {
+5 -1
View File
@@ -62,7 +62,11 @@ function captureTencentPayload(params: {
}) {
let captured: Record<string, unknown> | undefined;
const baseStreamFn: StreamFn = (_model, context, options) => {
const payload = buildOpenAICompletionsParams(_model as OpenAICompletionsModel, context, options);
const payload = buildOpenAICompletionsParams(
_model as OpenAICompletionsModel,
context,
options as Parameters<typeof buildOpenAICompletionsParams>[2],
);
options?.onPayload?.(payload, _model);
captured = payload;
return {} as ReturnType<StreamFn>;
+15
View File
@@ -35,6 +35,21 @@ describe("resolveAgentReasoningOption", () => {
expect(resolveAgentReasoningOption(makeModel({ off: "low" }), "high")).toBe("high");
});
it("preserves explicit off for Sonnet 5 on Anthropic Messages routes", () => {
expect(
resolveAgentReasoningOption(makeModel(undefined, { id: "claude-sonnet-5" }), "off"),
).toBe("off");
});
it("uses the route-owned Sonnet 5 off mapping when provided", () => {
expect(
resolveAgentReasoningOption(
makeModel({ off: "low" }, { id: "anthropic.claude-sonnet-5" }),
"off",
),
).toBe("low");
});
it.each(["anthropic-messages", "bedrock-converse-stream"] as const)(
"maps explicit off to low for canonical Fable aliases on %s",
(api) => {
+8 -2
View File
@@ -1,11 +1,12 @@
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeSonnet5ModelIdentity,
type Model,
type SimpleStreamOptions,
} from "../../llm-core/src/index.js";
import type { ThinkingLevel } from "./types.js";
type EnabledThinkingLevel = NonNullable<SimpleStreamOptions["reasoning"]>;
type EnabledThinkingLevel = Exclude<NonNullable<SimpleStreamOptions["reasoning"]>, "off">;
const ENABLED_THINKING_LEVELS = new Set<EnabledThinkingLevel>([
"minimal",
@@ -33,5 +34,10 @@ export function resolveAgentReasoningOption(
resolveClaudeFable5ModelIdentity(model)
? "low"
: undefined);
return isEnabledThinkingLevel(offFallback) ? offFallback : undefined;
if (isEnabledThinkingLevel(offFallback)) {
return offFallback;
}
return model.api === "anthropic-messages" && resolveClaudeSonnet5ModelIdentity(model)
? "off"
: undefined;
}
@@ -1,10 +1,16 @@
// Model-bound thinking cannot be exposed or replayed after a model switch.
import { resolveClaudeFable5ModelIdentity, resolveClaudeModelIdentity } from "@openclaw/llm-core";
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
} from "@openclaw/llm-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { Context, Model } from "../types.js";
export {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeNativeThinkingLevelMap,
resolveClaudeSonnet5ModelIdentity,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
@@ -49,6 +55,21 @@ export function usesClaudeFable5MessagesContract(model: {
);
}
/** Return whether streamed output must wait for the terminal refusal decision. */
export function usesClaudeStreamingRefusalContract(model: {
id?: string;
params?: Record<string, unknown>;
api?: string;
}): boolean {
if (normalizeApi(model.api) !== "anthropic-messages") {
return false;
}
return (
resolveClaudeFable5ModelIdentity(model) !== undefined ||
resolveClaudeSonnet5ModelIdentity(model) !== undefined
);
}
export function requiresClaudeAdaptiveThinking(model: {
id?: string;
params?: Record<string, unknown>;
@@ -64,6 +85,41 @@ export function requiresClaudeAdaptiveThinking(model: {
);
}
/** Return whether omitted thinking should default to adaptive/high. */
export function defaultsClaudeAdaptiveThinking(model: {
id?: string;
params?: Record<string, unknown>;
api?: string;
}): boolean {
return (
requiresClaudeAdaptiveThinking(model) ||
(normalizeApi(model.api) === "anthropic-messages" &&
resolveClaudeSonnet5ModelIdentity(model) !== undefined)
);
}
/** Remove Sonnet 5 assistant prefills while preserving completed tool-use turns. */
export function prepareClaudeSonnet5RequestContext(model: Model, context: Context): Context {
if (!resolveClaudeSonnet5ModelIdentity(model)) {
return context;
}
let end = context.messages.length;
while (end > 0) {
const message = context.messages[end - 1];
if (
message?.role !== "assistant" ||
(Array.isArray(message.content) && message.content.some((block) => block.type === "toolCall"))
) {
break;
}
end -= 1;
}
return end === context.messages.length
? context
: { ...context, messages: context.messages.slice(0, end) };
}
function resolveReplayFableIdentity(ref: ReplayModelRef): string | undefined {
if (normalizeApi(ref.api) !== "anthropic-messages") {
return undefined;
+84 -5
View File
@@ -50,6 +50,32 @@ function makeAnthropicModel(overrides: Partial<Model<"anthropic-messages">> = {}
} satisfies Model<"anthropic-messages">;
}
function makeSonnet5PrefillContext(): Context {
return {
messages: [
{ role: "user", content: "Return JSON.", timestamp: 0 },
{
role: "assistant",
content: [{ type: "text", text: "{" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
},
],
tools: [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }],
};
}
describe("Anthropic provider", () => {
beforeEach(() => {
anthropicMockState.configs = [];
@@ -835,9 +861,10 @@ describe("Anthropic provider", () => {
});
it.each([
["anthropic", "sk-ant-provider"],
["anthropic-vertex", "vertex-token"],
])("surfaces structured Anthropic streaming refusals for %s", async (provider, apiKey) => {
["claude-fable-5", "Claude Fable 5", "anthropic", "sk-ant-provider"],
["claude-sonnet-5", "Claude Sonnet 5", "anthropic", "sk-ant-provider"],
["claude-sonnet-5", "Claude Sonnet 5", "anthropic-vertex", "vertex-token"],
])("surfaces structured %s streaming refusals for %s", async (id, name, provider, apiKey) => {
const client = {
messages: {
create: vi.fn(() => ({
@@ -880,8 +907,8 @@ describe("Anthropic provider", () => {
const stream = streamAnthropic(
makeAnthropicModel({
id: "claude-fable-5",
name: "Claude Fable 5",
id,
name,
provider,
}),
{ messages: [{ role: "user", content: "hello", timestamp: 0 }] },
@@ -1997,4 +2024,56 @@ describe("Anthropic provider", () => {
},
]);
});
it.each([
{
name: "defaults to adaptive high",
reasoning: undefined,
thinking: { type: "adaptive", display: "summarized" },
effort: { effort: "high" },
toolChoice: { type: "auto" },
},
{
name: "allows explicit off",
reasoning: "off" as const,
thinking: { type: "disabled" },
effort: undefined,
toolChoice: { type: "any" },
},
])("supports Claude Sonnet 5: $name", async ({ reasoning, thinking, effort, toolChoice }) => {
let capturedPayload: Record<string, unknown> | undefined;
const stream = streamSimpleAnthropic(
makeAnthropicModel({
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
maxTokens: 128_000,
}),
makeSonnet5PrefillContext(),
{
apiKey: "sk-ant-provider",
reasoning,
temperature: 0.2,
toolChoice: "any",
onPayload: (payload) => {
capturedPayload = payload as unknown as Record<string, unknown>;
throw new Error("stop before network");
},
},
);
await stream.result();
expect(capturedPayload).toMatchObject({
max_tokens: 128_000,
messages: [{ role: "user" }],
thinking,
tool_choice: toolChoice,
});
expect(capturedPayload).not.toHaveProperty("temperature");
if (effort) {
expect(capturedPayload).toMatchObject({ output_config: effort });
} else {
expect(capturedPayload).not.toHaveProperty("output_config");
}
});
});
+50 -18
View File
@@ -46,12 +46,15 @@ import {
usesFoundryBearerAuth,
} from "./anthropic-auth-headers.js";
import {
prepareClaudeSonnet5RequestContext,
resolveClaudeNativeThinkingLevelMap,
resolveClaudeSonnet5ModelIdentity,
requiresClaudeAdaptiveThinking,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
usesClaudeFable5MessagesContract,
usesClaudeStreamingRefusalContract,
} from "./anthropic-model-contract.js";
import { applyAnthropicRefusal } from "./anthropic-refusal.js";
import {
@@ -493,6 +496,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
options?: AnthropicOptions,
) => {
const stream = new AssistantMessageEventStream();
const requestContext = prepareClaudeSonnet5RequestContext(model, context);
void (async () => {
const output: AssistantMessage = {
@@ -512,9 +516,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
stopReason: "stop",
timestamp: Date.now(),
};
// Fable classifiers can refuse after partial generation, so no event is
// safe to expose until the terminal stop reason is known.
const refusalBuffer = usesClaudeFable5MessagesContract(model)
// Classifier refusals can invalidate partial output, so no event is safe
// to expose until the terminal stop reason is known.
const refusalBuffer = usesClaudeStreamingRefusalContract(model)
? createDeferredEventBuffer<AssistantMessageEvent>(stream, () =>
notifyLlmRequestActivity(options?.signal),
)
@@ -541,9 +545,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
let copilotDynamicHeaders: Record<string, string> | undefined;
if (model.provider === "github-copilot") {
const hasImages = hasCopilotVisionInput(context.messages);
const hasImages = hasCopilotVisionInput(requestContext.messages);
copilotDynamicHeaders = buildCopilotDynamicHeaders({
messages: context.messages,
messages: requestContext.messages,
hasImages,
});
}
@@ -555,7 +559,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
model,
apiKey,
options?.interleavedThinking ?? true,
shouldUseFineGrainedToolStreamingBeta(model, context),
shouldUseFineGrainedToolStreamingBeta(model, requestContext),
options?.headers,
copilotDynamicHeaders,
cacheSessionId,
@@ -564,7 +568,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
isOAuth = created.isOAuthToken;
serverSideFallback = created.serverSideFallback;
}
const builtParams = buildParams(model, context, isOAuth, options, serverSideFallback);
const builtParams = buildParams(model, requestContext, isOAuth, options, serverSideFallback);
let params = builtParams.params;
const toolProjection = builtParams.toolProjection;
const nextParams = await options?.onPayload?.(params, model);
@@ -923,11 +927,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti
};
function normalizeAnthropicToolChoice(
model: Model<"anthropic-messages">,
thinkingEnabled: boolean,
toolChoice: NonNullable<AnthropicOptions["toolChoice"]>,
): AnthropicProjectedToolChoice {
if (
requiresClaudeAdaptiveThinking(model) &&
thinkingEnabled &&
(toolChoice === "any" || (typeof toolChoice === "object" && toolChoice.type === "tool"))
) {
return { type: "auto" as const };
@@ -988,19 +992,44 @@ function mapThinkingLevelToEffort(
}
}
export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions> = (
type AnthropicSimpleStreamOptions = SimpleStreamOptions & {
toolChoice?: AnthropicOptions["toolChoice"];
};
export const streamSimpleAnthropic: StreamFunction<
"anthropic-messages",
AnthropicSimpleStreamOptions
> = (
model: Model<"anthropic-messages">,
context: Context,
options?: SimpleStreamOptions,
options?: AnthropicSimpleStreamOptions,
) => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, options, apiKey);
if (!options?.reasoning) {
const mandatoryAdaptiveThinking = requiresClaudeAdaptiveThinking(model);
const base = {
...buildBaseOptions(model, options, apiKey),
toolChoice: options?.toolChoice,
};
const mandatoryAdaptiveThinking = requiresClaudeAdaptiveThinking(model);
const fable5 = usesClaudeFable5MessagesContract(model);
if (options?.reasoning === "off" && !mandatoryAdaptiveThinking) {
return streamAnthropic(model, context, {
...base,
thinkingEnabled: false,
} satisfies AnthropicOptions);
}
const reasoning = options?.reasoning === "off" ? (fable5 ? "low" : "high") : options?.reasoning;
if (resolveClaudeSonnet5ModelIdentity(model)) {
return streamAnthropic(model, context, {
...base,
thinkingEnabled: true,
effort: mapThinkingLevelToEffort(model, reasoning ?? "high"),
} satisfies AnthropicOptions);
}
if (!reasoning) {
return streamAnthropic(model, context, {
...base,
thinkingEnabled: mandatoryAdaptiveThinking,
@@ -1011,7 +1040,7 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS
// For Opus 4.6 and Sonnet 4.6: use adaptive thinking with effort level
// For older models: use budget-based thinking
if (supportsAdaptiveThinking(model)) {
const effort = mapThinkingLevelToEffort(model, options.reasoning);
const effort = mapThinkingLevelToEffort(model, reasoning);
return streamAnthropic(model, context, {
...base,
thinkingEnabled: true,
@@ -1024,8 +1053,8 @@ export const streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleS
const adjusted = adjustMaxTokensForThinking(
base.maxTokens,
model.maxTokens,
options.reasoning,
options.thinkingBudgets,
reasoning,
options?.thinkingBudgets,
);
return streamAnthropic(model, context, {
@@ -1317,7 +1346,10 @@ function buildParams(
}
if (options?.toolChoice) {
const normalizedToolChoice = normalizeAnthropicToolChoice(model, options.toolChoice);
const normalizedToolChoice = normalizeAnthropicToolChoice(
replayThinkingEnabled,
options.toolChoice,
);
const projectedToolChoice = toolProjection
? reconcileAnthropicToolChoice(normalizedToolChoice, toolProjection)
: normalizedToolChoice;
+1 -1
View File
@@ -552,7 +552,7 @@ export function buildGoogleSimpleThinking<T extends GoogleApiType>(
useFlashLiteBudgets?: boolean;
},
): GoogleThinkingOptions {
if (!options?.reasoning) {
if (!options?.reasoning || options.reasoning === "off") {
return { enabled: false };
}
@@ -30,6 +30,20 @@ export const CLAUDE_FABLE_5_THINKING_PROFILE = {
preserveWhenCatalogReasoningFalse: true,
} as const;
export const CLAUDE_SONNET_5_THINKING_PROFILE = {
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "xhigh" },
{ id: "adaptive" },
{ id: "max" },
],
defaultLevel: "high",
} as const;
/** Resolve the canonical normalized Claude model id for one runtime model ref. */
export function resolveClaudeModelIdentity(ref: ClaudeModelRef): string {
const configuredCanonicalModelId =
@@ -51,10 +65,20 @@ export function resolveClaudeFable5ModelIdentity(ref: ClaudeModelRef): string |
return normalized.slice((match.index ?? 0) + (match[0].startsWith("-") ? 1 : 0));
}
/** Resolve Claude Sonnet 5 through direct ids, cloud ids, or deployment metadata. */
export function resolveClaudeSonnet5ModelIdentity(ref: ClaudeModelRef): string | undefined {
const normalized = resolveClaudeModelIdentity(ref);
const match = /(?:^|-)claude-sonnet-5(?=$|[^a-z0-9])/.exec(normalized);
if (!match) {
return undefined;
}
return normalized.slice((match.index ?? 0) + (match[0].startsWith("-") ? 1 : 0));
}
/** Return whether a Claude model supports adaptive thinking. */
export function supportsClaudeAdaptiveThinking(ref: ClaudeModelRef): boolean {
const modelId = resolveClaudeModelIdentity(ref);
return /(?:^|-)claude-(?:fable-5|mythos-preview|opus-4-(?:6|7|8)|sonnet-4-6)(?=$|[^a-z0-9])/.test(
return /(?:^|-)claude-(?:fable-5|mythos-preview|opus-4-(?:6|7|8)|sonnet-(?:5|4-6))(?=$|[^a-z0-9])/.test(
modelId,
);
}
@@ -62,13 +86,13 @@ export function supportsClaudeAdaptiveThinking(ref: ClaudeModelRef): boolean {
/** Return whether a Claude model supports native max effort. */
export function supportsClaudeNativeMaxEffort(ref: ClaudeModelRef): boolean {
const modelId = resolveClaudeModelIdentity(ref);
return /(?:^|-)claude-(?:fable-5|opus-4-(?:6|7|8)|sonnet-4-6)(?=$|[^a-z0-9])/.test(modelId);
return /(?:^|-)claude-(?:fable-5|opus-4-(?:6|7|8)|sonnet-(?:5|4-6))(?=$|[^a-z0-9])/.test(modelId);
}
/** Return whether a Claude model supports native xhigh effort. */
export function supportsClaudeNativeXhighEffort(ref: ClaudeModelRef): boolean {
const modelId = resolveClaudeModelIdentity(ref);
return /(?:^|-)claude-(?:fable-5|opus-4-(?:7|8))(?=$|[^a-z0-9])/.test(modelId);
return /(?:^|-)claude-(?:fable-5|opus-4-(?:7|8)|sonnet-5)(?=$|[^a-z0-9])/.test(modelId);
}
/**
+1 -1
View File
@@ -185,7 +185,7 @@ export type ProviderImagesOptions = ImagesOptions & Record<string, unknown>;
/** Unified text options used by simple completion helpers. */
export interface SimpleStreamOptions extends StreamOptions {
reasoning?: ThinkingLevel;
reasoning?: ModelThinkingLevel;
/** Custom token budgets for thinking levels (token-based providers only) */
thinkingBudgets?: ThinkingBudgets;
}
@@ -47,6 +47,18 @@ describe("provider model id policy normalization", () => {
expect(
normalizeConfiguredProviderCatalogModelId("anthropic", "anthropic/claude-haiku-4-5"),
).toBe("claude-haiku-4-5");
expect(normalizeStaticProviderModelIdWithPolicies("anthropic", "sonnet")).toBe(
"claude-sonnet-5",
);
expect(normalizeStaticProviderModelIdWithPolicies("anthropic", "sonnet-5")).toBe(
"claude-sonnet-5",
);
expect(normalizeStaticProviderModelIdWithPolicies("vercel-ai-gateway", "sonnet")).toBe(
"anthropic/claude-sonnet-4-6",
);
expect(normalizeStaticProviderModelIdWithPolicies("vercel-ai-gateway", "sonnet-5")).toBe(
"anthropic/claude-sonnet-5",
);
});
it("normalizes provider-prefixed native catalog refs without stripping catalog prefixes", () => {
@@ -139,6 +139,8 @@ export function normalizeBuiltInProviderModelId(provider: string, model: string)
"opus-4.8": "claude-opus-4-8",
opus: "claude-opus-4-8",
"opus-4.6": "claude-opus-4-6",
"sonnet-5": "claude-sonnet-5",
sonnet: "claude-sonnet-5",
"sonnet-4.6": "claude-sonnet-4-6",
};
const anthropicPrefix = "anthropic/";
@@ -151,6 +153,8 @@ export function normalizeBuiltInProviderModelId(provider: string, model: string)
if (normalizedProvider === "vercel-ai-gateway") {
const vercelAliases: Record<string, string> = {
"opus-4.6": "claude-opus-4-6",
"sonnet-5": "claude-sonnet-5",
sonnet: "claude-sonnet-4-6",
"sonnet-4.6": "claude-sonnet-4-6",
};
const aliased = vercelAliases[normalizeLowercaseStringOrEmpty(model)] ?? model;
+2 -2
View File
@@ -172,7 +172,7 @@ export const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"provider-auth": 20,
"provider-oauth-runtime": 2,
"provider-auth-login": 3,
"provider-model-shared": 29,
"provider-model-shared": 30,
"provider-stream-family": 40,
"provider-stream-shared": 29,
"provider-stream": 40,
@@ -195,7 +195,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10454,
10455,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
+140 -69
View File
@@ -173,6 +173,32 @@ function makeAnthropicTransportModel(
);
}
function makeSonnet5PrefillContext(): AnthropicStreamContext {
return {
messages: [
{ role: "user", content: "Return JSON." },
{
role: "assistant",
content: [{ type: "text", text: "{" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
},
],
tools: [{ name: "lookup", description: "Lookup", parameters: { type: "object" } }],
} as AnthropicStreamContext;
}
async function runTransportStream(
model: AnthropicMessagesModel,
context: AnthropicStreamContext,
@@ -1253,79 +1279,80 @@ describe("anthropic transport stream", () => {
expect(result.errorMessage).toBe("OpenClaw transport error: malformed_streaming_fragment");
});
it.each(["anthropic", "anthropic-vertex"])(
"surfaces structured Anthropic streaming refusals for %s",
async (provider) => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
{
type: "message_start",
message: { id: "msg_refusal", usage: { input_tokens: 3, output_tokens: 0 } },
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "discard this partial output" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: {
stop_reason: "refusal",
stop_details: {
type: "refusal",
category: "bio",
explanation: "This request is not allowed.",
},
it.each([
["claude-fable-5", "Claude Fable 5", "anthropic"],
["claude-sonnet-5", "Claude Sonnet 5", "anthropic"],
["claude-sonnet-5", "Claude Sonnet 5", "anthropic-vertex"],
])("surfaces structured %s streaming refusals for %s", async (id, name, provider) => {
guardedFetchMock.mockResolvedValueOnce(
createSseResponse([
{
type: "message_start",
message: { id: "msg_refusal", usage: { input_tokens: 3, output_tokens: 0 } },
},
{
type: "content_block_start",
index: 0,
content_block: { type: "text", text: "" },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "text_delta", text: "discard this partial output" },
},
{ type: "content_block_stop", index: 0 },
{
type: "message_delta",
delta: {
stop_reason: "refusal",
stop_details: {
type: "refusal",
category: "bio",
explanation: "This request is not allowed.",
},
usage: { input_tokens: 3, output_tokens: 2 },
},
{ type: "message_stop" },
]),
);
usage: { input_tokens: 3, output_tokens: 2 },
},
{ type: "message_stop" },
]),
);
const streamFn = createAnthropicMessagesTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
makeAnthropicTransportModel({
id: "claude-fable-5",
name: "Claude Fable 5",
provider,
}),
{ messages: [{ role: "user", content: "hello" }] } as AnthropicStreamContext,
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
),
);
const eventTypes: string[] = [];
for await (const event of stream as AsyncIterable<{ type: string }>) {
eventTypes.push(event.type);
}
const result = await stream.result();
expect(eventTypes).toEqual(["error"]);
expect(result.stopReason).toBe("error");
expect(result.content).toEqual([]);
expect(result.errorMessage).toBe(
"Anthropic refusal (category: bio): This request is not allowed.",
);
expect(result.usage).toMatchObject({ input: 3, output: 2 });
expect(result.diagnostics).toEqual([
expect.objectContaining({
type: "provider_refusal",
details: {
provider,
category: "bio",
explanation: "This request is not allowed.",
},
const streamFn = createAnthropicMessagesTransportStreamFn();
const stream = await Promise.resolve(
streamFn(
makeAnthropicTransportModel({
id,
name,
provider,
}),
]);
},
);
{ messages: [{ role: "user", content: "hello" }] } as AnthropicStreamContext,
{ apiKey: "sk-ant-api" } as AnthropicStreamOptions,
),
);
const eventTypes: string[] = [];
for await (const event of stream as AsyncIterable<{ type: string }>) {
eventTypes.push(event.type);
}
const result = await stream.result();
expect(eventTypes).toEqual(["error"]);
expect(result.stopReason).toBe("error");
expect(result.content).toEqual([]);
expect(result.errorMessage).toBe(
"Anthropic refusal (category: bio): This request is not allowed.",
);
expect(result.usage).toMatchObject({ input: 3, output: 2 });
expect(result.diagnostics).toEqual([
expect.objectContaining({
type: "provider_refusal",
details: {
provider,
category: "bio",
explanation: "This request is not allowed.",
},
}),
]);
});
it("discards buffered Fable output when the transport ends before terminal status", async () => {
guardedFetchMock.mockResolvedValueOnce(
@@ -3399,6 +3426,50 @@ describe("anthropic transport stream", () => {
},
);
it.each([
{
name: "defaults to adaptive high",
reasoning: undefined,
thinking: { type: "adaptive", display: "summarized" },
effort: { effort: "high" },
toolChoice: { type: "auto" },
},
{
name: "allows explicit off",
reasoning: "off" as const,
thinking: { type: "disabled" },
effort: undefined,
toolChoice: { type: "any" },
},
])("supports Claude Sonnet 5 transport: $name", async (testCase) => {
const model = makeAnthropicTransportModel({
id: "claude-sonnet-5",
name: "Claude Sonnet 5",
maxTokens: 128_000,
});
await runTransportStream(model, makeSonnet5PrefillContext(), {
apiKey: "sk-ant-api",
reasoning: testCase.reasoning,
temperature: 0.2,
toolChoice: "any",
} as AnthropicStreamOptions);
const payload = latestAnthropicRequest().payload;
expect(payload).toMatchObject({
max_tokens: 128_000,
messages: [{ role: "user" }],
thinking: testCase.thinking,
tool_choice: testCase.toolChoice,
});
expect(payload).not.toHaveProperty("temperature");
if (testCase.effort) {
expect(payload.output_config).toEqual(testCase.effort);
} else {
expect(payload).not.toHaveProperty("output_config");
}
});
it("uses always-on adaptive thinking for Claude Fable 5 transport runs", async () => {
const model = makeAnthropicTransportModel({
id: "prod-primary",
+46 -15
View File
@@ -4,13 +4,16 @@ import {
CLAUDE_FABLE_5_FALLBACK_MODEL_COST,
applyAnthropicFallbackBoundary,
buildAnthropicServerSideFallbacks,
defaultsClaudeAdaptiveThinking,
applyAnthropicRefusal,
findActiveAnthropicToolTurnAssistantIndex,
omitFoundryBearerCredentialHeaders,
prepareClaudeSonnet5RequestContext,
projectAnthropicTools,
reconcileAnthropicToolChoice,
requiresClaudeAdaptiveThinking,
resolveClaudeNativeThinkingLevelMap,
resolveClaudeSonnet5ModelIdentity,
resolveOriginalAnthropicToolName,
readAnthropicFallbackBoundary,
readAnthropicPromptUsageSnapshot,
@@ -20,6 +23,7 @@ import {
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
usesClaudeFable5MessagesContract,
usesClaudeStreamingRefusalContract,
usesFoundryBearerAuth,
type AnthropicOptions,
type AnthropicPromptUsageSnapshot,
@@ -179,11 +183,11 @@ type MutableAssistantOutput = {
const EMPTY_ANTHROPIC_MESSAGES_FALLBACK_TEXT = ".";
function normalizeAnthropicToolChoice(
model: AnthropicTransportModel,
thinkingEnabled: boolean,
toolChoice: NonNullable<AnthropicTransportOptions["toolChoice"]>,
): AnthropicProjectedToolChoice {
if (
requiresClaudeAdaptiveThinking(model) &&
thinkingEnabled &&
(toolChoice === "any" || (typeof toolChoice === "object" && toolChoice.type === "tool"))
) {
return { type: "auto" as const };
@@ -245,13 +249,18 @@ function resolvePositiveAnthropicMaxTokens(value: unknown): number | undefined {
function resolveAnthropicMessagesMaxTokens(params: {
modelMaxTokens: number | undefined;
requestedMaxTokens: number | undefined;
useModelDefault?: boolean;
}): number | undefined {
const requested = resolvePositiveAnthropicMaxTokens(params.requestedMaxTokens);
if (requested !== undefined) {
return requested;
}
const modelMax = resolvePositiveAnthropicMaxTokens(params.modelMaxTokens);
return modelMax !== undefined ? Math.min(modelMax, 32_000) : undefined;
return modelMax !== undefined
? params.useModelDefault
? modelMax
: Math.min(modelMax, 32_000)
: undefined;
}
function adjustMaxTokensForThinking(params: {
@@ -1079,7 +1088,10 @@ function buildAnthropicParams(
params.metadata = { user_id: options.metadata.user_id };
}
if (options?.toolChoice) {
const normalizedToolChoice = normalizeAnthropicToolChoice(model, options.toolChoice);
const normalizedToolChoice = normalizeAnthropicToolChoice(
replayThinkingEnabled,
options.toolChoice,
);
const projectedToolChoice = toolProjection
? reconcileAnthropicToolChoice(normalizedToolChoice, toolProjection)
: normalizedToolChoice;
@@ -1099,6 +1111,7 @@ function resolveAnthropicTransportOptions(
const baseMaxTokens = resolveAnthropicMessagesMaxTokens({
modelMaxTokens: model.maxTokens,
requestedMaxTokens: options?.maxTokens,
useModelDefault: resolveClaudeSonnet5ModelIdentity(model) !== undefined,
});
if (baseMaxTokens === undefined) {
throw new Error(
@@ -1107,6 +1120,14 @@ function resolveAnthropicTransportOptions(
}
const reasoningModelMaxTokens =
resolvePositiveAnthropicMaxTokens(model.maxTokens) ?? baseMaxTokens;
const mandatoryAdaptiveThinking = requiresClaudeAdaptiveThinking(model);
const fable5 = usesClaudeFable5MessagesContract(model);
const reasoning =
options?.reasoning === "off" && mandatoryAdaptiveThinking
? fable5
? "low"
: "high"
: options?.reasoning;
const resolved: AnthropicTransportOptions = {
temperature: options?.temperature,
stop: options?.stop,
@@ -1122,10 +1143,14 @@ function resolveAnthropicTransportOptions(
interleavedThinking: options?.interleavedThinking,
toolChoice: options?.toolChoice,
thinkingBudgets: options?.thinkingBudgets,
reasoning: options?.reasoning,
reasoning,
};
if (!options?.reasoning) {
resolved.thinkingEnabled = requiresClaudeAdaptiveThinking(model);
if (reasoning === "off") {
resolved.thinkingEnabled = false;
return resolved;
}
if (!reasoning) {
resolved.thinkingEnabled = defaultsClaudeAdaptiveThinking(model);
if (resolved.thinkingEnabled) {
resolved.effort = "high";
}
@@ -1133,7 +1158,7 @@ function resolveAnthropicTransportOptions(
}
if (supportsAdaptiveThinking(model)) {
resolved.thinkingEnabled = true;
resolved.effort = mapThinkingLevelToEffort(options.reasoning, model) as NonNullable<
resolved.effort = mapThinkingLevelToEffort(reasoning, model) as NonNullable<
AnthropicOptions["effort"]
>;
return resolved;
@@ -1141,8 +1166,8 @@ function resolveAnthropicTransportOptions(
const adjusted = adjustMaxTokensForThinking({
baseMaxTokens,
modelMaxTokens: reasoningModelMaxTokens,
reasoningLevel: options.reasoning,
customBudgets: options.thinkingBudgets,
reasoningLevel: reasoning,
customBudgets: options?.thinkingBudgets,
});
resolved.maxTokens = adjusted.maxTokens;
resolved.thinkingEnabled = true;
@@ -1167,9 +1192,9 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
stopReason: "stop",
timestamp: Date.now(),
};
// Fable classifiers can refuse after partial generation, so no event is
// safe to expose until the terminal stop reason is known.
const refusalBuffer = usesClaudeFable5MessagesContract(model)
// Classifier refusals can invalidate partial output, so no event is safe
// to expose until the terminal stop reason is known.
const refusalBuffer = usesClaudeStreamingRefusalContract(model)
? createDeferredEventBuffer<unknown>(stream, () =>
notifyLlmRequestActivity(options?.signal),
)
@@ -1185,13 +1210,19 @@ export function createAnthropicMessagesTransportStreamFn(): StreamFn {
throw new Error(`No API key for provider: ${model.provider}`);
}
const transportOptions = resolveAnthropicTransportOptions(model, options, apiKey);
const requestContext = prepareClaudeSonnet5RequestContext(model, context);
const { client, isOAuthToken } = createAnthropicTransportClient({
model,
context,
context: requestContext,
apiKey,
options: transportOptions,
});
const builtParams = buildAnthropicParams(model, context, isOAuthToken, transportOptions);
const builtParams = buildAnthropicParams(
model,
requestContext,
isOAuthToken,
transportOptions,
);
let params = builtParams.params;
const toolProjection = builtParams.toolProjection;
const nextParams = await transportOptions.onPayload?.(params, model);
+2 -1
View File
@@ -246,7 +246,8 @@ const CLAUDE_CLI_CONTEXT_MODEL_ALIASES: Record<string, string> = {
"opus-4-7": "claude-opus-4-7",
"opus-4.6": "claude-opus-4-6",
"opus-4-6": "claude-opus-4-6",
sonnet: "claude-sonnet-4-6",
sonnet: "claude-sonnet-5",
"sonnet-5": "claude-sonnet-5",
"sonnet-4.6": "claude-sonnet-4-6",
"sonnet-4-6": "claude-sonnet-4-6",
};
+9 -2
View File
@@ -1,3 +1,4 @@
import { resolveClaudeSonnet5ModelIdentity } from "@openclaw/llm-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -43,6 +44,7 @@ const ANTHROPIC_GA_1M_MODEL_PREFIXES = [
export const ANTHROPIC_CONTEXT_1M_TOKENS = 1_048_576;
export const ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS = 1_000_000;
export const ANTHROPIC_FABLE_CONTEXT_TOKENS = 1_000_000;
export const ANTHROPIC_SONNET_5_CONTEXT_TOKENS = 1_000_000;
type ConfiguredContextTokens = {
value: number;
@@ -147,14 +149,19 @@ export function resolveAnthropicFixedContextWindow(
model: string,
): number | undefined {
const modelId = resolveModelFamilyId(model);
const isAnthropicProvider =
provider === "anthropic" || provider === "anthropic-vertex" || provider === "claude-cli";
if (!isAnthropicProvider) {
return undefined;
}
if (
(provider === "anthropic" || provider === "anthropic-vertex") &&
/^claude-fable-5(?=$|[^a-z0-9])/.test(modelId)
) {
return ANTHROPIC_FABLE_CONTEXT_TOKENS;
}
if (provider !== "anthropic" && provider !== "anthropic-vertex" && provider !== "claude-cli") {
return undefined;
if (resolveClaudeSonnet5ModelIdentity({ id: modelId })) {
return ANTHROPIC_SONNET_5_CONTEXT_TOKENS;
}
if (!ANTHROPIC_GA_1M_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix))) {
return undefined;
+4
View File
@@ -11,6 +11,7 @@ import {
import {
ANTHROPIC_CONTEXT_1M_TOKENS,
ANTHROPIC_FABLE_CONTEXT_TOKENS,
ANTHROPIC_SONNET_5_CONTEXT_TOKENS,
ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS,
applyConfiguredContextWindows,
applyDiscoveredContextWindows,
@@ -418,6 +419,9 @@ describe("resolveContextTokensForModel", () => {
it.each([
["anthropic", "claude-fable-5", ANTHROPIC_FABLE_CONTEXT_TOKENS],
["anthropic-vertex", "claude-fable-5", ANTHROPIC_FABLE_CONTEXT_TOKENS],
["anthropic", "claude-sonnet-5", ANTHROPIC_SONNET_5_CONTEXT_TOKENS],
["anthropic-vertex", "claude-sonnet-5", ANTHROPIC_SONNET_5_CONTEXT_TOKENS],
["claude-cli", "claude-sonnet-5", ANTHROPIC_SONNET_5_CONTEXT_TOKENS],
["anthropic", "claude-sonnet-4-6", ANTHROPIC_CONTEXT_1M_TOKENS],
["anthropic-vertex", "claude-sonnet-4-6", ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS],
])(
+1
View File
@@ -30,6 +30,7 @@ import { normalizeProviderId } from "./model-selection.js";
export {
ANTHROPIC_CONTEXT_1M_TOKENS,
ANTHROPIC_FABLE_CONTEXT_TOKENS,
ANTHROPIC_SONNET_5_CONTEXT_TOKENS,
ANTHROPIC_VERTEX_CONTEXT_1M_TOKENS,
} from "./context-resolution.js";
export { resetContextWindowCacheForTest } from "./context-runtime-state.js";
+1
View File
@@ -17,6 +17,7 @@ type ModelRef = {
const HIGH_SIGNAL_LIVE_MODEL_PRIORITY = [
"anthropic/claude-opus-4-8",
"anthropic/claude-sonnet-5",
"anthropic/claude-sonnet-4-6",
"anthropic/claude-opus-4-7",
"google/gemini-3.1-pro-preview",
+1
View File
@@ -661,6 +661,7 @@ describe("isPrioritizedHighSignalLiveModelRef", () => {
it("lists priority refs as provider/id pairs", () => {
expect(listPrioritizedHighSignalLiveModelRefs()).toStrictEqual([
{ provider: "anthropic", id: "claude-opus-4-8" },
{ provider: "anthropic", id: "claude-sonnet-5" },
{ provider: "anthropic", id: "claude-sonnet-4-6" },
{ provider: "anthropic", id: "claude-opus-4-7" },
{ provider: "google", id: "gemini-3.1-pro-preview" },
@@ -767,4 +767,51 @@ describe("completeWithPreparedSimpleCompletionModel", () => {
},
);
});
it("preserves explicit off for a prepared Claude Sonnet 5 alias", async () => {
const model = {
provider: "anthropic",
id: "production-sonnet",
name: "Production Sonnet",
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text", "image"],
cost: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
contextWindow: 1_000_000,
maxTokens: 128_000,
params: { canonicalModelId: "claude-sonnet-5" },
} satisfies Model<"anthropic-messages">;
const preparedModel = {
...model,
api: "openclaw-provider-simple:anthropic:production-sonnet",
} satisfies Model;
hoisted.prepareModelForSimpleCompletionMock.mockReturnValueOnce(preparedModel);
await completeWithPreparedSimpleCompletionModel({
model,
auth: {
apiKey: "sk-test",
source: "env:ANTHROPIC_API_KEY",
mode: "api-key",
},
context: {
messages: [{ role: "user", content: "pong", timestamp: 1 }],
},
options: {
reasoning: "off",
},
});
expect(hoisted.completeMock).toHaveBeenCalledWith(
preparedModel,
{
messages: [{ role: "user", content: "pong", timestamp: 1 }],
},
{
reasoning: "off",
apiKey: "sk-test",
},
);
});
});
+5 -2
View File
@@ -1,4 +1,5 @@
import { supportsOpenAIReasoningEffort } from "@openclaw/ai/internal/openai";
import { resolveClaudeSonnet5ModelIdentity } from "@openclaw/llm-core";
/**
* Simple completion runtime preparation.
*
@@ -11,6 +12,7 @@ import { completeSimple } from "../llm/stream.js";
import type {
AssistantMessage,
Model,
ModelThinkingLevel,
ThinkingLevel as SimpleCompletionThinkingLevel,
} from "../llm/types.js";
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.runtime.js";
@@ -373,11 +375,12 @@ export async function completeWithPreparedSimpleCompletionModel(params: {
function normalizeSimpleCompletionReasoning(
reasoning: SimpleCompletionModelOptions["reasoning"],
model: Model,
): SimpleCompletionThinkingLevel | undefined {
): ModelThinkingLevel | undefined {
switch (reasoning) {
case undefined:
case "off":
return undefined;
case "off":
return resolveClaudeSonnet5ModelIdentity(model) ? "off" : undefined;
case "adaptive":
return "medium";
case "max":
+5 -2
View File
@@ -171,12 +171,15 @@ function catalogProbePriority(provider: string, modelId: string): number {
if (id === "claude-haiku-4-5") {
return 1;
}
if (id === "claude-sonnet-4-6" || id.startsWith("claude-sonnet-4-6-")) {
if (id === "claude-sonnet-5" || id.startsWith("claude-sonnet-5-")) {
return 2;
}
if (id.startsWith("claude-sonnet-4-")) {
if (id === "claude-sonnet-4-6" || id.startsWith("claude-sonnet-4-6-")) {
return 3;
}
if (id.startsWith("claude-sonnet-4-")) {
return 4;
}
if (id.startsWith("claude-3-")) {
return 100;
}
+11 -1
View File
@@ -5,6 +5,7 @@ import {
normalizeConfiguredProviderCatalogModelId,
} from "@openclaw/model-catalog-core/provider-model-id-normalization";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import {
@@ -33,7 +34,7 @@ const defaultWarnState: WarnState = { warned: false };
export const DEFAULT_MODEL_ALIASES: Readonly<Record<string, string>> = {
// Anthropic (shared model runtime catalog uses "latest" ids without date suffix)
opus: "anthropic/claude-opus-4-8",
sonnet: "anthropic/claude-sonnet-4-6",
sonnet: "anthropic/claude-sonnet-5",
// OpenAI
gpt: "openai/gpt-5.4",
@@ -352,6 +353,15 @@ export function applyModelDefaults(
if (entry.alias !== undefined) {
continue;
}
const normalizedAlias = normalizeLowercaseStringOrEmpty(alias);
const aliasAlreadyOwned = Object.entries(nextModels).some(
([modelRef, candidate]) =>
modelRef !== target && normalizeLowercaseStringOrEmpty(candidate.alias) === normalizedAlias,
);
// Preserve explicit alias ownership when a newer default target is also configured.
if (aliasAlreadyOwned) {
continue;
}
nextModels[target] = { ...entry, alias };
mutated = true;
}
+20
View File
@@ -146,6 +146,7 @@ describe("applyModelDefaults", () => {
defaults: {
models: {
"anthropic/claude-opus-4-8": {},
"anthropic/claude-sonnet-5": {},
"openai/gpt-5.4": {},
},
},
@@ -154,6 +155,7 @@ describe("applyModelDefaults", () => {
const next = applyModelDefaults(cfg);
expect(next.agents?.defaults?.models?.["anthropic/claude-opus-4-8"]?.alias).toBe("opus");
expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-5"]?.alias).toBe("sonnet");
expect(next.agents?.defaults?.models?.["openai/gpt-5.4"]?.alias).toBe("gpt");
});
@@ -173,6 +175,24 @@ describe("applyModelDefaults", () => {
expect(next.agents?.defaults?.models?.["anthropic/claude-opus-4-8"]?.alias).toBe("Opus");
});
it("preserves an authored Sonnet alias when the new default target is also present", () => {
const cfg = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
"anthropic/claude-sonnet-5": {},
},
},
},
} satisfies OpenClawConfig;
const next = applyModelDefaults(cfg);
expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]?.alias).toBe("Sonnet");
expect(next.agents?.defaults?.models?.["anthropic/claude-sonnet-5"]?.alias).toBeUndefined();
});
it("respects explicit empty alias disables", () => {
const cfg = {
agents: {
@@ -9,6 +9,7 @@ import {
OPENAI_COMPATIBLE_REPLAY_HOOKS,
PASSTHROUGH_GEMINI_REPLAY_HOOKS,
resolveClaudeFable5ModelIdentity,
resolveClaudeSonnet5ModelIdentity,
resolveClaudeThinkingProfile,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
@@ -45,6 +46,9 @@ describe("Claude model contracts", () => {
);
expect(supportsClaudeAdaptiveThinking({ id: "claude-sonnet-4-6@20260301" })).toBe(true);
expect(supportsClaudeNativeXhighEffort({ id: "claude-opus-4-8@20260401" })).toBe(true);
expect(resolveClaudeSonnet5ModelIdentity({ id: "claude-sonnet-5@20260701" })).toBe(
"claude-sonnet-5@20260701",
);
});
it("does not classify later numeric model versions as supported aliases", () => {
@@ -308,6 +312,12 @@ describe("buildProviderReplayFamilyHooks", () => {
});
describe("resolveClaudeThinkingProfile", () => {
it("defaults Sonnet 5 to high adaptive thinking with native effort levels", () => {
const profile = resolveClaudeThinkingProfile("claude-sonnet-5");
expectFields(profile, { defaultLevel: "high" });
expectLevelIdsInclude(profile, ["off", "xhigh", "adaptive", "max"]);
});
it("exposes Fable 5's always-adaptive profile to Claude providers", () => {
const profile = resolveClaudeThinkingProfile("claude-fable-5");
expectFields(profile, {
+1
View File
@@ -30,6 +30,7 @@ export {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeNativeThinkingLevelMap,
resolveClaudeSonnet5ModelIdentity,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
+5
View File
@@ -3,8 +3,10 @@
// `plugin-sdk/provider-model-shared`.
import {
CLAUDE_FABLE_5_THINKING_PROFILE,
CLAUDE_SONNET_5_THINKING_PROFILE,
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeSonnet5ModelIdentity,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeXhighEffort,
} from "@openclaw/llm-core";
@@ -39,6 +41,9 @@ export function resolveClaudeThinkingProfile(
if (resolveClaudeFable5ModelIdentity(ref)) {
return CLAUDE_FABLE_5_THINKING_PROFILE;
}
if (resolveClaudeSonnet5ModelIdentity(ref)) {
return CLAUDE_SONNET_5_THINKING_PROFILE;
}
if (supportsClaudeNativeXhighEffort(ref)) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "xhigh" }, { id: "adaptive" }, { id: "max" }],