mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 23:41:34 +00:00
* feat(anthropic): add Claude Opus 5 model support Wire claude-opus-5 through the Claude 5 contract seams: adaptive-by-default thinking with the full low..max effort range, default-sampling and prefill stripping, streaming refusal contract, model-bound thinking replay, and 1M/128k catalog metadata across anthropic, claude-cli, Vertex, Bedrock, and Mantle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(anthropic-vertex): normalize explicit Vertex Opus 5 model rows Extend normalizeAnthropicVertexResolvedModel so user-configured Vertex Opus 5 rows regain reasoning, image input, 1M/128k limits, and the native thinking map; update the live-model priority expectation and regenerate docs_map. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
312 lines
11 KiB
TypeScript
312 lines
11 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 { GoogleAuth, type GoogleAuthOptions } from "google-auth-library";
|
|
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,
|
|
resolveClaudeOpus5ModelIdentity,
|
|
resolveClaudeSonnet5ModelIdentity,
|
|
requiresClaudeMandatoryAdaptiveThinking,
|
|
supportsClaudeAdaptiveThinking,
|
|
supportsClaudeNativeMaxEffort,
|
|
supportsClaudeNativeXhighEffort,
|
|
} from "openclaw/plugin-sdk/provider-model-shared";
|
|
import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici";
|
|
import {
|
|
resolveAnthropicVertexAdcCredentials,
|
|
resolveAnthropicVertexClientRegion,
|
|
resolveAnthropicVertexProjectId,
|
|
} from "./region.js";
|
|
|
|
const GOOGLE_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
|
|
// Proxy settings are process-stable. Reuse one dispatcher so auth requests do
|
|
// not leak sockets while avoiding gaxios's broken node-fetch dynamic import.
|
|
let googleAuthDispatcher: EnvHttpProxyAgent | undefined;
|
|
|
|
const googleAuthFetch: typeof globalThis.fetch = (input, init) => {
|
|
googleAuthDispatcher ??= new EnvHttpProxyAgent();
|
|
const fetchInit = { ...init } as Parameters<typeof undiciFetch>[1] & { agent?: unknown };
|
|
delete fetchInit.agent;
|
|
fetchInit.dispatcher = googleAuthDispatcher;
|
|
return undiciFetch(
|
|
input as Parameters<typeof undiciFetch>[0],
|
|
fetchInit,
|
|
) as unknown as ReturnType<typeof globalThis.fetch>;
|
|
};
|
|
|
|
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;
|
|
googleAuth: GoogleAuth;
|
|
projectId?: string;
|
|
region: string;
|
|
};
|
|
|
|
/** Injectable dependencies for Anthropic Vertex stream tests. */
|
|
export type AnthropicVertexStreamDeps = {
|
|
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;
|
|
GoogleAuth: new (options?: GoogleAuthOptions) => GoogleAuth;
|
|
streamAnthropic: typeof streamDefault;
|
|
};
|
|
|
|
const defaultAnthropicVertexStreamDeps: AnthropicVertexStreamDeps = {
|
|
AnthropicVertex: AnthropicVertexSdk as AnthropicVertexStreamDeps["AnthropicVertex"],
|
|
GoogleAuth,
|
|
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 isClaudeOpus5Model(modelId: string): boolean {
|
|
return resolveClaudeOpus5ModelIdentity({ 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,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): StreamFn {
|
|
// GoogleAuth carries clientOptions into file-backed ADC clients. Keep the
|
|
// proxy-aware transport provider-local; a window shim changes detection globally.
|
|
const adcConfig = resolveAnthropicVertexAdcCredentials(env);
|
|
const googleAuth = new deps.GoogleAuth({
|
|
scopes: [GOOGLE_CLOUD_PLATFORM_SCOPE],
|
|
...(adcConfig ? { credentials: adcConfig } : {}),
|
|
clientOptions: {
|
|
transporterOptions: { fetchImplementation: googleAuthFetch },
|
|
},
|
|
});
|
|
const client = new deps.AnthropicVertex({
|
|
googleAuth,
|
|
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);
|
|
// Sonnet 5 and Opus 5 default thinking on when the caller omits reasoning.
|
|
const adaptiveDefaultClaude5 =
|
|
isClaudeSonnet5Model(contractModelId) || isClaudeOpus5Model(contractModelId);
|
|
const mandatoryAdaptiveThinking = requiresClaudeMandatoryAdaptiveThinking({
|
|
id: contractModelId,
|
|
});
|
|
const requestedReasoning = options?.reasoning;
|
|
const reasoning =
|
|
requestedReasoning === "off" && mandatoryAdaptiveThinking
|
|
? "low"
|
|
: (requestedReasoning ??
|
|
(mandatoryAdaptiveThinking || adaptiveDefaultClaude5 ? "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,
|
|
env,
|
|
);
|
|
}
|