mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
co-authored by
Peter Steinberger
Ariel Bravy
parent
93eaf5affb
commit
1f6ddb5df0
+2
-1
@@ -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 -->
|
||||
|
||||
@@ -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": {},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
])(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -30,6 +30,7 @@ export {
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
resolveClaudeModelIdentity,
|
||||
resolveClaudeNativeThinkingLevelMap,
|
||||
resolveClaudeSonnet5ModelIdentity,
|
||||
supportsClaudeAdaptiveThinking,
|
||||
supportsClaudeNativeMaxEffort,
|
||||
supportsClaudeNativeXhighEffort,
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
Reference in New Issue
Block a user