mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 05:56:11 +00:00
* fix(anthropic): resolve thinking as disabled when legacy budget is below 1024 When adjustMaxTokensForThinking collapses the thinking budget below the Anthropic minimum (1024), option resolution now sets thinkingEnabled to false instead of always forcing it to true. This keeps every downstream consumer (payload, replay, temperature, tool-choice) consistent — they all see the same disabled state instead of an enabled flag with a missing or API-rejected thinking block. || → ?? in both builders is defensive: the resolution layer already prevents invalid budgets from reaching the builder through the normal path, but ?? preserves an explicit zero when the builder is called directly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(anthropic): guard raw streamAnthropic builder path against sub-minimum budgets Add budget guards in both Anthropic payload builders so direct streamAnthropic and bundled-plugin callers (e.g. Mantle) that bypass option resolution also get the disabled-state rule instead of producing API-rejected { type: "enabled", budget_tokens: < 1024 } requests. Add proof-anthropic-thinking-budget.mts driving real production functions with terminal output and negative control. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(anthropic): normalize legacy thinking budgets Co-authored-by: Pick-cat <huang.ting3@xydigit.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
265 lines
9.0 KiB
TypeScript
265 lines
9.0 KiB
TypeScript
/**
|
|
* Anthropic Vertex stream runtime. It constructs Vertex SDK clients and adapts
|
|
* OpenClaw stream options for the shared Anthropic Messages transport.
|
|
*/
|
|
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
|
|
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
|
import {
|
|
clampThinkingLevel,
|
|
stream as streamDefault,
|
|
type Model,
|
|
type ModelThinkingLevel,
|
|
type ProviderStreamOptions,
|
|
} from "openclaw/plugin-sdk/llm";
|
|
import {
|
|
resolveClaudeFable5ModelIdentity,
|
|
resolveClaudeModelIdentity,
|
|
resolveClaudeMythos5ModelIdentity,
|
|
resolveClaudeSonnet5ModelIdentity,
|
|
requiresClaudeMandatoryAdaptiveThinking,
|
|
supportsClaudeAdaptiveThinking,
|
|
supportsClaudeNativeMaxEffort,
|
|
supportsClaudeNativeXhighEffort,
|
|
} from "openclaw/plugin-sdk/provider-model-shared";
|
|
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js";
|
|
|
|
type AnthropicVertexTransportOptions = ProviderStreamOptions & {
|
|
client?: unknown;
|
|
thinkingEnabled?: boolean;
|
|
thinkingBudgetTokens?: number;
|
|
effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
};
|
|
|
|
type AnthropicVertexEffort = NonNullable<AnthropicVertexTransportOptions["effort"]>;
|
|
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
|
|
type AnthropicVertexClientOptions = {
|
|
baseURL?: string;
|
|
projectId?: string;
|
|
region: string;
|
|
};
|
|
|
|
/** Injectable dependencies for Anthropic Vertex stream tests. */
|
|
export type AnthropicVertexStreamDeps = {
|
|
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;
|
|
streamAnthropic: typeof streamDefault;
|
|
};
|
|
|
|
const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = {
|
|
AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"],
|
|
streamAnthropic: streamDefault,
|
|
};
|
|
|
|
function isClaudeOpus47OrNewerModel(modelId: string): boolean {
|
|
return supportsClaudeNativeXhighEffort({ id: modelId });
|
|
}
|
|
|
|
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 resolveClaudeMythos5ModelIdentity({ id: modelId }) !== undefined;
|
|
}
|
|
|
|
function supportsAdaptiveThinking(modelId: string): boolean {
|
|
return supportsClaudeAdaptiveThinking({ id: modelId });
|
|
}
|
|
|
|
function mapAnthropicAdaptiveEffort(
|
|
reasoning: ModelThinkingLevel,
|
|
model: Model<"anthropic-messages">,
|
|
modelId: string,
|
|
): AnthropicVertexAdaptiveEffort {
|
|
const clampModel =
|
|
typeof model.params?.canonicalModelId === "string" ? { ...model, reasoning: true } : model;
|
|
const resolvedReasoning = clampThinkingLevel(clampModel, reasoning);
|
|
const mapped = model.thinkingLevelMap?.[resolvedReasoning];
|
|
if (typeof mapped === "string") {
|
|
return mapped as AnthropicVertexAdaptiveEffort;
|
|
}
|
|
const effortMap: Record<string, AnthropicVertexAdaptiveEffort> = {
|
|
off: "low",
|
|
minimal: "low",
|
|
low: "low",
|
|
medium: "medium",
|
|
high: "high",
|
|
xhigh: isClaudeFable5Model(modelId)
|
|
? "xhigh"
|
|
: isClaudeOpus47OrNewerModel(modelId) || isClaudeMythos5Model(modelId)
|
|
? "xhigh"
|
|
: "high",
|
|
max:
|
|
supportsClaudeNativeMaxEffort({ id: modelId }) || isClaudeMythos5Model(modelId)
|
|
? "max"
|
|
: "high",
|
|
};
|
|
return effortMap[resolvedReasoning] ?? "high";
|
|
}
|
|
|
|
function resolveAnthropicVertexMaxTokens(params: {
|
|
modelMaxTokens: number | undefined;
|
|
requestedMaxTokens: number | undefined;
|
|
}): number | undefined {
|
|
const modelMax =
|
|
typeof params.modelMaxTokens === "number" &&
|
|
Number.isFinite(params.modelMaxTokens) &&
|
|
params.modelMaxTokens > 0
|
|
? Math.floor(params.modelMaxTokens)
|
|
: undefined;
|
|
const requested =
|
|
typeof params.requestedMaxTokens === "number" &&
|
|
Number.isFinite(params.requestedMaxTokens) &&
|
|
params.requestedMaxTokens > 0
|
|
? Math.floor(params.requestedMaxTokens)
|
|
: undefined;
|
|
|
|
if (modelMax !== undefined && requested !== undefined) {
|
|
return Math.min(requested, modelMax);
|
|
}
|
|
return requested ?? modelMax;
|
|
}
|
|
|
|
/**
|
|
* Create a StreamFn that routes through OpenClaw's generic model stream with an
|
|
* injected `AnthropicVertex` client. All streaming, message conversion, and
|
|
* event handling is handled by the shared model runtime - we only supply the GCP-authenticated
|
|
* client and provider transport options.
|
|
*/
|
|
export function createAnthropicVertexStreamFn(
|
|
projectId: string | undefined,
|
|
region: string,
|
|
baseURL?: string,
|
|
deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps,
|
|
): StreamFn {
|
|
const client = new deps.AnthropicVertex({
|
|
region,
|
|
...(baseURL ? { baseURL } : {}),
|
|
...(projectId ? { projectId } : {}),
|
|
});
|
|
|
|
return (model, context, options) => {
|
|
// Simple completions use a synthetic registry API to select this plugin.
|
|
// The shared Anthropic transport must receive its canonical API or it recurses.
|
|
const transportModel = (
|
|
model.api === "anthropic-messages" ? model : { ...model, api: "anthropic-messages" as const }
|
|
) as Model<"anthropic-messages"> & {
|
|
baseUrl?: string;
|
|
provider: string;
|
|
};
|
|
const maxTokens = resolveAnthropicVertexMaxTokens({
|
|
modelMaxTokens: transportModel.maxTokens,
|
|
requestedMaxTokens: options?.maxTokens,
|
|
});
|
|
const contractModelId = resolveClaudeModelIdentity(model);
|
|
const sonnet5 = isClaudeSonnet5Model(contractModelId);
|
|
const mandatoryAdaptiveThinking = requiresClaudeMandatoryAdaptiveThinking({
|
|
id: contractModelId,
|
|
});
|
|
const requestedReasoning = options?.reasoning;
|
|
const reasoning =
|
|
requestedReasoning === "off" && mandatoryAdaptiveThinking
|
|
? "low"
|
|
: (requestedReasoning ?? (mandatoryAdaptiveThinking || sonnet5 ? "high" : undefined));
|
|
const adaptiveThinking =
|
|
mandatoryAdaptiveThinking ||
|
|
Boolean(reasoning && reasoning !== "off" && supportsAdaptiveThinking(contractModelId));
|
|
const temperature =
|
|
adaptiveThinking ||
|
|
isClaudeOpus47OrNewerModel(contractModelId) ||
|
|
isClaudeMythos5Model(contractModelId)
|
|
? undefined
|
|
: options?.temperature;
|
|
const opts: AnthropicVertexTransportOptions = {
|
|
client,
|
|
...(temperature !== undefined ? { temperature } : {}),
|
|
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
signal: options?.signal,
|
|
cacheRetention: options?.cacheRetention,
|
|
sessionId: options?.sessionId,
|
|
headers: options?.headers,
|
|
// The shared anthropic-messages transport already splits the system prompt
|
|
// cache boundary and budgets all cache_control markers; re-applying the
|
|
// payload policy here marked the uncached suffix and breached the 4-marker cap.
|
|
onPayload: options?.onPayload,
|
|
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
metadata: options?.metadata,
|
|
};
|
|
|
|
if (reasoning === "off") {
|
|
opts.thinkingEnabled = false;
|
|
} else if (reasoning) {
|
|
if (supportsAdaptiveThinking(contractModelId)) {
|
|
opts.thinkingEnabled = true;
|
|
opts.effort = mapAnthropicAdaptiveEffort(
|
|
reasoning,
|
|
transportModel,
|
|
contractModelId,
|
|
) as AnthropicVertexEffort;
|
|
} else {
|
|
const budgets = options?.thinkingBudgets;
|
|
const thinkingBudgetTokens =
|
|
(budgets && reasoning in budgets
|
|
? budgets[reasoning as keyof typeof budgets]
|
|
: undefined) ?? 10000;
|
|
const requestMaxTokens = opts.maxTokens ?? transportModel.maxTokens;
|
|
opts.thinkingEnabled =
|
|
thinkingBudgetTokens >= 1024 && thinkingBudgetTokens < requestMaxTokens;
|
|
if (opts.thinkingEnabled) {
|
|
opts.thinkingBudgetTokens = thinkingBudgetTokens;
|
|
}
|
|
}
|
|
} else if (mandatoryAdaptiveThinking) {
|
|
opts.thinkingEnabled = true;
|
|
opts.effort = "high";
|
|
} else {
|
|
opts.thinkingEnabled = false;
|
|
}
|
|
|
|
return deps.streamAnthropic(transportModel, context, opts);
|
|
};
|
|
}
|
|
|
|
function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined {
|
|
const trimmed = baseUrl?.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(trimmed);
|
|
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
|
if (!normalizedPath || normalizedPath === "") {
|
|
url.pathname = "/v1";
|
|
return url.toString().replace(/\/$/, "");
|
|
}
|
|
if (!normalizedPath.endsWith("/v1")) {
|
|
url.pathname = `${normalizedPath}/v1`;
|
|
return url.toString().replace(/\/$/, "");
|
|
}
|
|
return trimmed;
|
|
} catch {
|
|
return trimmed;
|
|
}
|
|
}
|
|
|
|
/** Create an Anthropic Vertex stream function from model metadata and env. */
|
|
export function createAnthropicVertexStreamFnForModel(
|
|
model: { baseUrl?: string },
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
deps?: AnthropicVertexStreamDeps,
|
|
): StreamFn {
|
|
return createAnthropicVertexStreamFn(
|
|
resolveAnthropicVertexProjectId(env),
|
|
resolveAnthropicVertexClientRegion({
|
|
baseUrl: model.baseUrl,
|
|
env,
|
|
}),
|
|
resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
|
|
deps,
|
|
);
|
|
}
|