Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
15
extensions/anthropic/api.ts
Normal file
15
extensions/anthropic/api.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Public Anthropic provider API barrel. It exposes provider construction,
|
||||
* Claude CLI helpers, and stream wrappers for config/runtime consumers.
|
||||
*/
|
||||
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
|
||||
export { buildAnthropicProvider } from "./register.runtime.js";
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
wrapAnthropicProviderStream,
|
||||
} from "./stream-wrappers.js";
|
||||
233
extensions/anthropic/claude-model-refs.ts
Normal file
233
extensions/anthropic/claude-model-refs.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Claude CLI model-ref normalization. It maps family aliases and retired model
|
||||
* ids to current Anthropic runtime refs while preserving auth-profile suffixes.
|
||||
*/
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_MODEL_ALIASES } from "./cli-constants.js";
|
||||
|
||||
const DEFAULT_CLAUDE_MODEL_BY_FAMILY: Record<string, string> = {
|
||||
opus: "claude-opus-4-8",
|
||||
sonnet: "claude-sonnet-4-6",
|
||||
haiku: "claude-haiku-4-5",
|
||||
};
|
||||
|
||||
/** Normalized Claude CLI selection plus runtime refs used by setup migrations. */
|
||||
export type ClaudeCliAnthropicModelRefs = {
|
||||
selectedRef: string;
|
||||
runtimeRefs: string[];
|
||||
rewriteRef?: string;
|
||||
};
|
||||
|
||||
function splitTrailingModelAuthProfile(raw: string): { model: string; profile?: string } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { model: "" };
|
||||
}
|
||||
const lastSlash = trimmed.lastIndexOf("/");
|
||||
let delimiter = trimmed.indexOf("@", lastSlash + 1);
|
||||
if (delimiter <= 0) {
|
||||
return { model: trimmed };
|
||||
}
|
||||
if (/^\d{8}(?:@|$)/.test(trimmed.slice(delimiter + 1))) {
|
||||
const nextDelimiter = trimmed.indexOf("@", delimiter + 9);
|
||||
if (nextDelimiter < 0) {
|
||||
return { model: trimmed };
|
||||
}
|
||||
delimiter = nextDelimiter;
|
||||
}
|
||||
const model = trimmed.slice(0, delimiter).trim();
|
||||
const profile = trimmed.slice(delimiter + 1).trim();
|
||||
return model && profile ? { model, profile } : { model: trimmed };
|
||||
}
|
||||
|
||||
function attachModelAuthProfile(model: string, profile?: string): string {
|
||||
return profile ? `${model}@${profile}` : model;
|
||||
}
|
||||
|
||||
function hasRetiredVersionPrefix(normalized: string, prefix: string): boolean {
|
||||
if (normalized === prefix) {
|
||||
return true;
|
||||
}
|
||||
if (!normalized.startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
const next = normalized[prefix.length];
|
||||
return next === "-" || next === "." || next === ":" || next === "@";
|
||||
}
|
||||
|
||||
function hasAnyRetiredVersionPrefix(normalized: string, prefixes: readonly string[]): boolean {
|
||||
return prefixes.some((prefix) => hasRetiredVersionPrefix(normalized, prefix));
|
||||
}
|
||||
|
||||
function parseProviderModelRef(
|
||||
raw: string,
|
||||
defaultProvider: string,
|
||||
): { provider: string; model: string; explicitProvider: boolean } | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0) {
|
||||
return { provider: defaultProvider, model: trimmed, explicitProvider: false };
|
||||
}
|
||||
const provider = trimmed.slice(0, slashIndex).trim();
|
||||
const model = trimmed.slice(slashIndex + 1).trim();
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: normalizeLowercaseStringOrEmpty(provider),
|
||||
model,
|
||||
explicitProvider: true,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalizeKnownClaudeCliModelId(modelId: string): string | null {
|
||||
const split = splitTrailingModelAuthProfile(modelId);
|
||||
const trimmed = split.model.trim();
|
||||
const normalized = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const upgraded = upgradeOldClaudeModelId(normalized);
|
||||
if (upgraded) {
|
||||
return attachModelAuthProfile(upgraded, split.profile);
|
||||
}
|
||||
if (normalized.startsWith("claude-")) {
|
||||
return attachModelAuthProfile(trimmed, split.profile);
|
||||
}
|
||||
const defaultModel = DEFAULT_CLAUDE_MODEL_BY_FAMILY[normalized];
|
||||
if (defaultModel) {
|
||||
return attachModelAuthProfile(defaultModel, split.profile);
|
||||
}
|
||||
const aliasedModel = CLAUDE_CLI_MODEL_ALIASES[normalized];
|
||||
return aliasedModel?.startsWith("claude-")
|
||||
? attachModelAuthProfile(aliasedModel, split.profile)
|
||||
: null;
|
||||
}
|
||||
|
||||
function upgradeOldClaudeModelId(normalized: string): string | null {
|
||||
if (normalized.startsWith("claude-opus-4-8") || normalized.startsWith("claude-opus-4.8")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("claude-opus-4-7") || normalized.startsWith("claude-opus-4.7")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("claude-opus-4-6") || normalized.startsWith("claude-opus-4.6")) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.startsWith("claude-sonnet-4-6") || normalized.startsWith("claude-sonnet-4.6")) {
|
||||
return null;
|
||||
}
|
||||
// claude-haiku-4-5 is a current production model and must not be migrated.
|
||||
if (normalized.startsWith("claude-haiku-4-5") || normalized.startsWith("claude-haiku-4.5")) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
normalized === "claude-opus-4" ||
|
||||
hasAnyRetiredVersionPrefix(normalized, [
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4.5",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4.1",
|
||||
"claude-opus-4-0",
|
||||
"claude-opus-4.0",
|
||||
]) ||
|
||||
/^claude-opus-4-20\d{6}/.test(normalized)
|
||||
) {
|
||||
return "claude-opus-4-8";
|
||||
}
|
||||
if (
|
||||
normalized === "claude-sonnet-4" ||
|
||||
hasAnyRetiredVersionPrefix(normalized, [
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-sonnet-4-1",
|
||||
"claude-sonnet-4.1",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4.0",
|
||||
]) ||
|
||||
/^claude-sonnet-4-20\d{6}/.test(normalized)
|
||||
) {
|
||||
return "claude-sonnet-4-6";
|
||||
}
|
||||
if (normalized.startsWith("claude-3") && normalized.includes("opus")) {
|
||||
return "claude-opus-4-8";
|
||||
}
|
||||
if (
|
||||
normalized.startsWith("claude-3") &&
|
||||
(normalized.includes("sonnet") || normalized.includes("haiku"))
|
||||
) {
|
||||
return "claude-sonnet-4-6";
|
||||
}
|
||||
if (
|
||||
normalized === "opus-4.5" ||
|
||||
normalized === "opus-4.1" ||
|
||||
normalized === "opus-4" ||
|
||||
normalized === "opus-3"
|
||||
) {
|
||||
return "claude-opus-4-8";
|
||||
}
|
||||
if (
|
||||
normalized === "sonnet-4.5" ||
|
||||
normalized === "sonnet-4.1" ||
|
||||
normalized === "sonnet-4.0" ||
|
||||
normalized === "sonnet-4" ||
|
||||
normalized === "sonnet-3.7" ||
|
||||
normalized === "sonnet-3.5" ||
|
||||
normalized === "sonnet-3" ||
|
||||
normalized === "haiku-3.5" ||
|
||||
normalized === "haiku-3"
|
||||
) {
|
||||
return "claude-sonnet-4-6";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve a Claude CLI model ref into selected and Anthropic-compatible runtime refs. */
|
||||
export function resolveClaudeCliAnthropicModelRefs(
|
||||
raw: string,
|
||||
): ClaudeCliAnthropicModelRefs | null {
|
||||
const parsed = parseProviderModelRef(raw, "anthropic");
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.provider !== "anthropic" && parsed.provider !== CLAUDE_CLI_BACKEND_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedRef = `anthropic/${parsed.model}`;
|
||||
const runtimeRefs = new Set<string>([selectedRef]);
|
||||
const canonicalModelId = canonicalizeKnownClaudeCliModelId(parsed.model);
|
||||
if (!parsed.explicitProvider && !canonicalModelId) {
|
||||
return null;
|
||||
}
|
||||
const rewriteRef =
|
||||
canonicalModelId || parsed.provider === CLAUDE_CLI_BACKEND_ID
|
||||
? `anthropic/${canonicalModelId ?? parsed.model}`
|
||||
: undefined;
|
||||
if (rewriteRef) {
|
||||
runtimeRefs.add(rewriteRef);
|
||||
}
|
||||
|
||||
return {
|
||||
selectedRef,
|
||||
runtimeRefs: [...runtimeRefs],
|
||||
...(rewriteRef ? { rewriteRef } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a known Anthropic/Claude CLI model ref to its current Anthropic model ref. */
|
||||
export function resolveKnownAnthropicModelRef(raw?: string): string | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return resolveClaudeCliAnthropicModelRefs(trimmed)?.rewriteRef ?? trimmed;
|
||||
}
|
||||
20
extensions/anthropic/cli-auth-seam.ts
Normal file
20
extensions/anthropic/cli-auth-seam.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Claude CLI auth seam. Setup may prompt for keychain-backed credentials while
|
||||
* runtime paths stay non-interactive.
|
||||
*/
|
||||
import { readClaudeCliCredentialsCached } from "openclaw/plugin-sdk/provider-auth";
|
||||
|
||||
/** Read Claude CLI credentials for interactive setup paths. */
|
||||
export function readClaudeCliCredentialsForSetup() {
|
||||
return readClaudeCliCredentialsCached();
|
||||
}
|
||||
|
||||
/** Read Claude CLI credentials for setup checks that must not prompt. */
|
||||
export function readClaudeCliCredentialsForSetupNonInteractive() {
|
||||
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
|
||||
}
|
||||
|
||||
/** Read Claude CLI credentials for runtime without keychain prompts. */
|
||||
export function readClaudeCliCredentialsForRuntime() {
|
||||
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
|
||||
}
|
||||
95
extensions/anthropic/cli-backend.ts
Normal file
95
extensions/anthropic/cli-backend.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Claude CLI backend descriptor. It configures Claude Code process arguments,
|
||||
* MCP bundling, session handling, environment scrubbing, and watchdog defaults.
|
||||
*/
|
||||
import type { CliBackendPlugin } from "openclaw/plugin-sdk/cli-backend";
|
||||
import {
|
||||
CLI_FRESH_WATCHDOG_DEFAULTS,
|
||||
CLI_RESUME_WATCHDOG_DEFAULTS,
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
CLAUDE_CLI_CLEAR_ENV,
|
||||
CLAUDE_CLI_MODEL_ALIASES,
|
||||
CLAUDE_CLI_SESSION_ID_FIELDS,
|
||||
normalizeClaudeBackendConfig,
|
||||
resolveClaudeCliExecutionArgs,
|
||||
} from "./cli-shared.js";
|
||||
|
||||
/** Build the Claude CLI backend plugin descriptor. */
|
||||
export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
return {
|
||||
id: CLAUDE_CLI_BACKEND_ID,
|
||||
modelProvider: "anthropic",
|
||||
liveTest: {
|
||||
defaultModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
defaultImageProbe: true,
|
||||
defaultMcpProbe: true,
|
||||
docker: {
|
||||
npmPackage: "@anthropic-ai/claude-code",
|
||||
binaryName: "claude",
|
||||
},
|
||||
},
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "claude-config-file",
|
||||
nativeToolMode: "always-on",
|
||||
sideQuestionToolMode: "disabled",
|
||||
ownsNativeCompaction: true,
|
||||
config: {
|
||||
command: "claude",
|
||||
args: [
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--include-partial-messages",
|
||||
"--verbose",
|
||||
"--setting-sources",
|
||||
"user",
|
||||
"--allowedTools",
|
||||
"mcp__openclaw__*",
|
||||
"--disallowedTools",
|
||||
"ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor",
|
||||
],
|
||||
resumeArgs: [
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--include-partial-messages",
|
||||
"--verbose",
|
||||
"--setting-sources",
|
||||
"user",
|
||||
"--allowedTools",
|
||||
"mcp__openclaw__*",
|
||||
"--disallowedTools",
|
||||
"ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor",
|
||||
"--resume",
|
||||
"{sessionId}",
|
||||
],
|
||||
output: "jsonl",
|
||||
liveSession: "claude-stdio",
|
||||
input: "stdin",
|
||||
modelArg: "--model",
|
||||
modelAliases: CLAUDE_CLI_MODEL_ALIASES,
|
||||
imageArg: "@",
|
||||
imagePathScope: "workspace",
|
||||
sessionArg: "--session-id",
|
||||
sessionMode: "always",
|
||||
reseedFromRawTranscriptWhenUncompacted: true,
|
||||
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],
|
||||
systemPromptFileArg: "--append-system-prompt-file",
|
||||
systemPromptMode: "append",
|
||||
systemPromptWhen: "always",
|
||||
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
|
||||
reliability: {
|
||||
watchdog: {
|
||||
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },
|
||||
resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS },
|
||||
},
|
||||
},
|
||||
serialize: true,
|
||||
},
|
||||
normalizeConfig: normalizeClaudeBackendConfig,
|
||||
resolveExecutionArgs: resolveClaudeCliExecutionArgs,
|
||||
};
|
||||
}
|
||||
59
extensions/anthropic/cli-catalog.ts
Normal file
59
extensions/anthropic/cli-catalog.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Claude CLI model catalog entries. Subscription-backed CLI models use picker
|
||||
* metadata and do not require API-key auth rows.
|
||||
*/
|
||||
import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-constants.js";
|
||||
|
||||
// Claude CLI auth is subscription-backed, so catalog rows only need picker metadata.
|
||||
const CLAUDE_CLI_DEFAULT_CONTEXT_WINDOW = 200_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-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;
|
||||
return {
|
||||
image: {
|
||||
maxSidePx,
|
||||
preferredSidePx: maxSidePx,
|
||||
tokenMode: "provider",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractClaudeCliModelIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
|
||||
if (!ref.startsWith(`${CLAUDE_CLI_BACKEND_ID}/`)) {
|
||||
continue;
|
||||
}
|
||||
const id = ref.slice(CLAUDE_CLI_BACKEND_ID.length + 1);
|
||||
if (id.length === 0 || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Build catalog entries for the default Claude CLI allowlist. */
|
||||
export function buildClaudeCliCatalogEntries(): ModelCatalogEntry[] {
|
||||
return extractClaudeCliModelIds().map((id) => {
|
||||
return {
|
||||
id,
|
||||
name: CLAUDE_CLI_MODEL_LABELS[id] ?? `${id} (Claude CLI)`,
|
||||
provider: CLAUDE_CLI_BACKEND_ID,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
mediaInput: resolveClaudeCliImageMediaInput(id),
|
||||
contextWindow: id === "claude-opus-4-8" ? 1_048_576 : CLAUDE_CLI_DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
});
|
||||
}
|
||||
38
extensions/anthropic/cli-constants.ts
Normal file
38
extensions/anthropic/cli-constants.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Shared Claude CLI constants. These identify the synthetic backend, default
|
||||
* model refs, aliases, and session-id fields used across runtime and setup.
|
||||
*/
|
||||
/** Synthetic provider/backend id for Claude Code CLI-backed Anthropic models. */
|
||||
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
/** Default Claude CLI model ref for agent defaults and live tests. */
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-8`;
|
||||
/** 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-opus-4-7`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`,
|
||||
] as const;
|
||||
|
||||
/** User-facing Claude CLI model aliases normalized before execution. */
|
||||
export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
|
||||
opus: "opus",
|
||||
"opus-4.8": "claude-opus-4-8",
|
||||
"opus-4.7": "claude-opus-4-7",
|
||||
"opus-4.6": "claude-opus-4-6",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
"claude-opus-4-7": "claude-opus-4-7",
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
sonnet: "sonnet",
|
||||
"sonnet-4.6": "claude-sonnet-4-6",
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||
haiku: "haiku",
|
||||
};
|
||||
|
||||
/** JSONL fields that may contain Claude CLI session ids. */
|
||||
export const CLAUDE_CLI_SESSION_ID_FIELDS = [
|
||||
"session_id",
|
||||
"sessionId",
|
||||
"conversation_id",
|
||||
"conversationId",
|
||||
] as const;
|
||||
509
extensions/anthropic/cli-migration.test.ts
Normal file
509
extensions/anthropic/cli-migration.test.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
// Anthropic tests cover cli migration plugin behavior.
|
||||
import type {
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { readClaudeCliCredentialsForSetup, readClaudeCliCredentialsForSetupNonInteractive } =
|
||||
vi.hoisted(() => ({
|
||||
readClaudeCliCredentialsForSetup: vi.fn(),
|
||||
readClaudeCliCredentialsForSetupNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./cli-auth-seam.js", async (importActual) => {
|
||||
const actual = await importActual<typeof import("./cli-auth-seam.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readClaudeCliCredentialsForSetup,
|
||||
readClaudeCliCredentialsForSetupNonInteractive,
|
||||
};
|
||||
});
|
||||
|
||||
const { buildAnthropicCliMigrationResult } = await import("./cli-migration.js");
|
||||
const { resolveKnownAnthropicModelRef } = await import("./claude-model-refs.js");
|
||||
const { createTestWizardPrompter, registerSingleProviderPlugin } =
|
||||
await import("openclaw/plugin-sdk/plugin-test-runtime");
|
||||
const { default: anthropicPlugin } = await import("./index.js");
|
||||
|
||||
beforeEach(() => {
|
||||
readClaudeCliCredentialsForSetup.mockReset();
|
||||
readClaudeCliCredentialsForSetupNonInteractive.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./cli-auth-seam.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("anthropic Claude model refs", () => {
|
||||
it("upgrades retired refs without rewriting future canonical refs", () => {
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-opus-4-5")).toBe(
|
||||
"anthropic/claude-opus-4-8",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-opus-4-5@anthropic:work")).toBe(
|
||||
"anthropic/claude-opus-4-8@anthropic:work",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-sonnet-4-20250514")).toBe(
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-opus-5-0")).toBe(
|
||||
"anthropic/claude-opus-5-0",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-opus-4-10")).toBe(
|
||||
"anthropic/claude-opus-4-10",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-sonnet-4-7")).toBe(
|
||||
"anthropic/claude-sonnet-4-7",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4-5")).toBe(
|
||||
"anthropic/claude-haiku-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the current claude-haiku-4-5 model and its bare alias", () => {
|
||||
// claude-haiku-4-5 is a current production model (not retired), so neither
|
||||
// its full ref, its dotted variant, nor the bare "haiku" family alias must
|
||||
// be rewritten to sonnet.
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4-5")).toBe(
|
||||
"anthropic/claude-haiku-4-5",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4.5")).toBe(
|
||||
"anthropic/claude-haiku-4.5",
|
||||
);
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-haiku-4-5@anthropic:work")).toBe(
|
||||
"anthropic/claude-haiku-4-5@anthropic:work",
|
||||
);
|
||||
// Genuinely retired Claude 3 Haiku still upgrades to the current sonnet.
|
||||
expect(resolveKnownAnthropicModelRef("anthropic/claude-3-5-haiku-20241022")).toBe(
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function resolveAnthropicCliAuthMethod() {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const method = provider.auth.find((entry) => entry.id === "cli");
|
||||
if (!method) {
|
||||
throw new Error("anthropic cli auth method missing");
|
||||
}
|
||||
return method;
|
||||
}
|
||||
|
||||
function createProviderAuthContext(
|
||||
config: ProviderAuthContext["config"] = {},
|
||||
): ProviderAuthContext {
|
||||
return {
|
||||
config,
|
||||
opts: {},
|
||||
env: {},
|
||||
agentDir: "/tmp/openclaw/agents/main",
|
||||
workspaceDir: "/tmp/openclaw/workspace",
|
||||
prompter: createTestWizardPrompter(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
allowSecretRefPrompt: false,
|
||||
isRemote: false,
|
||||
openUrl: vi.fn(),
|
||||
oauth: {
|
||||
createVpsAwareHandlers: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderAuthMethodNonInteractiveContext(
|
||||
config: ProviderAuthMethodNonInteractiveContext["config"] = {},
|
||||
): ProviderAuthMethodNonInteractiveContext {
|
||||
return {
|
||||
authChoice: "anthropic-cli",
|
||||
config,
|
||||
baseConfig: config,
|
||||
opts: {},
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
agentDir: "/tmp/openclaw/agents/main",
|
||||
workspaceDir: "/tmp/openclaw/workspace",
|
||||
resolveApiKey: vi.fn(async () => null),
|
||||
toApiKeyCredential: vi.fn(() => null),
|
||||
};
|
||||
}
|
||||
|
||||
describe("anthropic cli migration", () => {
|
||||
it("keeps anthropic defaults and selects the claude-cli runtime", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.profiles).toStrictEqual([]);
|
||||
expect(result.defaultModel).toBe("anthropic/claude-opus-4-7");
|
||||
expect(result.configPatch).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": {
|
||||
alias: "Opus",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
},
|
||||
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-opus-4-6": {
|
||||
alias: "Opus",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
},
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes provider-qualified shorthand refs through Claude CLI without dropping the raw ref", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/opus-4.7",
|
||||
fallbacks: ["anthropic/sonnet-4.6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/opus-4.7": { alias: "Opus shorthand" },
|
||||
"anthropic/sonnet-4.6": { alias: "Sonnet shorthand" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const defaults = result.configPatch?.agents?.defaults;
|
||||
expect(defaults?.model).toEqual({
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.2"],
|
||||
});
|
||||
expect(defaults?.models?.["anthropic/opus-4.7"]).toEqual({
|
||||
alias: "Opus shorthand",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
expect(defaults?.models?.["anthropic/claude-opus-4-7"]).toEqual({
|
||||
alias: "Opus shorthand",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
expect(defaults?.models?.["anthropic/sonnet-4.6"]).toEqual({
|
||||
alias: "Sonnet shorthand",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
expect(defaults?.models?.["anthropic/claude-sonnet-4-6"]).toEqual({
|
||||
alias: "Sonnet shorthand",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unknown Anthropic refs raw while still selecting Claude CLI", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/opus-5.0" },
|
||||
models: {
|
||||
"anthropic/opus-5.0": { alias: "Future Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const defaults = result.configPatch?.agents?.defaults;
|
||||
expect(result.defaultModel).toBe("anthropic/opus-5.0");
|
||||
expect(defaults?.model).toBeUndefined();
|
||||
expect(defaults?.models?.["anthropic/opus-5.0"]).toEqual({
|
||||
alias: "Future Opus",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
expect(defaults?.models?.["anthropic/claude-opus-5-0"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds a Claude CLI default when no anthropic default is present", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.2" },
|
||||
models: {
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.defaultModel).toBe("anthropic/claude-opus-4-8");
|
||||
expect(result.configPatch).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.2": {},
|
||||
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat bare non-Claude model refs as Anthropic", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "gpt-5.2" },
|
||||
models: {
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.defaultModel).toBe("anthropic/claude-opus-4-8");
|
||||
expect(result.configPatch?.agents?.defaults?.model).toBeUndefined();
|
||||
expect(result.configPatch?.agents?.defaults?.models?.["anthropic/gpt-5.2"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("backfills the Claude CLI allowlist when older configs only stored sonnet", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "claude-cli/claude-opus-4-7" },
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.configPatch).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } },
|
||||
"anthropic/claude-opus-4-6": { agentRuntime: { id: "claude-cli" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit model runtime policy while filling missing Claude CLI policies", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-6"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": {
|
||||
alias: "Opus",
|
||||
agentRuntime: { id: "openclaw" },
|
||||
},
|
||||
"anthropic/claude-sonnet-4-6": {
|
||||
alias: "Sonnet",
|
||||
agentRuntime: { id: "auto" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const defaults = result.configPatch?.agents?.defaults;
|
||||
if (!defaults) {
|
||||
throw new Error("Expected Claude CLI migration to return default agent config");
|
||||
}
|
||||
|
||||
expect(defaults.models?.["anthropic/claude-opus-4-7"]).toEqual({
|
||||
alias: "Opus",
|
||||
agentRuntime: { id: "openclaw" },
|
||||
});
|
||||
expect(defaults.models?.["anthropic/claude-sonnet-4-6"]).toEqual({
|
||||
alias: "Sonnet",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
});
|
||||
|
||||
it("registered cli auth tells users to run claude auth login when local auth is missing", async () => {
|
||||
readClaudeCliCredentialsForSetup.mockReturnValue(null);
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
|
||||
await expect(method.run(createProviderAuthContext())).rejects.toThrow(
|
||||
[
|
||||
"Claude CLI is not authenticated on this host.",
|
||||
"Run claude auth login first, then re-run this setup.",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("registered cli auth returns the same migration result as the builder", async () => {
|
||||
const credential = {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
} as const;
|
||||
readClaudeCliCredentialsForSetup.mockReturnValue(credential);
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(method.run(createProviderAuthContext(config))).resolves.toEqual(
|
||||
buildAnthropicCliMigrationResult(config, credential),
|
||||
);
|
||||
});
|
||||
|
||||
it("stores a claude-cli oauth profile when Claude CLI credentials are available", () => {
|
||||
const result = buildAnthropicCliMigrationResult(
|
||||
{},
|
||||
{
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.profiles).toEqual([
|
||||
{
|
||||
profileId: "anthropic:claude-cli",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "claude-cli",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores a claude-cli token profile when Claude CLI only exposes a bearer token", () => {
|
||||
const result = buildAnthropicCliMigrationResult(
|
||||
{},
|
||||
{
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "bearer-token",
|
||||
expires: 123,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.profiles).toEqual([
|
||||
{
|
||||
profileId: "anthropic:claude-cli",
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "claude-cli",
|
||||
token: "bearer-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("registered non-interactive cli auth keeps anthropic fallbacks and selects claude-cli runtime", async () => {
|
||||
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
});
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await method.runNonInteractive?.(
|
||||
createProviderAuthMethodNonInteractiveContext(config),
|
||||
);
|
||||
const defaults = result?.agents?.defaults as
|
||||
| {
|
||||
model?: { primary?: string; fallbacks?: string[] };
|
||||
models?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
expect(defaults?.model?.primary).toBe("anthropic/claude-opus-4-7");
|
||||
expect(defaults?.model?.fallbacks).toEqual(["anthropic/claude-opus-4-6", "openai/gpt-5.2"]);
|
||||
expect(defaults?.models?.["anthropic/claude-opus-4-7"]).toEqual({
|
||||
alias: "Opus",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
expect(defaults?.models?.["anthropic/claude-opus-4-6"]).toEqual({
|
||||
alias: "Opus",
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
expect(defaults?.models?.["anthropic/claude-opus-4-8"]).toEqual({
|
||||
agentRuntime: { id: "claude-cli" },
|
||||
});
|
||||
expect(defaults?.models?.["openai/gpt-5.2"]).toEqual({});
|
||||
});
|
||||
|
||||
it("registered non-interactive cli auth reports missing local auth and exits cleanly", async () => {
|
||||
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue(null);
|
||||
const method = await resolveAnthropicCliAuthMethod();
|
||||
const ctx = createProviderAuthMethodNonInteractiveContext();
|
||||
|
||||
await expect(method.runNonInteractive?.(ctx)).resolves.toBeNull();
|
||||
expect(ctx.runtime.error).toHaveBeenCalledWith(
|
||||
[
|
||||
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
|
||||
"Run claude auth login first.",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
244
extensions/anthropic/cli-migration.ts
Normal file
244
extensions/anthropic/cli-migration.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Claude CLI setup migration helpers. They rewrite legacy Claude CLI model refs
|
||||
* to Anthropic refs while preserving runtime allowlist entries for CLI execution.
|
||||
*/
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
type OpenClawConfig,
|
||||
type ProviderAuthResult,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
isRecord,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveClaudeCliAnthropicModelRefs } from "./claude-model-refs.js";
|
||||
import type { readClaudeCliCredentialsForSetup } from "./cli-auth-seam.js";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-shared.js";
|
||||
|
||||
type AgentDefaultsModel = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["model"];
|
||||
type AgentDefaultsModels = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]>["models"];
|
||||
type ClaudeCliCredential = NonNullable<ReturnType<typeof readClaudeCliCredentialsForSetup>>;
|
||||
|
||||
function toAnthropicModelRef(raw: string): string | null {
|
||||
return resolveClaudeCliAnthropicModelRefs(raw)?.rewriteRef ?? null;
|
||||
}
|
||||
|
||||
function toAnthropicRuntimeRefs(raw: string): string[] {
|
||||
return resolveClaudeCliAnthropicModelRefs(raw)?.runtimeRefs ?? [];
|
||||
}
|
||||
|
||||
function toAnthropicSelectedModelRef(raw: string): string | undefined {
|
||||
const resolved = resolveClaudeCliAnthropicModelRefs(raw);
|
||||
return resolved?.rewriteRef ?? resolved?.selectedRef;
|
||||
}
|
||||
|
||||
function rewriteModelSelection(model: AgentDefaultsModel): {
|
||||
value: AgentDefaultsModel;
|
||||
primary?: string;
|
||||
runtimeRefs: string[];
|
||||
changed: boolean;
|
||||
} {
|
||||
if (typeof model === "string") {
|
||||
const runtimeRefs = toAnthropicRuntimeRefs(model);
|
||||
const converted = toAnthropicModelRef(model);
|
||||
const selectedRef = converted ?? toAnthropicSelectedModelRef(model);
|
||||
return converted
|
||||
? { value: converted, primary: converted, runtimeRefs, changed: true }
|
||||
: {
|
||||
value: model,
|
||||
...(selectedRef ? { primary: selectedRef } : {}),
|
||||
runtimeRefs,
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
if (!model || typeof model !== "object" || Array.isArray(model)) {
|
||||
return { value: model, runtimeRefs: [], changed: false };
|
||||
}
|
||||
|
||||
const current = model as Record<string, unknown>;
|
||||
const next: Record<string, unknown> = { ...current };
|
||||
const runtimeRefs: string[] = [];
|
||||
let changed = false;
|
||||
let primary: string | undefined;
|
||||
|
||||
if (typeof current.primary === "string") {
|
||||
runtimeRefs.push(...toAnthropicRuntimeRefs(current.primary));
|
||||
const converted = toAnthropicModelRef(current.primary);
|
||||
if (converted) {
|
||||
next.primary = converted;
|
||||
primary = converted;
|
||||
changed = true;
|
||||
} else {
|
||||
primary = toAnthropicSelectedModelRef(current.primary);
|
||||
}
|
||||
}
|
||||
|
||||
const currentFallbacks = current.fallbacks;
|
||||
if (Array.isArray(currentFallbacks)) {
|
||||
const nextFallbacks = currentFallbacks.map((entry) => {
|
||||
if (typeof entry !== "string") {
|
||||
return entry;
|
||||
}
|
||||
runtimeRefs.push(...toAnthropicRuntimeRefs(entry));
|
||||
const converted = toAnthropicModelRef(entry);
|
||||
return converted ?? entry;
|
||||
});
|
||||
if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) {
|
||||
next.fallbacks = nextFallbacks;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: changed ? next : model,
|
||||
...(primary ? { primary } : {}),
|
||||
runtimeRefs,
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
function rewriteModelEntryMap(models: Record<string, unknown> | undefined): {
|
||||
value: Record<string, unknown> | undefined;
|
||||
migrated: string[];
|
||||
runtimeRefs: string[];
|
||||
} {
|
||||
if (!models) {
|
||||
return { value: models, migrated: [], runtimeRefs: [] };
|
||||
}
|
||||
|
||||
const next = { ...models };
|
||||
const migrated: string[] = [];
|
||||
const runtimeRefs: string[] = [];
|
||||
|
||||
for (const [rawKey, value] of Object.entries(models)) {
|
||||
runtimeRefs.push(...toAnthropicRuntimeRefs(rawKey));
|
||||
const converted = toAnthropicModelRef(rawKey);
|
||||
if (!converted) {
|
||||
continue;
|
||||
}
|
||||
if (converted === rawKey) {
|
||||
continue;
|
||||
}
|
||||
if (!(converted in next)) {
|
||||
next[converted] = value;
|
||||
}
|
||||
if (normalizeLowercaseStringOrEmpty(rawKey).startsWith(`${CLAUDE_CLI_BACKEND_ID}/`)) {
|
||||
delete next[rawKey];
|
||||
}
|
||||
migrated.push(converted);
|
||||
}
|
||||
|
||||
return {
|
||||
value: migrated.length > 0 || runtimeRefs.length > 0 ? next : models,
|
||||
migrated,
|
||||
runtimeRefs,
|
||||
};
|
||||
}
|
||||
|
||||
function seedClaudeCliAllowlist(
|
||||
models: NonNullable<AgentDefaultsModels>,
|
||||
selectedRefs: readonly string[] = [],
|
||||
): NonNullable<AgentDefaultsModels> {
|
||||
const next = { ...models };
|
||||
const runtimeRefs = new Set<string>();
|
||||
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
|
||||
const canonicalRef = toAnthropicModelRef(ref) ?? ref;
|
||||
runtimeRefs.add(canonicalRef);
|
||||
}
|
||||
for (const ref of selectedRefs) {
|
||||
runtimeRefs.add(ref);
|
||||
}
|
||||
for (const ref of runtimeRefs) {
|
||||
next[ref] = modelEntryWithClaudeCliRuntime(next[ref]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown> {
|
||||
const base = isRecord(entry) ? { ...entry } : {};
|
||||
const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined;
|
||||
const currentRuntime =
|
||||
typeof currentRuntimeId === "string" ? normalizeLowercaseStringOrEmpty(currentRuntimeId) : "";
|
||||
if (currentRuntime && currentRuntime !== "auto") {
|
||||
return base;
|
||||
}
|
||||
base.agentRuntime = {
|
||||
...(isRecord(base.agentRuntime) ? base.agentRuntime : {}),
|
||||
id: CLAUDE_CLI_BACKEND_ID,
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildClaudeCliAuthProfiles(
|
||||
credential?: ClaudeCliCredential | null,
|
||||
): ProviderAuthResult["profiles"] {
|
||||
if (!credential) {
|
||||
return [];
|
||||
}
|
||||
if (credential.type === "oauth") {
|
||||
return [
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: CLAUDE_CLI_BACKEND_ID,
|
||||
access: credential.access,
|
||||
refresh: credential.refresh,
|
||||
expires: credential.expires,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
profileId: CLAUDE_CLI_PROFILE_ID,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: CLAUDE_CLI_BACKEND_ID,
|
||||
token: credential.token,
|
||||
expires: credential.expires,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Build the config migration result for adopting Claude CLI-backed Anthropic defaults. */
|
||||
export function buildAnthropicCliMigrationResult(
|
||||
config: OpenClawConfig,
|
||||
credential?: ClaudeCliCredential | null,
|
||||
): ProviderAuthResult {
|
||||
const defaults = config.agents?.defaults;
|
||||
const rewrittenModel = rewriteModelSelection(defaults?.model);
|
||||
const rewrittenModels = rewriteModelEntryMap(defaults?.models);
|
||||
const existingModels = (rewrittenModels.value ??
|
||||
defaults?.models ??
|
||||
{}) as NonNullable<AgentDefaultsModels>;
|
||||
const nextModels = seedClaudeCliAllowlist(existingModels, [
|
||||
...rewrittenModel.runtimeRefs,
|
||||
...rewrittenModels.runtimeRefs,
|
||||
...rewrittenModels.migrated,
|
||||
]);
|
||||
const defaultModel = rewrittenModel.primary ?? "anthropic/claude-opus-4-8";
|
||||
|
||||
return {
|
||||
profiles: buildClaudeCliAuthProfiles(credential),
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}),
|
||||
models: nextModels,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Rewrites `claude-cli/*` -> `anthropic/*`; merge would keep stale keys.
|
||||
replaceDefaultModels: true,
|
||||
defaultModel,
|
||||
notes: [
|
||||
"Claude CLI auth detected; kept Anthropic model refs and selected the local Claude CLI runtime.",
|
||||
"Existing Anthropic auth profiles are kept for rollback.",
|
||||
...(rewrittenModels.migrated.length > 0
|
||||
? [`Migrated allowlist entries: ${rewrittenModels.migrated.join(", ")}.`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
395
extensions/anthropic/cli-shared.test.ts
Normal file
395
extensions/anthropic/cli-shared.test.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
// Anthropic tests cover cli shared plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
import {
|
||||
CLAUDE_CLI_CLEAR_ENV,
|
||||
normalizeClaudeBackendConfig,
|
||||
normalizeClaudePermissionArgs,
|
||||
normalizeClaudeSettingSourcesArgs,
|
||||
resolveClaudePermissionMode,
|
||||
resolveClaudeCliExecutionArgs,
|
||||
} from "./cli-shared.js";
|
||||
|
||||
const CLAUDE_CLI_DISALLOWED_TOOLS =
|
||||
"ScheduleWakeup,CronCreate,Bash(run_in_background:true),Monitor";
|
||||
|
||||
function expectDefaultDisallowedTools(args: readonly string[] | undefined) {
|
||||
const disallowedIndex = args?.indexOf("--disallowedTools") ?? -1;
|
||||
expect(disallowedIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(args?.[disallowedIndex + 1]).toBe(CLAUDE_CLI_DISALLOWED_TOOLS);
|
||||
}
|
||||
|
||||
describe("normalizeClaudePermissionArgs", () => {
|
||||
it("leaves args alone when they omit permission flags", () => {
|
||||
expect(
|
||||
normalizeClaudePermissionArgs(["-p", "--output-format", "stream-json", "--verbose"]),
|
||||
).toEqual(["-p", "--output-format", "stream-json", "--verbose"]);
|
||||
});
|
||||
|
||||
it("removes legacy skip-permissions without adding bypassPermissions", () => {
|
||||
expect(
|
||||
normalizeClaudePermissionArgs(["-p", "--dangerously-skip-permissions", "--verbose"]),
|
||||
).toEqual(["-p", "--verbose"]);
|
||||
});
|
||||
|
||||
it("keeps explicit permission-mode overrides", () => {
|
||||
expect(normalizeClaudePermissionArgs(["-p", "--permission-mode", "acceptEdits"])).toEqual([
|
||||
"-p",
|
||||
"--permission-mode",
|
||||
"acceptEdits",
|
||||
]);
|
||||
expect(normalizeClaudePermissionArgs(["-p", "--permission-mode=acceptEdits"])).toEqual([
|
||||
"-p",
|
||||
"--permission-mode=acceptEdits",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed permission-mode flags in both split and equals forms", () => {
|
||||
expect(
|
||||
normalizeClaudePermissionArgs(["-p", "--permission-mode", "--output-format", "stream-json"]),
|
||||
).toEqual(["-p", "--output-format", "stream-json"]);
|
||||
expect(normalizeClaudePermissionArgs(["-p", "--permission-mode="])).toEqual(["-p"]);
|
||||
expect(normalizeClaudePermissionArgs(["-p", "--permission-mode=--output-format"])).toEqual([
|
||||
"-p",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeSettingSourcesArgs", () => {
|
||||
it("injects user-only setting sources when args omit the flag", () => {
|
||||
expect(
|
||||
normalizeClaudeSettingSourcesArgs(["-p", "--output-format", "stream-json", "--verbose"]),
|
||||
).toEqual(["-p", "--output-format", "stream-json", "--verbose", "--setting-sources", "user"]);
|
||||
});
|
||||
|
||||
it("forces explicit project or local setting sources back to user-only", () => {
|
||||
expect(normalizeClaudeSettingSourcesArgs(["-p", "--setting-sources", "project"])).toEqual([
|
||||
"-p",
|
||||
"--setting-sources",
|
||||
"user",
|
||||
]);
|
||||
expect(normalizeClaudeSettingSourcesArgs(["-p", "--setting-sources=local,user"])).toEqual([
|
||||
"-p",
|
||||
"--setting-sources=user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a bare setting-sources flag as malformed and falls back to user-only", () => {
|
||||
expect(
|
||||
normalizeClaudeSettingSourcesArgs([
|
||||
"-p",
|
||||
"--setting-sources",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
]),
|
||||
).toEqual(["-p", "--output-format", "stream-json", "--setting-sources", "user"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude CLI model aliases", () => {
|
||||
it("keeps pinned Claude CLI model refs on exact selectors", () => {
|
||||
const aliases = buildAnthropicCliBackend().config.modelAliases;
|
||||
|
||||
expect(aliases?.["opus"]).toBe("opus");
|
||||
expect(aliases?.["opus-4.8"]).toBe("claude-opus-4-8");
|
||||
expect(aliases?.["opus-4.7"]).toBe("claude-opus-4-7");
|
||||
expect(aliases?.["opus-4.6"]).toBe("claude-opus-4-6");
|
||||
expect(aliases?.["claude-opus-4-8"]).toBe("claude-opus-4-8");
|
||||
expect(aliases?.["claude-opus-4-7"]).toBe("claude-opus-4-7");
|
||||
expect(aliases?.["claude-opus-4-6"]).toBe("claude-opus-4-6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClaudeCliExecutionArgs", () => {
|
||||
it("omits effort args when thinking is off", () => {
|
||||
expect(
|
||||
resolveClaudeCliExecutionArgs({
|
||||
workspaceDir: "/tmp",
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
thinkingLevel: "off",
|
||||
useResume: false,
|
||||
baseArgs: ["-p", "--output-format", "stream-json"],
|
||||
}),
|
||||
).toEqual(["-p", "--output-format", "stream-json"]);
|
||||
});
|
||||
|
||||
it("maps OpenClaw thinking levels to Claude effort args", () => {
|
||||
expect(
|
||||
resolveClaudeCliExecutionArgs({
|
||||
workspaceDir: "/tmp",
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-opus-4-7",
|
||||
thinkingLevel: "minimal",
|
||||
useResume: false,
|
||||
baseArgs: ["-p"],
|
||||
}),
|
||||
).toEqual(["-p", "--effort", "low"]);
|
||||
expect(
|
||||
resolveClaudeCliExecutionArgs({
|
||||
workspaceDir: "/tmp",
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-opus-4-7",
|
||||
thinkingLevel: "adaptive",
|
||||
useResume: false,
|
||||
baseArgs: ["-p"],
|
||||
}),
|
||||
).toEqual(["-p", "--effort", "medium"]);
|
||||
expect(
|
||||
resolveClaudeCliExecutionArgs({
|
||||
workspaceDir: "/tmp",
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-opus-4-7",
|
||||
thinkingLevel: "xhigh",
|
||||
useResume: true,
|
||||
baseArgs: ["-p", "--resume", "{sessionId}"],
|
||||
}),
|
||||
).toEqual(["-p", "--resume", "{sessionId}", "--effort", "xhigh"]);
|
||||
});
|
||||
|
||||
it("replaces static effort args when a session thinking level is active", () => {
|
||||
expect(
|
||||
resolveClaudeCliExecutionArgs({
|
||||
workspaceDir: "/tmp",
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-opus-4-7",
|
||||
thinkingLevel: "max",
|
||||
useResume: false,
|
||||
baseArgs: ["-p", "--effort", "low", "--effort=high"],
|
||||
}),
|
||||
).toEqual(["-p", "--effort", "max"]);
|
||||
});
|
||||
|
||||
it("forces isolated no-tool one-shot args for side-question execution", () => {
|
||||
expect(
|
||||
resolveClaudeCliExecutionArgs({
|
||||
workspaceDir: "/tmp",
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-opus-4-7",
|
||||
thinkingLevel: "max",
|
||||
useResume: true,
|
||||
executionMode: "side-question",
|
||||
baseArgs: [
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--allowedTools=mcp__openclaw__*",
|
||||
"--allowedTools",
|
||||
"Read",
|
||||
"Grep",
|
||||
"--permission-mode",
|
||||
"bypassPermissions",
|
||||
"--session-id=abc",
|
||||
"--resume",
|
||||
"old-session",
|
||||
"--resume-session-at",
|
||||
"old-message",
|
||||
"--resume-session-at=old-message-equals",
|
||||
"--mcp-config",
|
||||
"/tmp/side-question-mcp.json",
|
||||
"--bare",
|
||||
"--safe-mode",
|
||||
"--strict-mcp-config",
|
||||
"--no-session-persistence",
|
||||
"--max-turns",
|
||||
"4",
|
||||
"--effort",
|
||||
"high",
|
||||
],
|
||||
}),
|
||||
).toEqual([
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--safe-mode",
|
||||
"--tools",
|
||||
"",
|
||||
"--disallowedTools",
|
||||
"mcp__*",
|
||||
"--strict-mcp-config",
|
||||
"--no-session-persistence",
|
||||
"--max-turns",
|
||||
"1",
|
||||
"--permission-mode",
|
||||
"default",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeBackendConfig", () => {
|
||||
it("normalizes both args and resumeArgs for custom overrides", () => {
|
||||
const normalized = normalizeClaudeBackendConfig({
|
||||
command: "claude",
|
||||
args: ["-p", "--output-format", "stream-json", "--verbose"],
|
||||
resumeArgs: ["-p", "--output-format", "stream-json", "--verbose", "--resume", "{sessionId}"],
|
||||
});
|
||||
|
||||
expect(normalized.args).toEqual([
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--setting-sources",
|
||||
"user",
|
||||
"--permission-mode",
|
||||
"bypassPermissions",
|
||||
]);
|
||||
expect(normalized.resumeArgs).toEqual([
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--resume",
|
||||
"{sessionId}",
|
||||
"--setting-sources",
|
||||
"user",
|
||||
"--permission-mode",
|
||||
"bypassPermissions",
|
||||
]);
|
||||
expect(normalized.output).toBe("jsonl");
|
||||
expect(normalized.liveSession).toBe("claude-stdio");
|
||||
expect(normalized.input).toBe("stdin");
|
||||
});
|
||||
|
||||
it("derives Claude bypass from OpenClaw YOLO policy and disables it for safer policy", () => {
|
||||
expect(resolveClaudePermissionMode({ backendId: "claude-cli" })).toEqual({
|
||||
mode: "bypassPermissions",
|
||||
overrideExisting: false,
|
||||
});
|
||||
expect(
|
||||
resolveClaudePermissionMode({
|
||||
backendId: "claude-cli",
|
||||
config: { tools: { exec: { security: "allowlist", ask: "on-miss" } } },
|
||||
}),
|
||||
).toEqual({ overrideExisting: false });
|
||||
});
|
||||
|
||||
it("derives Claude bypass from per-agent OpenClaw exec policy", () => {
|
||||
expect(
|
||||
resolveClaudePermissionMode({
|
||||
backendId: "claude-cli",
|
||||
agentId: "safe-agent",
|
||||
config: {
|
||||
tools: { exec: { security: "full", ask: "off" } },
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "safe-agent",
|
||||
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({ overrideExisting: false });
|
||||
expect(
|
||||
resolveClaudePermissionMode({
|
||||
backendId: "claude-cli",
|
||||
agentId: "yolo-agent",
|
||||
config: {
|
||||
tools: { exec: { security: "allowlist", ask: "on-miss" } },
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "yolo-agent",
|
||||
tools: { exec: { security: "full", ask: "off" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "bypassPermissions",
|
||||
overrideExisting: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer live stdio when explicit transport overrides are incompatible", () => {
|
||||
const normalized = normalizeClaudeBackendConfig({
|
||||
command: "claude",
|
||||
output: "json",
|
||||
input: "arg",
|
||||
});
|
||||
|
||||
expect(normalized.output).toBe("json");
|
||||
expect(normalized.liveSession).toBeUndefined();
|
||||
expect(normalized.input).toBe("arg");
|
||||
});
|
||||
|
||||
it("is wired through the anthropic cli backend normalize hook", () => {
|
||||
const backend = buildAnthropicCliBackend();
|
||||
const normalizeConfig = backend.normalizeConfig;
|
||||
|
||||
expect(normalizeConfig).toBeTypeOf("function");
|
||||
|
||||
const normalized = normalizeConfig?.({
|
||||
...backend.config,
|
||||
args: ["-p", "--output-format", "stream-json", "--verbose"],
|
||||
resumeArgs: ["-p", "--output-format", "stream-json", "--verbose", "--resume", "{sessionId}"],
|
||||
});
|
||||
|
||||
expect(normalized?.args).toContain("--setting-sources");
|
||||
expect(normalized?.args).toContain("user");
|
||||
expect(normalized?.args).toContain("--permission-mode");
|
||||
expect(normalized?.args).toContain("bypassPermissions");
|
||||
expect(normalized?.resumeArgs).toContain("--setting-sources");
|
||||
expect(normalized?.resumeArgs).toContain("user");
|
||||
expect(normalized?.resumeArgs).toContain("--permission-mode");
|
||||
expect(normalized?.resumeArgs).toContain("bypassPermissions");
|
||||
expect(normalized?.liveSession).toBe("claude-stdio");
|
||||
expect(backend.resolveExecutionArgs).toBe(resolveClaudeCliExecutionArgs);
|
||||
});
|
||||
|
||||
it("opts bundled Claude CLI into bounded raw transcript reseed without disabling native resume", () => {
|
||||
const backend = buildAnthropicCliBackend();
|
||||
|
||||
expect(backend.config.reseedFromRawTranscriptWhenUncompacted).toBe(true);
|
||||
expect(backend.config.sessionMode).toBe("always");
|
||||
expect(backend.config.resumeArgs).toContain("--resume");
|
||||
expect(backend.config.resumeArgs).toContain("{sessionId}");
|
||||
});
|
||||
|
||||
it("passes system prompt on every turn (issue #80374 — systemPromptWhen must be 'always')", () => {
|
||||
// Before fix this was hardcoded to "first", which silently dropped updated
|
||||
// OpenClaw system prompt context on resumed / compacted claude-cli sessions.
|
||||
const backend = buildAnthropicCliBackend();
|
||||
expect(backend.config.systemPromptWhen).toBe("always");
|
||||
});
|
||||
|
||||
it("leaves claude cli subscription-managed, restricts setting sources, and clears inherited env overrides", () => {
|
||||
const backend = buildAnthropicCliBackend();
|
||||
|
||||
expect(backend.config.env).toBeUndefined();
|
||||
expect(backend.config.liveSession).toBe("claude-stdio");
|
||||
expect(backend.config.output).toBe("jsonl");
|
||||
expect(backend.config.input).toBe("stdin");
|
||||
expect(backend.config.args).toContain("--setting-sources");
|
||||
expect(backend.config.args).toContain("user");
|
||||
expectDefaultDisallowedTools(backend.config.args);
|
||||
expect(backend.config.resumeArgs).toContain("--setting-sources");
|
||||
expect(backend.config.resumeArgs).toContain("user");
|
||||
expectDefaultDisallowedTools(backend.config.resumeArgs);
|
||||
expect(backend.config.clearEnv).toEqual([...CLAUDE_CLI_CLEAR_ENV]);
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_API_TOKEN");
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_BASE_URL");
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_CUSTOM_HEADERS");
|
||||
expect(backend.config.clearEnv).toContain("ANTHROPIC_OAUTH_TOKEN");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CONFIG_DIR");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_BEDROCK");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_OAUTH_TOKEN");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_CACHE_DIR");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_PLUGIN_SEED_DIR");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_REMOTE");
|
||||
expect(backend.config.clearEnv).toContain("CLAUDE_CODE_USE_COWORK_PLUGINS");
|
||||
expect(backend.config.clearEnv).toContain("OTEL_METRICS_EXPORTER");
|
||||
expect(backend.config.clearEnv).toContain("OTEL_EXPORTER_OTLP_PROTOCOL");
|
||||
expect(backend.config.clearEnv).toContain("OTEL_SDK_DISABLED");
|
||||
});
|
||||
|
||||
it("disables native background Bash and Monitor tools in args and resumeArgs", () => {
|
||||
const backend = buildAnthropicCliBackend();
|
||||
|
||||
expectDefaultDisallowedTools(backend.config.args);
|
||||
expectDefaultDisallowedTools(backend.config.resumeArgs);
|
||||
});
|
||||
});
|
||||
363
extensions/anthropic/cli-shared.ts
Normal file
363
extensions/anthropic/cli-shared.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* Shared Claude CLI backend normalization. It sanitizes command args, maps
|
||||
* thinking levels, and keeps OpenClaw-managed CLI runs isolated from shell env.
|
||||
*/
|
||||
import type {
|
||||
CliBackendConfig,
|
||||
CliBackendNormalizeConfigContext,
|
||||
CliBackendResolveExecutionArgsContext,
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js";
|
||||
export {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
CLAUDE_CLI_MODEL_ALIASES,
|
||||
CLAUDE_CLI_SESSION_ID_FIELDS,
|
||||
} from "./cli-constants.js";
|
||||
|
||||
// Claude Code honors provider-routing, auth, and config-root env before
|
||||
// consulting its local login state, so inherited shell overrides must not
|
||||
// steer OpenClaw-managed Claude CLI runs toward a different provider,
|
||||
// endpoint, token source, plugin/config tree, or telemetry bootstrap mode.
|
||||
/** Environment variables removed before launching OpenClaw-managed Claude CLI runs. */
|
||||
export const CLAUDE_CLI_CLEAR_ENV = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_API_KEY_OLD",
|
||||
"ANTHROPIC_API_TOKEN",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_CUSTOM_HEADERS",
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"ANTHROPIC_UNIX_SOCKET",
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
"CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_SCOPES",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
|
||||
"CLAUDE_CODE_PLUGIN_CACHE_DIR",
|
||||
"CLAUDE_CODE_PLUGIN_SEED_DIR",
|
||||
"CLAUDE_CODE_REMOTE",
|
||||
"CLAUDE_CODE_USE_COWORK_PLUGINS",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_FOUNDRY",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_PROTOCOL",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_SDK_DISABLED",
|
||||
"OTEL_TRACES_EXPORTER",
|
||||
] as const;
|
||||
|
||||
const CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG = "--dangerously-skip-permissions";
|
||||
const CLAUDE_PERMISSION_MODE_ARG = "--permission-mode";
|
||||
const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
|
||||
const CLAUDE_EFFORT_ARG = "--effort";
|
||||
const CLAUDE_BARE_ARG = "--bare";
|
||||
const CLAUDE_SAFE_MODE_ARG = "--safe-mode";
|
||||
const CLAUDE_TOOLS_ARG = "--tools";
|
||||
const CLAUDE_DISALLOWED_TOOLS_ARG = "--disallowedTools";
|
||||
const CLAUDE_MCP_CONFIG_ARG = "--mcp-config";
|
||||
const CLAUDE_STRICT_MCP_CONFIG_ARG = "--strict-mcp-config";
|
||||
const CLAUDE_NO_SESSION_PERSISTENCE_ARG = "--no-session-persistence";
|
||||
const CLAUDE_MAX_TURNS_ARG = "--max-turns";
|
||||
const CLAUDE_SESSION_ID_ARG = "--session-id";
|
||||
const CLAUDE_RESUME_ARG = "--resume";
|
||||
const CLAUDE_RESUME_SESSION_AT_ARG = "--resume-session-at";
|
||||
const CLAUDE_RESUME_SHORT_ARG = "-r";
|
||||
const CLAUDE_CONTINUE_ARG = "--continue";
|
||||
const CLAUDE_CONTINUE_SHORT_ARG = "-c";
|
||||
const CLAUDE_FORK_SESSION_ARG = "--fork-session";
|
||||
const CLAUDE_SAFE_SETTING_SOURCES = "user";
|
||||
const CLAUDE_BYPASS_PERMISSION_MODE = "bypassPermissions";
|
||||
const CLAUDE_DEFAULT_PERMISSION_MODE = "default";
|
||||
const CLAUDE_NO_TOOLS_VALUE = "";
|
||||
const CLAUDE_DENY_MCP_TOOLS_VALUE = "mcp__*";
|
||||
|
||||
type ClaudeCliEffort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
/** Explicit thinking opt-out for Claude CLI routes unsupported by Claude Code. */
|
||||
export const CLAUDE_CLI_OFF_THINKING_PROFILE = {
|
||||
levels: [{ id: "off" }],
|
||||
defaultLevel: "off",
|
||||
} as const;
|
||||
|
||||
/** Return whether a provider id refers to the Claude CLI backend. */
|
||||
export function isClaudeCliProvider(providerId: string): boolean {
|
||||
return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID;
|
||||
}
|
||||
|
||||
function isOpenClawRequestedYolo(context?: CliBackendNormalizeConfigContext): boolean {
|
||||
const agentExec = context?.agentId
|
||||
? context.config?.agents?.list?.find((agent) => agent.id === context.agentId)?.tools?.exec
|
||||
: undefined;
|
||||
const exec = agentExec ?? context?.config?.tools?.exec;
|
||||
const security = exec?.security ?? "full";
|
||||
const ask = exec?.ask ?? "off";
|
||||
return security === "full" && ask === "off";
|
||||
}
|
||||
|
||||
/** Resolve Claude permission mode from OpenClaw exec security settings. */
|
||||
export function resolveClaudePermissionMode(context?: CliBackendNormalizeConfigContext): {
|
||||
mode?: string;
|
||||
overrideExisting: boolean;
|
||||
} {
|
||||
return isOpenClawRequestedYolo(context)
|
||||
? { mode: CLAUDE_BYPASS_PERMISSION_MODE, overrideExisting: false }
|
||||
: { overrideExisting: false };
|
||||
}
|
||||
|
||||
/** Normalize Claude permission arguments, removing legacy skip-permissions flags. */
|
||||
export function normalizeClaudePermissionArgs(
|
||||
args?: string[],
|
||||
options?: { mode?: string; overrideExisting?: boolean },
|
||||
): string[] | undefined {
|
||||
if (!args) {
|
||||
return options?.mode ? [CLAUDE_PERMISSION_MODE_ARG, options.mode] : args;
|
||||
}
|
||||
const normalized: string[] = [];
|
||||
let hasPermissionMode = false;
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === CLAUDE_LEGACY_SKIP_PERMISSIONS_ARG) {
|
||||
continue;
|
||||
}
|
||||
if (arg === CLAUDE_PERMISSION_MODE_ARG) {
|
||||
const maybeValue = args[i + 1];
|
||||
if (
|
||||
typeof maybeValue === "string" &&
|
||||
maybeValue.trim().length > 0 &&
|
||||
!maybeValue.startsWith("-")
|
||||
) {
|
||||
hasPermissionMode = true;
|
||||
if (!options?.overrideExisting) {
|
||||
normalized.push(arg);
|
||||
normalized.push(maybeValue);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
|
||||
const maybeValue = arg.slice(`${CLAUDE_PERMISSION_MODE_ARG}=`.length).trim();
|
||||
if (maybeValue.length > 0 && !maybeValue.startsWith("-")) {
|
||||
hasPermissionMode = true;
|
||||
if (!options?.overrideExisting) {
|
||||
normalized.push(`${CLAUDE_PERMISSION_MODE_ARG}=${maybeValue}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
normalized.push(arg);
|
||||
}
|
||||
if (options?.mode && (!hasPermissionMode || options.overrideExisting)) {
|
||||
normalized.push(CLAUDE_PERMISSION_MODE_ARG, options.mode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Ensure Claude CLI setting sources stay restricted to user settings. */
|
||||
export function normalizeClaudeSettingSourcesArgs(args?: string[]): string[] | undefined {
|
||||
if (!args) {
|
||||
return args;
|
||||
}
|
||||
const normalized: string[] = [];
|
||||
let hasSettingSources = false;
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === CLAUDE_SETTING_SOURCES_ARG) {
|
||||
const maybeValue = args[i + 1];
|
||||
if (
|
||||
typeof maybeValue === "string" &&
|
||||
maybeValue.trim().length > 0 &&
|
||||
!maybeValue.startsWith("-")
|
||||
) {
|
||||
hasSettingSources = true;
|
||||
normalized.push(arg, CLAUDE_SAFE_SETTING_SOURCES);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith(`${CLAUDE_SETTING_SOURCES_ARG}=`)) {
|
||||
hasSettingSources = true;
|
||||
normalized.push(`${CLAUDE_SETTING_SOURCES_ARG}=${CLAUDE_SAFE_SETTING_SOURCES}`);
|
||||
continue;
|
||||
}
|
||||
normalized.push(arg);
|
||||
}
|
||||
if (!hasSettingSources) {
|
||||
normalized.push(CLAUDE_SETTING_SOURCES_ARG, CLAUDE_SAFE_SETTING_SOURCES);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Map OpenClaw thinking levels to Claude CLI effort flags for a model id. */
|
||||
export function mapClaudeCliThinkingLevelToEffort(
|
||||
thinkingLevel?: string | null,
|
||||
): ClaudeCliEffort | undefined {
|
||||
switch (normalizeOptionalLowercaseString(thinkingLevel)) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "low";
|
||||
case "adaptive":
|
||||
case "medium":
|
||||
return "medium";
|
||||
case "high":
|
||||
return "high";
|
||||
case "xhigh":
|
||||
return "xhigh";
|
||||
case "max":
|
||||
return "max";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stripClaudeEffortArgs(args: readonly string[]): string[] {
|
||||
const normalized: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i] ?? "";
|
||||
if (arg === CLAUDE_EFFORT_ARG) {
|
||||
const maybeValue = args[i + 1];
|
||||
if (
|
||||
typeof maybeValue === "string" &&
|
||||
maybeValue.trim().length > 0 &&
|
||||
!maybeValue.startsWith("-")
|
||||
) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith(`${CLAUDE_EFFORT_ARG}=`)) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(arg);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const CLAUDE_SIDE_QUESTION_VARIADIC_VALUE_ARGS = new Set([
|
||||
"--allowedTools",
|
||||
"--allowed-tools",
|
||||
CLAUDE_DISALLOWED_TOOLS_ARG,
|
||||
"--disallowed-tools",
|
||||
CLAUDE_TOOLS_ARG,
|
||||
CLAUDE_MCP_CONFIG_ARG,
|
||||
]);
|
||||
|
||||
const CLAUDE_SIDE_QUESTION_VALUE_ARGS = new Set([
|
||||
CLAUDE_PERMISSION_MODE_ARG,
|
||||
CLAUDE_SESSION_ID_ARG,
|
||||
CLAUDE_RESUME_ARG,
|
||||
CLAUDE_RESUME_SESSION_AT_ARG,
|
||||
CLAUDE_RESUME_SHORT_ARG,
|
||||
CLAUDE_MAX_TURNS_ARG,
|
||||
]);
|
||||
|
||||
const CLAUDE_SIDE_QUESTION_BARE_ARGS = new Set([
|
||||
CLAUDE_CONTINUE_ARG,
|
||||
CLAUDE_CONTINUE_SHORT_ARG,
|
||||
CLAUDE_FORK_SESSION_ARG,
|
||||
CLAUDE_BARE_ARG,
|
||||
CLAUDE_SAFE_MODE_ARG,
|
||||
CLAUDE_STRICT_MCP_CONFIG_ARG,
|
||||
CLAUDE_NO_SESSION_PERSISTENCE_ARG,
|
||||
]);
|
||||
|
||||
function stripClaudeSideQuestionConflictingArgs(args: readonly string[]): string[] {
|
||||
const normalized: string[] = [];
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i] ?? "";
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
const argName = equalsIndex > 0 ? arg.slice(0, equalsIndex) : arg;
|
||||
if (CLAUDE_SIDE_QUESTION_BARE_ARGS.has(argName)) {
|
||||
continue;
|
||||
}
|
||||
if (CLAUDE_SIDE_QUESTION_VARIADIC_VALUE_ARGS.has(argName)) {
|
||||
if (equalsIndex < 0) {
|
||||
while (typeof args[i + 1] === "string" && !args[i + 1]?.startsWith("-")) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (CLAUDE_SIDE_QUESTION_VALUE_ARGS.has(argName)) {
|
||||
if (equalsIndex < 0) {
|
||||
const maybeValue = args[i + 1];
|
||||
if (typeof maybeValue === "string" && !maybeValue.startsWith("-")) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
normalized.push(arg);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveClaudeCliSideQuestionExecutionArgs(baseArgs: readonly string[]): string[] {
|
||||
return [
|
||||
...stripClaudeSideQuestionConflictingArgs(stripClaudeEffortArgs(baseArgs)),
|
||||
CLAUDE_SAFE_MODE_ARG,
|
||||
CLAUDE_TOOLS_ARG,
|
||||
CLAUDE_NO_TOOLS_VALUE,
|
||||
CLAUDE_DISALLOWED_TOOLS_ARG,
|
||||
CLAUDE_DENY_MCP_TOOLS_VALUE,
|
||||
CLAUDE_STRICT_MCP_CONFIG_ARG,
|
||||
CLAUDE_NO_SESSION_PERSISTENCE_ARG,
|
||||
CLAUDE_MAX_TURNS_ARG,
|
||||
"1",
|
||||
CLAUDE_PERMISSION_MODE_ARG,
|
||||
CLAUDE_DEFAULT_PERMISSION_MODE,
|
||||
];
|
||||
}
|
||||
|
||||
/** Resolve final Claude CLI execution args for one backend invocation. */
|
||||
export function resolveClaudeCliExecutionArgs(
|
||||
context: CliBackendResolveExecutionArgsContext,
|
||||
): string[] {
|
||||
if (context.executionMode === "side-question") {
|
||||
return resolveClaudeCliSideQuestionExecutionArgs(context.baseArgs);
|
||||
}
|
||||
const effort = mapClaudeCliThinkingLevelToEffort(context.thinkingLevel);
|
||||
if (!effort) {
|
||||
return [...context.baseArgs];
|
||||
}
|
||||
return [...stripClaudeEffortArgs(context.baseArgs), CLAUDE_EFFORT_ARG, effort];
|
||||
}
|
||||
|
||||
/** Normalize Claude CLI backend config before registration or execution. */
|
||||
export function normalizeClaudeBackendConfig(
|
||||
config: CliBackendConfig,
|
||||
context?: CliBackendNormalizeConfigContext,
|
||||
): CliBackendConfig {
|
||||
const output = config.output ?? "jsonl";
|
||||
const input = config.input ?? "stdin";
|
||||
const permission = resolveClaudePermissionMode(context);
|
||||
return {
|
||||
...config,
|
||||
args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args), permission),
|
||||
resumeArgs: normalizeClaudePermissionArgs(
|
||||
normalizeClaudeSettingSourcesArgs(config.resumeArgs),
|
||||
permission,
|
||||
),
|
||||
output,
|
||||
liveSession:
|
||||
config.liveSession ?? (output === "jsonl" && input === "stdin" ? "claude-stdio" : undefined),
|
||||
input,
|
||||
};
|
||||
}
|
||||
427
extensions/anthropic/config-defaults.ts
Normal file
427
extensions/anthropic/config-defaults.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Anthropic config defaulting helpers. They seed default Anthropic/Claude CLI
|
||||
* model refs and cache-retention params based on configured auth mode.
|
||||
*/
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
isRecord,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
resolveClaudeCliAnthropicModelRefs,
|
||||
resolveKnownAnthropicModelRef,
|
||||
} from "./claude-model-refs.js";
|
||||
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;
|
||||
|
||||
function normalizeProviderId(provider: string): string {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(provider);
|
||||
if (normalized === "bedrock" || normalized === "aws-bedrock") {
|
||||
return "amazon-bedrock";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveAnthropicDefaultAuthMode(
|
||||
config: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): "api_key" | "oauth" | null {
|
||||
const profiles = config.auth?.profiles ?? {};
|
||||
const anthropicProfiles = Object.entries(profiles).filter(
|
||||
([, profile]) =>
|
||||
profile?.provider === "anthropic" || profile?.provider === CLAUDE_CLI_BACKEND_ID,
|
||||
);
|
||||
|
||||
const order = [
|
||||
...(config.auth?.order?.anthropic ?? []),
|
||||
...((config.auth?.order as Record<string, string[] | undefined> | undefined)?.[
|
||||
CLAUDE_CLI_BACKEND_ID
|
||||
] ?? []),
|
||||
];
|
||||
for (const profileId of order) {
|
||||
const entry = profiles[profileId];
|
||||
if (!entry || (entry.provider !== "anthropic" && entry.provider !== CLAUDE_CLI_BACKEND_ID)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.provider === CLAUDE_CLI_BACKEND_ID) {
|
||||
return "oauth";
|
||||
}
|
||||
if (entry.mode === "api_key") {
|
||||
return "api_key";
|
||||
}
|
||||
if (entry.mode === "oauth" || entry.mode === "token") {
|
||||
return "oauth";
|
||||
}
|
||||
}
|
||||
|
||||
const hasApiKey = anthropicProfiles.some(
|
||||
([, profile]) => profile?.provider === "anthropic" && profile?.mode === "api_key",
|
||||
);
|
||||
const hasOauth = anthropicProfiles.some(
|
||||
([, profile]) =>
|
||||
profile?.provider === CLAUDE_CLI_BACKEND_ID ||
|
||||
profile?.mode === "oauth" ||
|
||||
profile?.mode === "token",
|
||||
);
|
||||
if (hasApiKey && !hasOauth) {
|
||||
return "api_key";
|
||||
}
|
||||
if (hasOauth && !hasApiKey) {
|
||||
return "oauth";
|
||||
}
|
||||
|
||||
if (env.ANTHROPIC_OAUTH_TOKEN?.trim()) {
|
||||
return "oauth";
|
||||
}
|
||||
if (env.ANTHROPIC_API_KEY?.trim()) {
|
||||
return "api_key";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveModelPrimaryValue(
|
||||
value: string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
const primary = value?.primary;
|
||||
if (typeof primary !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = primary.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseProviderModelRef(
|
||||
raw: string,
|
||||
defaultProvider: string,
|
||||
): { provider: string; model: string } | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const slashIndex = trimmed.indexOf("/");
|
||||
if (slashIndex <= 0) {
|
||||
return { provider: defaultProvider, model: trimmed };
|
||||
}
|
||||
const provider = trimmed.slice(0, slashIndex).trim();
|
||||
const model = trimmed.slice(slashIndex + 1).trim();
|
||||
if (!provider || !model) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: normalizeProviderId(provider),
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
function isAnthropicCacheRetentionTarget(
|
||||
parsed: { provider: string; model: string } | null | undefined,
|
||||
): parsed is { provider: string; model: string } {
|
||||
return Boolean(
|
||||
parsed &&
|
||||
(parsed.provider === "anthropic" ||
|
||||
(parsed.provider === "amazon-bedrock" &&
|
||||
normalizeLowercaseStringOrEmpty(parsed.model).includes("anthropic.claude"))),
|
||||
);
|
||||
}
|
||||
|
||||
function usesClaudeCliModelSelection(config: OpenClawConfig): boolean {
|
||||
const primary = resolveModelPrimaryValue(
|
||||
config.agents?.defaults?.model as
|
||||
| string
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined,
|
||||
);
|
||||
const parsedPrimary = primary ? parseProviderModelRef(primary, "anthropic") : null;
|
||||
if (parsedPrimary?.provider === CLAUDE_CLI_BACKEND_ID) {
|
||||
return true;
|
||||
}
|
||||
return Object.entries(config.agents?.defaults?.models ?? {}).some(([key, entry]) => {
|
||||
const parsed = parseProviderModelRef(key, "anthropic");
|
||||
if (parsed?.provider === CLAUDE_CLI_BACKEND_ID) {
|
||||
return true;
|
||||
}
|
||||
const runtimeId = isRecord(entry?.agentRuntime) ? entry.agentRuntime.id : undefined;
|
||||
return (
|
||||
parsed?.provider === "anthropic" &&
|
||||
normalizeLowercaseStringOrEmpty(runtimeId) === CLAUDE_CLI_BACKEND_ID
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function usesSelectedClaudeCliAuthProfile(config: OpenClawConfig): boolean {
|
||||
const profiles = config.auth?.profiles ?? {};
|
||||
const orderedProfileIds = [
|
||||
...(config.auth?.order?.anthropic ?? []),
|
||||
...((config.auth?.order as Record<string, string[] | undefined> | undefined)?.[
|
||||
CLAUDE_CLI_BACKEND_ID
|
||||
] ?? []),
|
||||
];
|
||||
for (const profileId of orderedProfileIds) {
|
||||
const provider = profiles[profileId]?.provider;
|
||||
if (provider === CLAUDE_CLI_BACKEND_ID) {
|
||||
return true;
|
||||
}
|
||||
if (provider === "anthropic") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let hasClaudeCliProfile = false;
|
||||
let hasAnthropicProfile = false;
|
||||
for (const profile of Object.values(profiles)) {
|
||||
if (profile?.provider === CLAUDE_CLI_BACKEND_ID) {
|
||||
hasClaudeCliProfile = true;
|
||||
}
|
||||
if (profile?.provider === "anthropic") {
|
||||
hasAnthropicProfile = true;
|
||||
}
|
||||
}
|
||||
return hasClaudeCliProfile && !hasAnthropicProfile;
|
||||
}
|
||||
|
||||
function toCanonicalAnthropicModelRef(ref: string): string {
|
||||
return ref.startsWith(`${CLAUDE_CLI_BACKEND_ID}/`)
|
||||
? `anthropic/${ref.slice(CLAUDE_CLI_BACKEND_ID.length + 1)}`
|
||||
: ref;
|
||||
}
|
||||
|
||||
function modelEntryWithClaudeCliRuntime(entry: unknown): Record<string, unknown> {
|
||||
const base = isRecord(entry) ? { ...entry } : {};
|
||||
const currentRuntimeId = isRecord(base.agentRuntime) ? base.agentRuntime.id : undefined;
|
||||
const currentRuntime = normalizeLowercaseStringOrEmpty(currentRuntimeId);
|
||||
if (currentRuntime && currentRuntime !== "auto") {
|
||||
return base;
|
||||
}
|
||||
base.agentRuntime = {
|
||||
...(isRecord(base.agentRuntime) ? base.agentRuntime : {}),
|
||||
id: CLAUDE_CLI_BACKEND_ID,
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
function collectClaudeCliRuntimeRefs(
|
||||
model: string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
): string[] {
|
||||
const refs = new Set<string>();
|
||||
if (typeof model === "string") {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(model)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
if (typeof model?.primary === "string") {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(model.primary)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
for (const fallback of model?.fallbacks ?? []) {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(fallback)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
function collectClaudeCliRuntimeRefsFromModelMap(
|
||||
models: Record<string, unknown> | undefined,
|
||||
): string[] {
|
||||
const refs = new Set<string>();
|
||||
for (const key of Object.keys(models ?? {})) {
|
||||
for (const ref of resolveClaudeCliAnthropicModelRefs(key)?.runtimeRefs ?? []) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
function collectClaudeCliRuntimeRefsFromConfig(config: OpenClawConfig): string[] {
|
||||
const refs = new Set<string>(
|
||||
collectClaudeCliRuntimeRefs(
|
||||
config.agents?.defaults?.model as
|
||||
| string
|
||||
| { primary?: string; fallbacks?: string[] }
|
||||
| undefined,
|
||||
),
|
||||
);
|
||||
for (const ref of collectClaudeCliRuntimeRefsFromModelMap(config.agents?.defaults?.models)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
for (const agent of config.agents?.list ?? []) {
|
||||
for (const ref of collectClaudeCliRuntimeRefs(
|
||||
agent.model as string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
for (const ref of collectClaudeCliRuntimeRefsFromModelMap(agent.models)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
function normalizeAnthropicProviderConfig<T extends { api?: string; models?: unknown[] }>(
|
||||
providerConfig: T,
|
||||
): T {
|
||||
if (
|
||||
providerConfig.api ||
|
||||
!Array.isArray(providerConfig.models) ||
|
||||
providerConfig.models.length === 0
|
||||
) {
|
||||
return providerConfig;
|
||||
}
|
||||
return { ...providerConfig, api: ANTHROPIC_PROVIDER_API };
|
||||
}
|
||||
|
||||
/** Normalize Anthropic provider config defaults for one provider entry. */
|
||||
export function normalizeAnthropicProviderConfigForProvider<
|
||||
T extends { api?: string; models?: unknown[] },
|
||||
>(params: { provider: string; providerConfig: T }): T {
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
if (provider !== "anthropic" && provider !== CLAUDE_CLI_BACKEND_ID) {
|
||||
return params.providerConfig;
|
||||
}
|
||||
return normalizeAnthropicProviderConfig(params.providerConfig);
|
||||
}
|
||||
|
||||
/** Apply Anthropic and Claude CLI defaults to an OpenClaw config object. */
|
||||
export function applyAnthropicConfigDefaults(params: {
|
||||
config: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): OpenClawConfig {
|
||||
const defaults = params.config.agents?.defaults;
|
||||
if (!defaults) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
const authMode = resolveAnthropicDefaultAuthMode(params.config, params.env);
|
||||
if (!authMode) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
let mutated = false;
|
||||
const nextDefaults = { ...defaults };
|
||||
const contextPruning = defaults.contextPruning ?? {};
|
||||
const heartbeat = defaults.heartbeat ?? {};
|
||||
|
||||
if (defaults.contextPruning?.mode === undefined) {
|
||||
nextDefaults.contextPruning = {
|
||||
...contextPruning,
|
||||
mode: "cache-ttl",
|
||||
ttl: defaults.contextPruning?.ttl ?? "1h",
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (defaults.heartbeat?.every === undefined) {
|
||||
nextDefaults.heartbeat = {
|
||||
...heartbeat,
|
||||
every: authMode === "oauth" ? "1h" : "30m",
|
||||
};
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (authMode === "api_key") {
|
||||
const nextModels = defaults.models ? { ...defaults.models } : {};
|
||||
let modelsMutated = false;
|
||||
|
||||
for (const [key, entry] of Object.entries(nextModels)) {
|
||||
const parsed = parseProviderModelRef(key, "anthropic");
|
||||
if (!isAnthropicCacheRetentionTarget(parsed)) {
|
||||
continue;
|
||||
}
|
||||
const current = entry ?? {};
|
||||
const paramsValue = (current as { params?: Record<string, unknown> }).params ?? {};
|
||||
if (typeof paramsValue.cacheRetention === "string") {
|
||||
continue;
|
||||
}
|
||||
nextModels[key] = {
|
||||
...(current as Record<string, unknown>),
|
||||
params: { ...paramsValue, cacheRetention: "short" },
|
||||
};
|
||||
modelsMutated = true;
|
||||
}
|
||||
|
||||
const primary = resolveKnownAnthropicModelRef(
|
||||
resolveModelPrimaryValue(
|
||||
defaults.model as string | { primary?: string; fallbacks?: string[] } | undefined,
|
||||
),
|
||||
);
|
||||
if (primary) {
|
||||
const parsedPrimary = parseProviderModelRef(primary, "anthropic");
|
||||
if (parsedPrimary && isAnthropicCacheRetentionTarget(parsedPrimary)) {
|
||||
const key = `${parsedPrimary.provider}/${parsedPrimary.model}`;
|
||||
const entry = nextModels[key];
|
||||
const current = entry ?? {};
|
||||
const paramsValue = (current as { params?: Record<string, unknown> }).params ?? {};
|
||||
if (typeof paramsValue.cacheRetention !== "string") {
|
||||
nextModels[key] = {
|
||||
...(current as Record<string, unknown>),
|
||||
params: { ...paramsValue, cacheRetention: "short" },
|
||||
};
|
||||
modelsMutated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasAnthropicApiKeyModel = Object.keys(nextModels).some((key) =>
|
||||
isAnthropicCacheRetentionTarget(parseProviderModelRef(key, "anthropic")),
|
||||
);
|
||||
if (hasAnthropicApiKeyModel) {
|
||||
for (const ref of ANTHROPIC_API_KEY_DEFAULT_ALLOWLIST_REFS) {
|
||||
if (ref in nextModels) {
|
||||
continue;
|
||||
}
|
||||
nextModels[ref] = { params: { cacheRetention: "short" } };
|
||||
modelsMutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsMutated) {
|
||||
nextDefaults.models = nextModels;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
authMode === "oauth" &&
|
||||
(usesClaudeCliModelSelection(params.config) || usesSelectedClaudeCliAuthProfile(params.config))
|
||||
) {
|
||||
const nextModels = defaults.models ? { ...defaults.models } : {};
|
||||
let modelsMutated = false;
|
||||
const runtimeRefs = new Set<string>(collectClaudeCliRuntimeRefsFromConfig(params.config));
|
||||
for (const rawRef of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
|
||||
runtimeRefs.add(toCanonicalAnthropicModelRef(rawRef));
|
||||
}
|
||||
for (const ref of runtimeRefs) {
|
||||
const current = nextModels[ref];
|
||||
const updated = modelEntryWithClaudeCliRuntime(current);
|
||||
if (JSON.stringify(updated) === JSON.stringify(current ?? {})) {
|
||||
continue;
|
||||
}
|
||||
nextModels[ref] = updated;
|
||||
modelsMutated = true;
|
||||
}
|
||||
if (modelsMutated) {
|
||||
nextDefaults.models = nextModels;
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mutated) {
|
||||
return params.config;
|
||||
}
|
||||
|
||||
return {
|
||||
...params.config,
|
||||
agents: {
|
||||
...params.config.agents,
|
||||
defaults: nextDefaults,
|
||||
},
|
||||
};
|
||||
}
|
||||
13
extensions/anthropic/contract-api.ts
Normal file
13
extensions/anthropic/contract-api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Contract API barrel for Anthropic stream wrapper helpers. Tests and contract
|
||||
* checks import this lightweight path instead of the full provider entry.
|
||||
*/
|
||||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
wrapAnthropicProviderStream,
|
||||
} from "./stream-wrappers.js";
|
||||
20
extensions/anthropic/doctor-contract-api.ts
Normal file
20
extensions/anthropic/doctor-contract-api.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Doctor contract metadata for Anthropic and Claude CLI state. It declares
|
||||
* session/auth ownership so doctor cleanup can route stale state correctly.
|
||||
*/
|
||||
import type { DoctorSessionRouteStateOwner } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
|
||||
/** Anthropic currently has no legacy config migrations. */
|
||||
export const legacyConfigRules = [];
|
||||
|
||||
/** Session-route ownership metadata for Anthropic API and Claude CLI sessions. */
|
||||
export const sessionRouteStateOwners: DoctorSessionRouteStateOwner[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
providerIds: ["anthropic", "claude-cli"],
|
||||
runtimeIds: ["claude-cli"],
|
||||
cliSessionKeys: ["claude-cli"],
|
||||
authProfilePrefixes: ["anthropic:", "claude-cli:"],
|
||||
},
|
||||
];
|
||||
1030
extensions/anthropic/index.test.ts
Normal file
1030
extensions/anthropic/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
16
extensions/anthropic/index.ts
Normal file
16
extensions/anthropic/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Anthropic provider plugin entry. It registers Claude API auth, Claude CLI
|
||||
* backend support, media understanding, stream wrappers, and usage reporting.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerAnthropicPlugin } from "./register.runtime.js";
|
||||
|
||||
/** Provider entry for Anthropic API and Claude CLI runtime surfaces. */
|
||||
export default definePluginEntry({
|
||||
id: "anthropic",
|
||||
name: "Anthropic Provider",
|
||||
description: "Bundled Anthropic provider plugin",
|
||||
register(api) {
|
||||
return registerAnthropicPlugin(api);
|
||||
},
|
||||
});
|
||||
20
extensions/anthropic/media-understanding-provider.ts
Normal file
20
extensions/anthropic/media-understanding-provider.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Anthropic media-understanding provider descriptor. It routes image and native
|
||||
* document description through the shared model-backed media helpers.
|
||||
*/
|
||||
import {
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
type MediaUnderstandingProvider,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
|
||||
/** Media-understanding provider for Anthropic Claude models. */
|
||||
export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = {
|
||||
id: "anthropic",
|
||||
capabilities: ["image"],
|
||||
defaultModels: { image: "claude-opus-4-8" },
|
||||
autoPriority: { image: 20 },
|
||||
nativeDocumentInputs: ["pdf"],
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
};
|
||||
265
extensions/anthropic/openclaw.plugin.json
Normal file
265
extensions/anthropic/openclaw.plugin.json
Normal file
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"id": "anthropic",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["anthropic"],
|
||||
"providerCatalogEntry": "./provider-discovery.ts",
|
||||
"modelCatalog": {
|
||||
"runtimeAugment": true,
|
||||
"providers": {
|
||||
"claude-cli": {
|
||||
"models": [
|
||||
{
|
||||
"id": "claude-opus-4-8",
|
||||
"name": "Claude Opus 4.8 (Claude CLI)",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 128000
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-7",
|
||||
"name": "Claude Opus 4.7 (Claude CLI)",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-6",
|
||||
"name": "Claude Sonnet 4.6 (Claude CLI)",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6 (Claude CLI)",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
}
|
||||
]
|
||||
},
|
||||
"anthropic": {
|
||||
"baseUrl": "https://api.anthropic.com",
|
||||
"api": "anthropic-messages",
|
||||
"models": [
|
||||
{
|
||||
"id": "claude-fable-5",
|
||||
"name": "Claude Fable 5",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
|
||||
},
|
||||
"cost": { "input": 10, "output": 50, "cacheRead": 1, "cacheWrite": 12.5 },
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 128000,
|
||||
"thinkingLevelMap": {
|
||||
"off": "low",
|
||||
"minimal": "low",
|
||||
"xhigh": "xhigh",
|
||||
"max": "max"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-8",
|
||||
"name": "Claude Opus 4.8",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 128000
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-7",
|
||||
"name": "Claude Opus 4.7",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-4-5",
|
||||
"name": "Claude Haiku 4.5",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"name": "Claude Haiku 4.5",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-6",
|
||||
"name": "Claude Sonnet 4.6",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-opus-4-6",
|
||||
"name": "Claude Opus 4.6",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"claude-cli": "static",
|
||||
"anthropic": "static"
|
||||
}
|
||||
},
|
||||
"modelSupport": {
|
||||
"modelPrefixes": ["claude-"]
|
||||
},
|
||||
"modelIdNormalization": {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"aliases": {
|
||||
"opus-4.8": "claude-opus-4-8",
|
||||
"opus": "claude-opus-4-8",
|
||||
"opus-4.6": "claude-opus-4-6",
|
||||
"sonnet-4.6": "claude-sonnet-4-6"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"openRouter": {
|
||||
"modelIdTransforms": ["version-dots"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "anthropic-public",
|
||||
"hosts": ["api.anthropic.com"]
|
||||
}
|
||||
],
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"family": "anthropic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cliBackends": ["claude-cli"],
|
||||
"syntheticAuthRefs": ["claude-cli"],
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "anthropic",
|
||||
"envVars": ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"method": "cli",
|
||||
"choiceId": "anthropic-cli",
|
||||
"deprecatedChoiceIds": ["claude-cli"],
|
||||
"choiceLabel": "Anthropic Claude CLI",
|
||||
"choiceHint": "Reuse a local Claude CLI login on this host",
|
||||
"assistantPriority": -20,
|
||||
"groupId": "anthropic",
|
||||
"groupLabel": "Anthropic",
|
||||
"groupHint": "Claude CLI + API key",
|
||||
"onboardingFeatured": true
|
||||
},
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"method": "setup-token",
|
||||
"choiceId": "setup-token",
|
||||
"choiceLabel": "Anthropic setup-token",
|
||||
"choiceHint": "Manual token path",
|
||||
"assistantPriority": 40,
|
||||
"groupId": "anthropic",
|
||||
"groupLabel": "Anthropic",
|
||||
"groupHint": "Claude CLI + API key + token",
|
||||
"onboardingFeatured": true
|
||||
},
|
||||
{
|
||||
"provider": "anthropic",
|
||||
"method": "api-key",
|
||||
"choiceId": "apiKey",
|
||||
"choiceLabel": "Anthropic API key",
|
||||
"groupId": "anthropic",
|
||||
"groupLabel": "Anthropic",
|
||||
"groupHint": "Claude CLI + API key",
|
||||
"onboardingFeatured": true,
|
||||
"optionKey": "anthropicApiKey",
|
||||
"cliFlag": "--anthropic-api-key",
|
||||
"cliOption": "--anthropic-api-key <key>",
|
||||
"cliDescription": "Anthropic API key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["anthropic"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"anthropic": {
|
||||
"capabilities": ["image"],
|
||||
"defaultModels": {
|
||||
"image": "claude-opus-4-8"
|
||||
},
|
||||
"autoPriority": {
|
||||
"image": 20
|
||||
},
|
||||
"nativeDocumentInputs": ["pdf"]
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
57
extensions/anthropic/openclaw.plugin.test.ts
Normal file
57
extensions/anthropic/openclaw.plugin.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Anthropic tests cover provider manifest model catalog behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type AnthropicManifest = {
|
||||
modelCatalog?: {
|
||||
providers?: {
|
||||
anthropic?: {
|
||||
models?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
input?: string[];
|
||||
mediaInput?: {
|
||||
image?: {
|
||||
maxSidePx?: number;
|
||||
preferredSidePx?: number;
|
||||
tokenMode?: string;
|
||||
};
|
||||
};
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
discovery?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as AnthropicManifest;
|
||||
|
||||
describe("Anthropic plugin manifest", () => {
|
||||
it("resolves both official Claude Haiku 4.5 API identifiers from the static catalog", () => {
|
||||
expect(manifest.modelCatalog?.discovery?.anthropic).toBe("static");
|
||||
|
||||
const models = manifest.modelCatalog?.providers?.anthropic?.models ?? [];
|
||||
for (const id of ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]) {
|
||||
expect(models.find((model) => model.id === id)).toEqual({
|
||||
id,
|
||||
name: "Claude Haiku 4.5",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
mediaInput: {
|
||||
image: {
|
||||
maxSidePx: 1568,
|
||||
preferredSidePx: 1568,
|
||||
tokenMode: "provider",
|
||||
},
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
15
extensions/anthropic/package.json
Normal file
15
extensions/anthropic/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
64
extensions/anthropic/provider-contract-api.ts
Normal file
64
extensions/anthropic/provider-contract-api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Contract API for Anthropic provider metadata. It builds a provider descriptor
|
||||
* without runtime registration side effects.
|
||||
*/
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const noopAuth = async () => ({ profiles: [] });
|
||||
|
||||
/** Create the static Anthropic provider contract descriptor. */
|
||||
export function createAnthropicProvider(): ProviderPlugin {
|
||||
return {
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
hookAliases: ["claude-cli"],
|
||||
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "cli",
|
||||
kind: "custom",
|
||||
label: "Claude CLI",
|
||||
hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "anthropic-cli",
|
||||
choiceLabel: "Anthropic Claude CLI",
|
||||
choiceHint: "Reuse a local Claude CLI login on this host",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "setup-token",
|
||||
kind: "token",
|
||||
label: "Anthropic setup-token",
|
||||
hint: "Manual bearer token path",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "setup-token",
|
||||
choiceLabel: "Anthropic setup-token",
|
||||
choiceHint: "Manual token path",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key + token",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
hint: "Direct Anthropic API key",
|
||||
run: noopAuth,
|
||||
wizard: {
|
||||
choiceId: "apiKey",
|
||||
choiceLabel: "Anthropic API key",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
39
extensions/anthropic/provider-discovery.ts
Normal file
39
extensions/anthropic/provider-discovery.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Claude CLI provider discovery descriptor. It exposes subscription-backed
|
||||
* synthetic auth for catalog/runtime discovery without full Anthropic registration.
|
||||
*/
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { readClaudeCliCredentialsForRuntime } from "./cli-auth-seam.js";
|
||||
|
||||
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
|
||||
function resolveClaudeCliSyntheticAuth() {
|
||||
const credential = readClaudeCliCredentialsForRuntime();
|
||||
if (!credential) {
|
||||
return undefined;
|
||||
}
|
||||
return credential.type === "oauth"
|
||||
? {
|
||||
apiKey: credential.access,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "oauth" as const,
|
||||
expiresAt: credential.expires,
|
||||
}
|
||||
: {
|
||||
apiKey: credential.token,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "token" as const,
|
||||
expiresAt: credential.expires,
|
||||
};
|
||||
}
|
||||
|
||||
const anthropicProviderDiscovery: ProviderPlugin = {
|
||||
id: CLAUDE_CLI_BACKEND_ID,
|
||||
label: "Claude CLI",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
resolveSyntheticAuth: ({ provider }) =>
|
||||
provider === CLAUDE_CLI_BACKEND_ID ? resolveClaudeCliSyntheticAuth() : undefined,
|
||||
};
|
||||
|
||||
export default anthropicProviderDiscovery;
|
||||
205
extensions/anthropic/provider-policy-api.test.ts
Normal file
205
extensions/anthropic/provider-policy-api.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// Anthropic tests cover provider policy api plugin behavior.
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyConfigDefaults,
|
||||
normalizeConfig,
|
||||
resolveThinkingProfile,
|
||||
} from "./provider-policy-api.js";
|
||||
|
||||
function createModel(id: string, name: string): ModelDefinitionConfig {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
}
|
||||
|
||||
function collectLegacyExtendedLevelIds(levels: readonly { id: string }[] | undefined): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const level of levels ?? []) {
|
||||
if (level.id === "xhigh" || level.id === "max") {
|
||||
ids.push(level.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function levelIds(levels: readonly { id: string }[] | undefined): string[] {
|
||||
return (levels ?? []).map((level) => level.id);
|
||||
}
|
||||
|
||||
describe("anthropic provider policy public artifact", () => {
|
||||
it("normalizes Anthropic provider config", () => {
|
||||
const normalized = normalizeConfig({
|
||||
provider: "anthropic",
|
||||
providerConfig: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
models: [createModel("claude-sonnet-4-6", "Claude Sonnet 4.6")],
|
||||
},
|
||||
});
|
||||
expect(normalized.api).toBe("anthropic-messages");
|
||||
expect(normalized.baseUrl).toBe("https://api.anthropic.com");
|
||||
});
|
||||
|
||||
it("normalizes Claude CLI provider config", () => {
|
||||
const normalized = normalizeConfig({
|
||||
provider: "claude-cli",
|
||||
providerConfig: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
models: [createModel("claude-sonnet-4-6", "Claude Sonnet 4.6")],
|
||||
},
|
||||
});
|
||||
expect(normalized.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("does not normalize non-Anthropic provider config", () => {
|
||||
const providerConfig = {
|
||||
baseUrl: "https://chatgpt.com/backend-api/codex",
|
||||
models: [createModel("gpt-5.4", "GPT-5.4")],
|
||||
};
|
||||
|
||||
expect(
|
||||
normalizeConfig({
|
||||
provider: "openai",
|
||||
providerConfig,
|
||||
}),
|
||||
).toBe(providerConfig);
|
||||
});
|
||||
|
||||
it("applies Anthropic API-key defaults without loading the full provider plugin", () => {
|
||||
const nextConfig = applyConfigDefaults({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
},
|
||||
},
|
||||
order: { anthropic: ["anthropic:default"] },
|
||||
},
|
||||
agents: {
|
||||
defaults: {},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(nextConfig.agents?.defaults?.contextPruning?.mode).toBe("cache-ttl");
|
||||
expect(nextConfig.agents?.defaults?.contextPruning?.ttl).toBe("1h");
|
||||
});
|
||||
|
||||
it("adds cacheRetention defaults for dated Anthropic primary model refs", () => {
|
||||
const nextConfig = applyConfigDefaults({
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-sonnet-4-20250514" },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(
|
||||
nextConfig.agents?.defaults?.models?.["anthropic/claude-sonnet-4-6"]?.params?.cacheRetention,
|
||||
).toBe("short");
|
||||
});
|
||||
|
||||
it("exposes Claude Opus 4.8 thinking levels without loading the full provider plugin", () => {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-8",
|
||||
});
|
||||
const ids = levelIds(profile?.levels);
|
||||
expect(ids).toContain("xhigh");
|
||||
expect(ids).toContain("adaptive");
|
||||
expect(ids).toContain("max");
|
||||
expect(profile?.defaultLevel).toBe("off");
|
||||
});
|
||||
|
||||
it("exposes the always-adaptive Claude Fable 5 thinking profile", () => {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-fable-5",
|
||||
});
|
||||
|
||||
expect(profile).toEqual({
|
||||
levels: [
|
||||
{ id: "off" },
|
||||
{ id: "minimal" },
|
||||
{ id: "low" },
|
||||
{ id: "medium" },
|
||||
{ id: "high" },
|
||||
{ id: "xhigh" },
|
||||
{ id: "adaptive" },
|
||||
{ id: "max" },
|
||||
],
|
||||
defaultLevel: "high",
|
||||
preserveWhenCatalogReasoningFalse: true,
|
||||
});
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-fable-5",
|
||||
}),
|
||||
).toEqual({
|
||||
levels: [{ id: "off" }],
|
||||
defaultLevel: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not return fable-5 off-thinking profile for claude-fable-50 (prefix boundary check)", () => {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider: "claude-cli",
|
||||
modelId: "claude-fable-50",
|
||||
});
|
||||
|
||||
expect(profile).not.toBeNull();
|
||||
expect(profile?.defaultLevel).not.toBe("off");
|
||||
});
|
||||
|
||||
it("exposes native max without xhigh for direct Claude 4.6 routes", () => {
|
||||
for (const provider of ["anthropic", "claude-cli"]) {
|
||||
const profile = resolveThinkingProfile({
|
||||
provider,
|
||||
modelId: "claude-opus-4-6",
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
throw new Error(`Expected ${provider} policy profile`);
|
||||
}
|
||||
expect(levelIds(profile.levels)).toContain("adaptive");
|
||||
expect(levelIds(profile.levels)).toContain("max");
|
||||
expect(profile.defaultLevel).toBe("adaptive");
|
||||
expect(collectLegacyExtendedLevelIds(profile.levels)).toStrictEqual(["max"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not expose Anthropic thinking profiles for unrelated providers", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "openai",
|
||||
modelId: "claude-opus-4-7",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
52
extensions/anthropic/provider-policy-api.ts
Normal file
52
extensions/anthropic/provider-policy-api.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Provider-policy API for Anthropic and Claude CLI. Core calls this lightweight
|
||||
* path for config defaults and thinking profiles.
|
||||
*/
|
||||
import {
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
resolveClaudeModelIdentity,
|
||||
resolveClaudeThinkingProfile,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
import { CLAUDE_CLI_OFF_THINKING_PROFILE } from "./cli-shared.js";
|
||||
import {
|
||||
applyAnthropicConfigDefaults,
|
||||
normalizeAnthropicProviderConfigForProvider,
|
||||
} from "./config-defaults.js";
|
||||
|
||||
/** Normalize Anthropic provider config without importing runtime registration. */
|
||||
export function normalizeConfig(params: { provider: string; providerConfig: ModelProviderConfig }) {
|
||||
return normalizeAnthropicProviderConfigForProvider(params);
|
||||
}
|
||||
|
||||
/** Apply Anthropic config defaults through the provider-policy seam. */
|
||||
export function applyConfigDefaults(params: Parameters<typeof applyAnthropicConfigDefaults>[0]) {
|
||||
return applyAnthropicConfigDefaults(params);
|
||||
}
|
||||
|
||||
/** Resolve Claude thinking profile for Anthropic or Claude CLI providers. */
|
||||
export function resolveThinkingProfile(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) {
|
||||
const contractModelId = resolveClaudeModelIdentity({
|
||||
id: params.modelId,
|
||||
params: params.params,
|
||||
});
|
||||
switch (params.provider.trim().toLowerCase()) {
|
||||
case "anthropic":
|
||||
return resolveClaudeThinkingProfile(contractModelId, undefined, {
|
||||
includeNativeMax: true,
|
||||
});
|
||||
case "claude-cli":
|
||||
if (resolveClaudeFable5ModelIdentity({ id: contractModelId })) {
|
||||
return CLAUDE_CLI_OFF_THINKING_PROFILE;
|
||||
}
|
||||
return resolveClaudeThinkingProfile(contractModelId, undefined, {
|
||||
includeNativeMax: true,
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
4
extensions/anthropic/provider-runtime.contract.test.ts
Normal file
4
extensions/anthropic/provider-runtime.contract.test.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Anthropic tests cover provider runtime.contract plugin behavior.
|
||||
import { describeAnthropicProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
|
||||
describeAnthropicProviderRuntimeContract(() => import("./index.js"));
|
||||
877
extensions/anthropic/register.runtime.ts
Normal file
877
extensions/anthropic/register.runtime.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
/**
|
||||
* Anthropic provider runtime registration. It owns API-key/setup-token/Claude
|
||||
* CLI auth, dynamic model normalization, usage auth, media, and stream wrappers.
|
||||
*/
|
||||
import { formatCliCommand, parseDurationMs } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderNormalizeResolvedModelContext,
|
||||
ProviderResolveUsageAuthContext,
|
||||
ProviderResolvedUsageAuth,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
type AuthProfileStore,
|
||||
buildTokenProfileId,
|
||||
createProviderApiKeyAuthMethod,
|
||||
listProfilesForProvider,
|
||||
type OpenClawConfig as ProviderAuthConfig,
|
||||
type ProviderAuthResult,
|
||||
suggestOAuthProfileIdForLegacyDefault,
|
||||
upsertAuthProfileWithLock,
|
||||
validateAnthropicSetupToken,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
cloneFirstTemplateModel,
|
||||
NATIVE_ANTHROPIC_REPLAY_HOOKS,
|
||||
type ProviderPlugin,
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
resolveClaudeModelIdentity,
|
||||
resolveClaudeThinkingProfile,
|
||||
supportsClaudeAdaptiveThinking,
|
||||
supportsClaudeNativeMaxEffort,
|
||||
supportsClaudeNativeXhighEffort,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import * as claudeCliAuth from "./cli-auth-seam.js";
|
||||
import { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
import { buildClaudeCliCatalogEntries } from "./cli-catalog.js";
|
||||
import { buildAnthropicCliMigrationResult } from "./cli-migration.js";
|
||||
import {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
CLAUDE_CLI_OFF_THINKING_PROFILE,
|
||||
} from "./cli-shared.js";
|
||||
import {
|
||||
applyAnthropicConfigDefaults,
|
||||
normalizeAnthropicProviderConfigForProvider,
|
||||
} from "./config-defaults.js";
|
||||
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import { wrapAnthropicProviderStream } from "./stream-wrappers.js";
|
||||
|
||||
const PROVIDER_ID = "anthropic";
|
||||
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
|
||||
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-opus-4-8";
|
||||
const ANTHROPIC_OPUS_48_MODEL_ID = "claude-opus-4-8";
|
||||
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_MODERN_MAX_OUTPUT_TOKENS = 128_000;
|
||||
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 = [
|
||||
ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
ANTHROPIC_OPUS_46_DOT_MODEL_ID,
|
||||
] as const;
|
||||
const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
|
||||
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
|
||||
const ANTHROPIC_SETUP_TOKEN_NOTE_LINES = [
|
||||
"Anthropic setup-token auth is supported in OpenClaw.",
|
||||
"OpenClaw prefers Claude CLI reuse when it is available on the host.",
|
||||
"Anthropic staff told us this OpenClaw path is allowed again.",
|
||||
`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;
|
||||
|
||||
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)}`
|
||||
: ref,
|
||||
);
|
||||
|
||||
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
|
||||
const updated = await upsertAuthProfileWithLock(params);
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
"Failed to update auth profile store; the auth store lock may be busy. Wait a moment and retry.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const CLAUDE_CLI_CANONICAL_DEFAULT_MODEL_REF = CLAUDE_CLI_DEFAULT_MODEL_REF.startsWith(
|
||||
`${CLAUDE_CLI_BACKEND_ID}/`,
|
||||
)
|
||||
? `anthropic/${CLAUDE_CLI_DEFAULT_MODEL_REF.slice(CLAUDE_CLI_BACKEND_ID.length + 1)}`
|
||||
: CLAUDE_CLI_DEFAULT_MODEL_REF;
|
||||
|
||||
function normalizeAnthropicSetupTokenInput(value: string): string {
|
||||
return value.replaceAll(/\s+/g, "").trim();
|
||||
}
|
||||
|
||||
function resolveAnthropicSetupTokenProfileId(rawProfileId?: unknown): string {
|
||||
if (typeof rawProfileId === "string") {
|
||||
const trimmed = rawProfileId.trim();
|
||||
if (trimmed.length > 0) {
|
||||
if (trimmed.startsWith(`${PROVIDER_ID}:`)) {
|
||||
return trimmed;
|
||||
}
|
||||
return buildTokenProfileId({ provider: PROVIDER_ID, name: trimmed });
|
||||
}
|
||||
}
|
||||
return `${PROVIDER_ID}:default`;
|
||||
}
|
||||
|
||||
function resolveAnthropicSetupTokenExpiry(rawExpiresIn?: unknown): number | undefined {
|
||||
if (typeof rawExpiresIn !== "string" || rawExpiresIn.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveExpiresAtMsFromDurationMs(
|
||||
parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" }),
|
||||
);
|
||||
}
|
||||
|
||||
async function runAnthropicSetupTokenAuth(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
|
||||
const providedToken =
|
||||
typeof ctx.opts?.token === "string" && ctx.opts.token.trim().length > 0
|
||||
? normalizeAnthropicSetupTokenInput(ctx.opts.token)
|
||||
: undefined;
|
||||
const token =
|
||||
providedToken ??
|
||||
normalizeAnthropicSetupTokenInput(
|
||||
await ctx.prompter.text({
|
||||
message: "Paste Anthropic setup-token",
|
||||
validate: (value) => validateAnthropicSetupToken(normalizeAnthropicSetupTokenInput(value)),
|
||||
}),
|
||||
);
|
||||
const tokenError = validateAnthropicSetupToken(token);
|
||||
if (tokenError) {
|
||||
throw new Error(tokenError);
|
||||
}
|
||||
|
||||
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts?.tokenProfileId);
|
||||
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts?.tokenExpiresIn);
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: PROVIDER_ID,
|
||||
token,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: DEFAULT_ANTHROPIC_MODEL,
|
||||
notes: [...ANTHROPIC_SETUP_TOKEN_NOTE_LINES],
|
||||
};
|
||||
}
|
||||
|
||||
async function runAnthropicSetupTokenNonInteractive(
|
||||
ctx: ProviderAuthMethodNonInteractiveContext,
|
||||
): Promise<ProviderAuthConfig | null> {
|
||||
const rawToken =
|
||||
typeof ctx.opts.token === "string" ? normalizeAnthropicSetupTokenInput(ctx.opts.token) : "";
|
||||
const tokenError = validateAnthropicSetupToken(rawToken);
|
||||
if (tokenError) {
|
||||
ctx.runtime.error(
|
||||
["Anthropic setup-token auth requires --token with a valid setup-token.", tokenError].join(
|
||||
"\n",
|
||||
),
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileId = resolveAnthropicSetupTokenProfileId(ctx.opts.tokenProfileId);
|
||||
const expires = resolveAnthropicSetupTokenExpiry(ctx.opts.tokenExpiresIn);
|
||||
await upsertAuthProfileWithLockOrThrow({
|
||||
profileId,
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: PROVIDER_ID,
|
||||
token: rawToken,
|
||||
...(expires ? { expires } : {}),
|
||||
},
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
|
||||
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[0]);
|
||||
ctx.runtime.log(ANTHROPIC_SETUP_TOKEN_NOTE_LINES[1]);
|
||||
|
||||
const withProfile = applyAuthProfileConfig(ctx.config, {
|
||||
profileId,
|
||||
provider: PROVIDER_ID,
|
||||
mode: "token",
|
||||
});
|
||||
const existingModelConfig =
|
||||
withProfile.agents?.defaults?.model && typeof withProfile.agents.defaults.model === "object"
|
||||
? withProfile.agents.defaults.model
|
||||
: {};
|
||||
return {
|
||||
...withProfile,
|
||||
agents: {
|
||||
...withProfile.agents,
|
||||
defaults: {
|
||||
...withProfile.agents?.defaults,
|
||||
model: {
|
||||
...existingModelConfig,
|
||||
primary: DEFAULT_ANTHROPIC_MODEL,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnthropic46ForwardCompatModel(params: {
|
||||
ctx: ProviderResolveDynamicModelContext;
|
||||
dashModelId: string;
|
||||
dotModelId: string;
|
||||
dashTemplateId: string;
|
||||
dotTemplateId: string;
|
||||
fallbackTemplateIds: readonly string[];
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const trimmedModelId = params.ctx.modelId.trim();
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmedModelId);
|
||||
if (trimmedModelId !== lower) {
|
||||
return undefined;
|
||||
}
|
||||
const is46Model =
|
||||
lower === params.dashModelId ||
|
||||
lower === params.dotModelId ||
|
||||
lower.startsWith(`${params.dashModelId}-`) ||
|
||||
lower.startsWith(`${params.dotModelId}-`);
|
||||
if (!is46Model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const templateIds: string[] = [];
|
||||
if (lower.startsWith(params.dashModelId)) {
|
||||
templateIds.push(lower.replace(params.dashModelId, params.dashTemplateId));
|
||||
}
|
||||
if (lower.startsWith(params.dotModelId)) {
|
||||
templateIds.push(lower.replace(params.dotModelId, params.dotTemplateId));
|
||||
}
|
||||
templateIds.push(...params.fallbackTemplateIds);
|
||||
|
||||
return cloneFirstTemplateModel({
|
||||
providerId: PROVIDER_ID,
|
||||
modelId: trimmedModelId,
|
||||
templateIds,
|
||||
ctx: params.ctx,
|
||||
patch:
|
||||
normalizeLowercaseStringOrEmpty(params.ctx.provider) === CLAUDE_CLI_BACKEND_ID
|
||||
? { provider: CLAUDE_CLI_BACKEND_ID }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function buildAnthropicForwardCompatModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
const trimmedModelId = ctx.modelId.trim();
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmedModelId);
|
||||
const normalizedProvider = normalizeLowercaseStringOrEmpty(ctx.provider);
|
||||
if (trimmedModelId !== lower || !matchesAnthropicModernModel(lower)) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAnthropicFable5Model(lower) && normalizedProvider !== PROVIDER_ID) {
|
||||
return undefined;
|
||||
}
|
||||
const provider =
|
||||
normalizedProvider === CLAUDE_CLI_BACKEND_ID ? CLAUDE_CLI_BACKEND_ID : PROVIDER_ID;
|
||||
return {
|
||||
id: trimmedModelId,
|
||||
name: trimmedModelId,
|
||||
provider,
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: isAnthropicFable5Model(trimmedModelId)
|
||||
? { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }
|
||||
: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: resolveAnthropicFixedContextWindow(trimmedModelId) ?? 200_000,
|
||||
maxTokens: isAnthropic128kOutputModel(trimmedModelId)
|
||||
? ANTHROPIC_MODERN_MAX_OUTPUT_TOKENS
|
||||
: 64_000,
|
||||
...(supportsClaudeNativeXhighEffort({ id: trimmedModelId })
|
||||
? { thinkingLevelMap: { xhigh: "xhigh", max: "max" } }
|
||||
: supportsAnthropicNativeMaxEffort(trimmedModelId)
|
||||
? { thinkingLevelMap: { max: "max" } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAnthropicForwardCompatModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
return (
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_OPUS_48_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_OPUS_48_DOT_MODEL_ID,
|
||||
dashTemplateId: ANTHROPIC_OPUS_47_MODEL_ID,
|
||||
dotTemplateId: ANTHROPIC_OPUS_47_DOT_MODEL_ID,
|
||||
fallbackTemplateIds: ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS,
|
||||
}) ??
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_OPUS_47_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_OPUS_47_DOT_MODEL_ID,
|
||||
dashTemplateId: ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
dotTemplateId: ANTHROPIC_OPUS_46_DOT_MODEL_ID,
|
||||
fallbackTemplateIds: ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS,
|
||||
}) ??
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_OPUS_46_DOT_MODEL_ID,
|
||||
dashTemplateId: ANTHROPIC_OPUS_47_MODEL_ID,
|
||||
dotTemplateId: ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
fallbackTemplateIds: ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS,
|
||||
}) ??
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_SONNET_46_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID,
|
||||
dashTemplateId: ANTHROPIC_SONNET_46_MODEL_ID,
|
||||
dotTemplateId: ANTHROPIC_SONNET_46_MODEL_ID,
|
||||
fallbackTemplateIds: [ANTHROPIC_SONNET_46_MODEL_ID, ANTHROPIC_SONNET_46_DOT_MODEL_ID],
|
||||
}) ??
|
||||
buildAnthropicForwardCompatModel(ctx)
|
||||
);
|
||||
}
|
||||
|
||||
function isAnthropicGa1MModel(modelId: string): boolean {
|
||||
return supportsClaudeAdaptiveThinking({ id: modelId });
|
||||
}
|
||||
|
||||
function isAnthropicFable5Model(modelId: string): boolean {
|
||||
return resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined;
|
||||
}
|
||||
|
||||
function resolveAnthropicFixedContextWindow(modelId: string): number | undefined {
|
||||
if (isAnthropicFable5Model(modelId)) {
|
||||
return ANTHROPIC_FABLE_CONTEXT_TOKENS;
|
||||
}
|
||||
return isAnthropicGa1MModel(modelId) ? ANTHROPIC_GA_1M_CONTEXT_TOKENS : undefined;
|
||||
}
|
||||
|
||||
function isAnthropic128kOutputModel(modelId: string): boolean {
|
||||
if (isAnthropicFable5Model(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 isAnthropicMythosPreviewModel(modelId: string): boolean {
|
||||
return /(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
|
||||
resolveClaudeModelIdentity({ id: modelId }),
|
||||
);
|
||||
}
|
||||
|
||||
function supportsAnthropicNativeMaxEffort(modelId: string): boolean {
|
||||
return supportsClaudeNativeMaxEffort({ id: modelId }) || isAnthropicMythosPreviewModel(modelId);
|
||||
}
|
||||
|
||||
function hasConfiguredModelContextOverride(
|
||||
config: ProviderNormalizeResolvedModelContext["config"],
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
const providers = config?.models?.providers;
|
||||
if (!providers || typeof providers !== "object") {
|
||||
return false;
|
||||
}
|
||||
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
|
||||
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
|
||||
for (const [providerId, providerConfig] of Object.entries(providers)) {
|
||||
if (normalizeLowercaseStringOrEmpty(providerId) !== normalizedProvider) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(providerConfig?.models)) {
|
||||
continue;
|
||||
}
|
||||
for (const model of providerConfig.models) {
|
||||
if (
|
||||
normalizeLowercaseStringOrEmpty(typeof model?.id === "string" ? model.id : "") !==
|
||||
normalizedModelId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(typeof model?.contextTokens === "number" && model.contextTokens > 0) ||
|
||||
(typeof model?.contextWindow === "number" && model.contextWindow > 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function applyAnthropicFixedContextWindow(params: {
|
||||
config?: ProviderNormalizeResolvedModelContext["config"];
|
||||
provider: string;
|
||||
modelId: string;
|
||||
contractModelId: string;
|
||||
model: ProviderRuntimeModel;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const fixedContextWindow = resolveAnthropicFixedContextWindow(params.contractModelId);
|
||||
if (fixedContextWindow === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (hasConfiguredModelContextOverride(params.config, params.provider, params.modelId)) {
|
||||
return undefined;
|
||||
}
|
||||
const exactContextWindow = isAnthropicFable5Model(params.contractModelId);
|
||||
const nextContextWindow = exactContextWindow
|
||||
? fixedContextWindow
|
||||
: Math.max(params.model.contextWindow ?? 0, fixedContextWindow);
|
||||
const nextContextTokens = exactContextWindow
|
||||
? fixedContextWindow
|
||||
: typeof params.model.contextTokens === "number"
|
||||
? Math.max(params.model.contextTokens, fixedContextWindow)
|
||||
: fixedContextWindow;
|
||||
if (
|
||||
nextContextWindow === params.model.contextWindow &&
|
||||
nextContextTokens === params.model.contextTokens
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...params.model,
|
||||
contextWindow: nextContextWindow,
|
||||
contextTokens: nextContextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAnthropicModernMaxTokens(params: {
|
||||
modelId: string;
|
||||
model: ProviderRuntimeModel;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
if (!isAnthropic128kOutputModel(params.modelId)) {
|
||||
return undefined;
|
||||
}
|
||||
if ((params.model.maxTokens ?? 0) >= ANTHROPIC_MODERN_MAX_OUTPUT_TOKENS) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...params.model,
|
||||
maxTokens: ANTHROPIC_MODERN_MAX_OUTPUT_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function applyAnthropicThinkingLevelMap(params: {
|
||||
modelId: string;
|
||||
model: ProviderRuntimeModel;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const fable5 = isAnthropicFable5Model(params.modelId);
|
||||
const nativeXhigh = fable5 || isAnthropicOpus47OrNewerModel(params.modelId);
|
||||
if (!supportsAnthropicNativeMaxEffort(params.modelId)) {
|
||||
return undefined;
|
||||
}
|
||||
const current = params.model.thinkingLevelMap;
|
||||
const nativeDefaults = isAnthropicMythosPreviewModel(params.modelId)
|
||||
? { max: "max" as const }
|
||||
: {
|
||||
...(fable5 ? { off: "low" as const, minimal: "low" as const } : {}),
|
||||
xhigh: nativeXhigh ? ("xhigh" as const) : null,
|
||||
max: "max" as const,
|
||||
};
|
||||
const currentEfforts = current as Record<string, string | null | undefined> | undefined;
|
||||
if (Object.keys(nativeDefaults).every((level) => currentEfforts?.[level] !== undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...params.model,
|
||||
thinkingLevelMap: {
|
||||
...nativeDefaults,
|
||||
...current,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matchesAnthropicModernModel(modelId: string): boolean {
|
||||
return supportsClaudeAdaptiveThinking({ id: modelId }) || isAnthropicMythosPreviewModel(modelId);
|
||||
}
|
||||
|
||||
function hasImageInput(input: unknown): boolean {
|
||||
return Array.isArray(input) && input.includes("image");
|
||||
}
|
||||
|
||||
function supportsAnthropicImageInput(modelId: string, modelName?: string): boolean {
|
||||
return [modelId, modelName]
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.some((candidate) => matchesAnthropicModernModel(candidate));
|
||||
}
|
||||
|
||||
function resolveAnthropicImageMediaInput(modelId: string, modelName?: string) {
|
||||
if (!supportsAnthropicImageInput(modelId, modelName)) {
|
||||
return undefined;
|
||||
}
|
||||
const refs = [modelId, modelName].filter((value): value is string => typeof value === "string");
|
||||
const largeImageModel = refs.some(
|
||||
(ref) => isAnthropicFable5Model(ref) || isAnthropicOpus47OrNewerModel(ref),
|
||||
);
|
||||
return {
|
||||
image: {
|
||||
maxSidePx: largeImageModel ? 2576 : 1568,
|
||||
preferredSidePx: largeImageModel ? 2576 : 1568,
|
||||
tokenMode: "provider" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyAnthropicImageInputCapability(params: {
|
||||
modelId: string;
|
||||
model: ProviderRuntimeModel;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
if (hasImageInput(params.model.input)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!supportsAnthropicImageInput(params.modelId, params.model.name)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...params.model,
|
||||
input: ["text", "image"],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnthropicResolvedModel(
|
||||
ctx: ProviderNormalizeResolvedModelContext,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
const contractModelId = resolveClaudeModelIdentity({
|
||||
id: ctx.modelId,
|
||||
params: ctx.model.params,
|
||||
});
|
||||
if (
|
||||
isAnthropicFable5Model(contractModelId) &&
|
||||
normalizeLowercaseStringOrEmpty(ctx.provider) !== PROVIDER_ID
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const contractModel =
|
||||
isAnthropicFable5Model(contractModelId) && !ctx.model.reasoning
|
||||
? { ...ctx.model, reasoning: true }
|
||||
: ctx.model;
|
||||
const imageCapableModel =
|
||||
applyAnthropicImageInputCapability({
|
||||
modelId: contractModelId,
|
||||
model: contractModel,
|
||||
}) ?? contractModel;
|
||||
const mediaInput = resolveAnthropicImageMediaInput(contractModelId, imageCapableModel.name);
|
||||
const mediaInputModel = mediaInput
|
||||
? {
|
||||
...imageCapableModel,
|
||||
mediaInput: {
|
||||
...mediaInput,
|
||||
...imageCapableModel.mediaInput,
|
||||
image: {
|
||||
...mediaInput.image,
|
||||
...imageCapableModel.mediaInput?.image,
|
||||
},
|
||||
},
|
||||
}
|
||||
: imageCapableModel;
|
||||
const outputModel =
|
||||
applyAnthropicModernMaxTokens({
|
||||
modelId: contractModelId,
|
||||
model: mediaInputModel,
|
||||
}) ?? mediaInputModel;
|
||||
const thinkingLevelModel =
|
||||
applyAnthropicThinkingLevelMap({
|
||||
modelId: contractModelId,
|
||||
model: outputModel,
|
||||
}) ?? outputModel;
|
||||
const contextWindowModel =
|
||||
applyAnthropicFixedContextWindow({
|
||||
config: ctx.config,
|
||||
provider: ctx.provider,
|
||||
modelId: ctx.modelId,
|
||||
contractModelId,
|
||||
model: thinkingLevelModel,
|
||||
}) ?? thinkingLevelModel;
|
||||
return contextWindowModel === ctx.model ? undefined : contextWindowModel;
|
||||
}
|
||||
|
||||
function buildAnthropicAuthDoctorHint(params: {
|
||||
config?: ProviderAuthContext["config"];
|
||||
store: AuthProfileStore;
|
||||
profileId?: string;
|
||||
}): string {
|
||||
const legacyProfileId = params.profileId ?? "anthropic:default";
|
||||
const suggested = suggestOAuthProfileIdForLegacyDefault({
|
||||
cfg: params.config,
|
||||
store: params.store,
|
||||
provider: PROVIDER_ID,
|
||||
legacyProfileId,
|
||||
});
|
||||
if (!suggested || suggested === legacyProfileId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const storeOauthProfiles = listProfilesForProvider(params.store, PROVIDER_ID)
|
||||
.filter((id) => params.store.profiles[id]?.type === "oauth")
|
||||
.join(", ");
|
||||
|
||||
const cfgMode = params.config?.auth?.profiles?.[legacyProfileId]?.mode;
|
||||
const cfgProvider = params.config?.auth?.profiles?.[legacyProfileId]?.provider;
|
||||
|
||||
return [
|
||||
"Doctor hint (for GitHub issue):",
|
||||
`- provider: ${PROVIDER_ID}`,
|
||||
`- config: ${legacyProfileId}${
|
||||
cfgProvider || cfgMode ? ` (provider=${cfgProvider ?? "?"}, mode=${cfgMode ?? "?"})` : ""
|
||||
}`,
|
||||
`- auth store oauth profiles: ${storeOauthProfiles || "(none)"}`,
|
||||
`- suggested profile: ${suggested}`,
|
||||
`Fix: run "${formatCliCommand("openclaw doctor --yes")}"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function resolveClaudeCliSyntheticAuth() {
|
||||
const credential = claudeCliAuth.readClaudeCliCredentialsForRuntime();
|
||||
if (!credential) {
|
||||
return undefined;
|
||||
}
|
||||
return credential.type === "oauth"
|
||||
? {
|
||||
apiKey: credential.access,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "oauth" as const,
|
||||
expiresAt: credential.expires,
|
||||
}
|
||||
: {
|
||||
apiKey: credential.token,
|
||||
source: "Claude CLI native auth",
|
||||
mode: "token" as const,
|
||||
expiresAt: credential.expires,
|
||||
};
|
||||
}
|
||||
|
||||
async function runAnthropicCliMigration(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
|
||||
const credential = claudeCliAuth.readClaudeCliCredentialsForSetup();
|
||||
if (!credential) {
|
||||
throw new Error(
|
||||
[
|
||||
"Claude CLI is not authenticated on this host.",
|
||||
`Run ${formatCliCommand("claude auth login")} first, then re-run this setup.`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return buildAnthropicCliMigrationResult(ctx.config, credential);
|
||||
}
|
||||
|
||||
async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
config: ProviderAuthContext["config"];
|
||||
runtime: ProviderAuthContext["runtime"];
|
||||
agentDir?: string;
|
||||
}): Promise<ProviderAuthContext["config"] | null> {
|
||||
const credential = claudeCliAuth.readClaudeCliCredentialsForSetupNonInteractive();
|
||||
if (!credential) {
|
||||
ctx.runtime.error(
|
||||
[
|
||||
'Auth choice "anthropic-cli" requires Claude CLI auth on this host.',
|
||||
`Run ${formatCliCommand("claude auth login")} first.`,
|
||||
].join("\n"),
|
||||
);
|
||||
ctx.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = buildAnthropicCliMigrationResult(ctx.config, credential);
|
||||
const currentDefaults = ctx.config.agents?.defaults;
|
||||
const currentModel = currentDefaults?.model;
|
||||
const currentFallbacks =
|
||||
currentModel && typeof currentModel === "object" && "fallbacks" in currentModel
|
||||
? currentModel.fallbacks
|
||||
: undefined;
|
||||
const migratedModel = result.configPatch?.agents?.defaults?.model;
|
||||
const migratedFallbacks =
|
||||
migratedModel && typeof migratedModel === "object" && "fallbacks" in migratedModel
|
||||
? migratedModel.fallbacks
|
||||
: undefined;
|
||||
const nextFallbacks = Array.isArray(migratedFallbacks) ? migratedFallbacks : currentFallbacks;
|
||||
|
||||
return {
|
||||
...ctx.config,
|
||||
...result.configPatch,
|
||||
agents: {
|
||||
...ctx.config.agents,
|
||||
...result.configPatch?.agents,
|
||||
defaults: {
|
||||
...currentDefaults,
|
||||
...result.configPatch?.agents?.defaults,
|
||||
model: {
|
||||
...(Array.isArray(nextFallbacks) ? { fallbacks: nextFallbacks } : {}),
|
||||
primary: result.defaultModel,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveAnthropicUsageAuth(
|
||||
ctx: ProviderResolveUsageAuthContext,
|
||||
): Promise<ProviderResolvedUsageAuth> {
|
||||
const oauthToken = await ctx.resolveOAuthToken();
|
||||
if (oauthToken) {
|
||||
return oauthToken;
|
||||
}
|
||||
|
||||
const apiKey = ctx.resolveApiKeyFromConfigAndStore();
|
||||
if (apiKey && validateAnthropicSetupToken(apiKey) === undefined) {
|
||||
return { token: apiKey };
|
||||
}
|
||||
|
||||
return { handled: true };
|
||||
}
|
||||
|
||||
/** Build the full Anthropic provider descriptor used by runtime registration. */
|
||||
export function buildAnthropicProvider(): ProviderPlugin {
|
||||
const providerId = "anthropic";
|
||||
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
|
||||
return {
|
||||
id: providerId,
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
hookAliases: [CLAUDE_CLI_BACKEND_ID],
|
||||
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
oauthProfileIdRepairs: [
|
||||
{
|
||||
legacyProfileId: "anthropic:default",
|
||||
promptLabel: "Anthropic",
|
||||
},
|
||||
],
|
||||
auth: [
|
||||
{
|
||||
id: "cli",
|
||||
label: "Claude CLI",
|
||||
hint: "Reuse a local Claude CLI login and run Anthropic models through the Claude CLI runtime",
|
||||
kind: "custom",
|
||||
wizard: {
|
||||
choiceId: "anthropic-cli",
|
||||
choiceLabel: "Anthropic Claude CLI",
|
||||
choiceHint: "Reuse a local Claude CLI login on this host",
|
||||
assistantPriority: -20,
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
modelAllowlist: {
|
||||
allowedKeys: [...CLAUDE_CLI_CANONICAL_ALLOWLIST_REFS],
|
||||
initialSelections: [CLAUDE_CLI_CANONICAL_DEFAULT_MODEL_REF],
|
||||
message: "Claude CLI models",
|
||||
},
|
||||
},
|
||||
run: async (ctx: ProviderAuthContext) => await runAnthropicCliMigration(ctx),
|
||||
runNonInteractive: async (ctx) =>
|
||||
await runAnthropicCliMigrationNonInteractive({
|
||||
config: ctx.config,
|
||||
runtime: ctx.runtime,
|
||||
agentDir: ctx.agentDir,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "setup-token",
|
||||
label: "Anthropic setup-token",
|
||||
hint: "Manual bearer token path",
|
||||
kind: "token",
|
||||
wizard: {
|
||||
choiceId: "setup-token",
|
||||
choiceLabel: "Anthropic setup-token",
|
||||
choiceHint: "Manual token path",
|
||||
assistantPriority: 40,
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key + token",
|
||||
},
|
||||
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupTokenAuth(ctx),
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
||||
await runAnthropicSetupTokenNonInteractive(ctx),
|
||||
},
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId,
|
||||
methodId: "api-key",
|
||||
label: "Anthropic API key",
|
||||
hint: "Direct Anthropic API key",
|
||||
optionKey: "anthropicApiKey",
|
||||
flagName: "--anthropic-api-key",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
promptMessage: "Enter Anthropic API key",
|
||||
defaultModel: defaultAnthropicModel,
|
||||
expectedProviders: ["anthropic"],
|
||||
wizard: {
|
||||
choiceId: "apiKey",
|
||||
choiceLabel: "Anthropic API key",
|
||||
groupId: "anthropic",
|
||||
groupLabel: "Anthropic",
|
||||
groupHint: "Claude CLI + API key",
|
||||
},
|
||||
}),
|
||||
],
|
||||
normalizeConfig: ({ provider, providerConfig }) =>
|
||||
normalizeAnthropicProviderConfigForProvider({ provider, providerConfig }),
|
||||
applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }),
|
||||
resolveDynamicModel: (ctx) => {
|
||||
const model = resolveAnthropicForwardCompatModel(ctx);
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
normalizeAnthropicResolvedModel({
|
||||
config: ctx.config,
|
||||
provider: ctx.provider,
|
||||
modelId: ctx.modelId,
|
||||
model,
|
||||
}) ?? model
|
||||
);
|
||||
},
|
||||
normalizeResolvedModel: (ctx) => normalizeAnthropicResolvedModel(ctx),
|
||||
resolveSyntheticAuth: ({ provider }) =>
|
||||
normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID
|
||||
? resolveClaudeCliSyntheticAuth()
|
||||
: undefined,
|
||||
// Publish Claude CLI rows through the provider catalog hook.
|
||||
augmentModelCatalog: () => buildClaudeCliCatalogEntries(),
|
||||
...NATIVE_ANTHROPIC_REPLAY_HOOKS,
|
||||
isModernModelRef: ({ provider, modelId }) =>
|
||||
matchesAnthropicModernModel(modelId) &&
|
||||
(!isAnthropicFable5Model(modelId) ||
|
||||
normalizeLowercaseStringOrEmpty(provider) === PROVIDER_ID),
|
||||
resolveReasoningOutputMode: () => "native",
|
||||
resolveThinkingProfile: ({ provider, modelId, params }) => {
|
||||
const contractModelId = resolveClaudeModelIdentity({ id: modelId, params });
|
||||
return isAnthropicFable5Model(contractModelId) &&
|
||||
normalizeLowercaseStringOrEmpty(provider) !== PROVIDER_ID
|
||||
? CLAUDE_CLI_OFF_THINKING_PROFILE
|
||||
: resolveClaudeThinkingProfile(contractModelId, undefined, {
|
||||
includeNativeMax: [PROVIDER_ID, CLAUDE_CLI_BACKEND_ID].includes(
|
||||
normalizeLowercaseStringOrEmpty(provider),
|
||||
),
|
||||
});
|
||||
},
|
||||
wrapStreamFn: wrapAnthropicProviderStream,
|
||||
resolveUsageAuth: resolveAnthropicUsageAuth,
|
||||
fetchUsageSnapshot: async (ctx) =>
|
||||
await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
|
||||
isCacheTtlEligible: () => true,
|
||||
buildAuthDoctorHint: (ctx) =>
|
||||
buildAnthropicAuthDoctorHint({
|
||||
config: ctx.config,
|
||||
store: ctx.store,
|
||||
profileId: ctx.profileId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Register Anthropic provider, Claude CLI backend, and media understanding provider. */
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider(buildAnthropicProvider());
|
||||
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
|
||||
}
|
||||
16
extensions/anthropic/setup-api.ts
Normal file
16
extensions/anthropic/setup-api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Lightweight Anthropic setup entry. It registers Claude CLI backend metadata
|
||||
* without loading full provider runtime code.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
|
||||
/** Setup entry for Claude CLI backend registration. */
|
||||
export default definePluginEntry({
|
||||
id: "anthropic",
|
||||
name: "Anthropic Setup",
|
||||
description: "Lightweight Anthropic setup hooks",
|
||||
register(api) {
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
},
|
||||
});
|
||||
310
extensions/anthropic/stream-wrappers.test.ts
Normal file
310
extensions/anthropic/stream-wrappers.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
// Anthropic tests cover stream wrappers plugin behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
testing,
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
createAnthropicThinkingPrefillWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
wrapAnthropicProviderStream,
|
||||
} from "./stream-wrappers.js";
|
||||
|
||||
const CONTEXT_1M_BETA = "context-1m-2025-08-07";
|
||||
const OAUTH_BETA = "oauth-2025-04-20";
|
||||
const DEFAULT_BETA_HEADER =
|
||||
"fine-grained-tool-streaming-2025-05-14,interleaved-thinking-2025-05-14";
|
||||
const OAUTH_BETA_HEADER = `claude-code-20250219,${OAUTH_BETA},${DEFAULT_BETA_HEADER}`;
|
||||
|
||||
function runWrapper(apiKey: string | undefined): Record<string, string> | undefined {
|
||||
const captured: { headers?: Record<string, string> } = {};
|
||||
const base: StreamFn = (_model, _context, options) => {
|
||||
captured.headers = options?.headers;
|
||||
return {} as never;
|
||||
};
|
||||
const wrapper = createAnthropicBetaHeadersWrapper(base, [CONTEXT_1M_BETA]);
|
||||
void wrapper(
|
||||
{ provider: "anthropic", id: "claude-opus-4-6" } as never,
|
||||
{} as never,
|
||||
{ apiKey } as never,
|
||||
);
|
||||
return captured.headers;
|
||||
}
|
||||
|
||||
function createPayloadCapturingBaseStream(captured: {
|
||||
headers?: Record<string, string>;
|
||||
payload?: Record<string, unknown>;
|
||||
}): StreamFn {
|
||||
return (model, _context, options) => {
|
||||
captured.headers = options?.headers;
|
||||
const payload = {} as Record<string, unknown>;
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
captured.payload = payload;
|
||||
return {} as never;
|
||||
};
|
||||
}
|
||||
|
||||
function runComposedAnthropicProviderStream(apiKey: string) {
|
||||
const captured: { headers?: Record<string, string>; payload?: Record<string, unknown> } = {};
|
||||
const wrapped = wrapAnthropicProviderStream({
|
||||
streamFn: createPayloadCapturingBaseStream(captured),
|
||||
modelId: "claude-sonnet-4-6",
|
||||
extraParams: { context1m: true, serviceTier: "auto" },
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{ provider: "anthropic", api: "anthropic-messages", id: "claude-sonnet-4-6" } as never,
|
||||
{} as never,
|
||||
{ apiKey } as never,
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
|
||||
function runPayloadWrapper(
|
||||
params: {
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
api?: string;
|
||||
baseUrl?: string;
|
||||
},
|
||||
createWrapper: (base: StreamFn) => StreamFn,
|
||||
): Record<string, unknown> | undefined {
|
||||
const captured: { payload?: Record<string, unknown> } = {};
|
||||
const wrapper = createWrapper(createPayloadCapturingBaseStream(captured));
|
||||
void wrapper(
|
||||
{
|
||||
provider: params.provider ?? "anthropic",
|
||||
api: params.api ?? "anthropic-messages",
|
||||
baseUrl: params.baseUrl,
|
||||
id: "claude-sonnet-4-6",
|
||||
} as never,
|
||||
{} as never,
|
||||
{ apiKey: params.apiKey } as never,
|
||||
);
|
||||
return captured.payload;
|
||||
}
|
||||
|
||||
describe("anthropic stream wrappers", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("strips legacy context-1m betas for Claude CLI or legacy token auth", () => {
|
||||
const warn = vi.spyOn(testing.log, "warn").mockImplementation(() => undefined);
|
||||
const headers = runWrapper("sk-ant-oat01-123");
|
||||
expect(headers?.["anthropic-beta"]).toBeDefined();
|
||||
expect(headers?.["anthropic-beta"]).toContain(OAUTH_BETA);
|
||||
expect(headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips legacy context-1m betas for API key auth", () => {
|
||||
const warn = vi.spyOn(testing.log, "warn").mockImplementation(() => undefined);
|
||||
const headers = runWrapper("sk-ant-api-123");
|
||||
expect(headers?.["anthropic-beta"]).toBeDefined();
|
||||
expect(headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips service_tier for OAuth token in composed stream chain", () => {
|
||||
const captured = runComposedAnthropicProviderStream("sk-ant-oat01-oauth-token");
|
||||
expect(captured.headers?.["anthropic-beta"]).toBe(OAUTH_BETA_HEADER);
|
||||
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);
|
||||
expect(captured.payload).toMatchObject({ service_tier: "auto" });
|
||||
});
|
||||
|
||||
it("does not emit the legacy context-1m beta from context1m or explicit config", () => {
|
||||
expect(
|
||||
resolveAnthropicBetas(
|
||||
{ context1m: true, anthropicBeta: [CONTEXT_1M_BETA, "files-api-2025-04-14"] },
|
||||
"claude-sonnet-4-6",
|
||||
),
|
||||
).toEqual(["files-api-2025-04-14"]);
|
||||
});
|
||||
|
||||
it("strips legacy context-1m beta from comma-separated string config", () => {
|
||||
expect(
|
||||
resolveAnthropicBetas(
|
||||
{ anthropicBeta: `${CONTEXT_1M_BETA},files-api-2025-04-14` },
|
||||
"claude-sonnet-4-6",
|
||||
),
|
||||
).toEqual(["files-api-2025-04-14"]);
|
||||
});
|
||||
|
||||
it("preserves OAuth-required betas when context1m is the only configured beta trigger", () => {
|
||||
const captured: { headers?: Record<string, string> } = {};
|
||||
const wrapped = wrapAnthropicProviderStream({
|
||||
streamFn: createPayloadCapturingBaseStream(captured),
|
||||
modelId: "claude-sonnet-4-6",
|
||||
extraParams: { context1m: true },
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{ provider: "anthropic", api: "anthropic-messages", id: "claude-sonnet-4-6" } as never,
|
||||
{} as never,
|
||||
{ apiKey: "sk-ant-oat01-oauth-token" } as never,
|
||||
);
|
||||
|
||||
expect(captured.headers?.["anthropic-beta"]).toContain(OAUTH_BETA);
|
||||
expect(captured.headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
|
||||
});
|
||||
|
||||
it("preserves OAuth-required betas when legacy context-1m is the only configured beta", () => {
|
||||
const captured: { headers?: Record<string, string> } = {};
|
||||
const wrapped = wrapAnthropicProviderStream({
|
||||
streamFn: createPayloadCapturingBaseStream(captured),
|
||||
modelId: "claude-sonnet-4-6",
|
||||
extraParams: { anthropicBeta: [CONTEXT_1M_BETA] },
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{ provider: "anthropic", api: "anthropic-messages", id: "claude-sonnet-4-6" } as never,
|
||||
{} as never,
|
||||
{ apiKey: "sk-ant-oat01-oauth-token" } as never,
|
||||
);
|
||||
|
||||
expect(captured.headers?.["anthropic-beta"]).toContain(OAUTH_BETA);
|
||||
expect(captured.headers?.["anthropic-beta"]).not.toContain(CONTEXT_1M_BETA);
|
||||
});
|
||||
|
||||
it("ignores unresolved auto fast mode at the provider boundary", () => {
|
||||
expect(resolveAnthropicFastMode({ fastMode: "auto" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAnthropicThinkingPrefillWrapper", () => {
|
||||
function runThinkingPrefillWrapper(payload: Record<string, unknown>): Record<string, unknown> {
|
||||
const wrapper = createAnthropicThinkingPrefillWrapper(((_model, _context, options) => {
|
||||
options?.onPayload?.(payload as never, {} as never);
|
||||
return {} as never;
|
||||
}) as StreamFn);
|
||||
void wrapper({ provider: "anthropic", api: "anthropic-messages" } as never, {} as never, {});
|
||||
return payload;
|
||||
}
|
||||
|
||||
it("removes trailing assistant prefill when extended thinking is enabled", () => {
|
||||
const warn = vi.spyOn(testing.log, "warn").mockImplementation(() => undefined);
|
||||
const payload = runThinkingPrefillWrapper({
|
||||
thinking: { type: "enabled", budget_tokens: 1024 },
|
||||
messages: [
|
||||
{ role: "user", content: "Return JSON." },
|
||||
{ role: "assistant", content: "{" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload.messages).toEqual([{ role: "user", content: "Return JSON." }]);
|
||||
expect(warn).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps assistant prefill when thinking is disabled", () => {
|
||||
const payload = runThinkingPrefillWrapper({
|
||||
thinking: { type: "disabled" },
|
||||
messages: [
|
||||
{ role: "user", content: "Return JSON." },
|
||||
{ role: "assistant", content: "{" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps trailing assistant tool use turns", () => {
|
||||
const payload = runThinkingPrefillWrapper({
|
||||
thinking: { type: "adaptive" },
|
||||
messages: [
|
||||
{ role: "user", content: "Read a file." },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "toolu_1", name: "Read" }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(payload.messages).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
type ServiceTierWrapperParams = {
|
||||
apiKey?: string;
|
||||
provider?: string;
|
||||
api?: string;
|
||||
enabled?: boolean;
|
||||
serviceTier?: "auto" | "standard_only";
|
||||
};
|
||||
|
||||
const serviceTierWrapperCases: Array<{
|
||||
name: string;
|
||||
run: (params: ServiceTierWrapperParams) => Record<string, unknown> | undefined;
|
||||
}> = [
|
||||
{
|
||||
name: "fast mode",
|
||||
run: (params) =>
|
||||
runPayloadWrapper(params, (base) =>
|
||||
createAnthropicFastModeWrapper(base, params.enabled ?? true),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "explicit service tier",
|
||||
run: (params) =>
|
||||
runPayloadWrapper(params, (base) =>
|
||||
createAnthropicServiceTierWrapper(base, params.serviceTier ?? "auto"),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Anthropic service_tier payload wrappers", () => {
|
||||
it.each(serviceTierWrapperCases)("$name skips service_tier for OAuth token", ({ run }) => {
|
||||
const payload = run({ apiKey: "sk-ant-oat01-test-token" });
|
||||
expect(payload?.service_tier).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(serviceTierWrapperCases)("$name injects service_tier for regular API keys", ({ run }) => {
|
||||
const payload = run({ apiKey: "sk-ant-api03-test-key" });
|
||||
expect(payload?.service_tier).toBe("auto");
|
||||
});
|
||||
|
||||
it.each(serviceTierWrapperCases)(
|
||||
"$name does not inject service_tier for non-anthropic provider",
|
||||
({ run }) => {
|
||||
const payload = run({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
provider: "openai",
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(payload?.service_tier).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("fast mode injects service_tier=standard_only when disabled for API keys", () => {
|
||||
const payload = serviceTierWrapperCases[0].run({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
enabled: false,
|
||||
});
|
||||
expect(payload?.service_tier).toBe("standard_only");
|
||||
});
|
||||
|
||||
it("fast mode resolves dynamic service_tier for each stream call", () => {
|
||||
let enabled = true;
|
||||
const first = runPayloadWrapper({ apiKey: "sk-ant-api03-test-key" }, (base) =>
|
||||
createAnthropicFastModeWrapper(base, () => enabled),
|
||||
);
|
||||
enabled = false;
|
||||
const second = runPayloadWrapper({ apiKey: "sk-ant-api03-test-key" }, (base) =>
|
||||
createAnthropicFastModeWrapper(base, () => enabled),
|
||||
);
|
||||
expect(first?.service_tier).toBe("auto");
|
||||
expect(second?.service_tier).toBe("standard_only");
|
||||
});
|
||||
|
||||
it("explicit service tier injects service_tier=standard_only for regular API keys", () => {
|
||||
const payload = serviceTierWrapperCases[1].run({
|
||||
apiKey: "sk-ant-api03-test-key",
|
||||
serviceTier: "standard_only",
|
||||
});
|
||||
expect(payload?.service_tier).toBe("standard_only");
|
||||
});
|
||||
});
|
||||
272
extensions/anthropic/stream-wrappers.ts
Normal file
272
extensions/anthropic/stream-wrappers.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Anthropic stream wrappers. They add beta headers, service tier/fast-mode
|
||||
* payload fields, and thinking-prefill cleanup around provider stream functions.
|
||||
*/
|
||||
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 {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
composeProviderStreamWrappers,
|
||||
createAnthropicThinkingPrefillPayloadWrapper,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
normalizeFastMode,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
readStringValue,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const log = createSubsystemLogger("anthropic-stream");
|
||||
|
||||
const ANTHROPIC_CONTEXT_1M_BETA_LEGACY = "context-1m-2025-08-07";
|
||||
const ANTHROPIC_GA_1M_MODEL_PREFIXES = [
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4.6",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4.7",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4.6",
|
||||
] as const;
|
||||
const OPENCLAW_DEFAULT_ANTHROPIC_BETAS = [
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
"interleaved-thinking-2025-05-14",
|
||||
] as const;
|
||||
const OPENCLAW_OAUTH_ANTHROPIC_BETAS = [
|
||||
"claude-code-20250219",
|
||||
"oauth-2025-04-20",
|
||||
...OPENCLAW_DEFAULT_ANTHROPIC_BETAS,
|
||||
] as const;
|
||||
|
||||
type AnthropicServiceTier = "auto" | "standard_only";
|
||||
type DynamicFastMode = boolean | (() => boolean | undefined);
|
||||
|
||||
function isAnthropic1MModel(modelId: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return ANTHROPIC_GA_1M_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||
}
|
||||
|
||||
function parseHeaderList(value: unknown): string[] {
|
||||
if (typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function mergeAnthropicBetaHeader(
|
||||
headers: Record<string, string> | undefined,
|
||||
betas: string[],
|
||||
): Record<string, string> {
|
||||
const merged = { ...headers };
|
||||
const existingKey = Object.keys(merged).find(
|
||||
(key) => normalizeLowercaseStringOrEmpty(key) === "anthropic-beta",
|
||||
);
|
||||
const existing = existingKey ? parseHeaderList(merged[existingKey]) : [];
|
||||
const values = Array.from(new Set([...existing, ...betas]));
|
||||
const key = existingKey ?? "anthropic-beta";
|
||||
merged[key] = values.join(",");
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
|
||||
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
|
||||
}
|
||||
|
||||
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {
|
||||
return enabled ? "auto" : "standard_only";
|
||||
}
|
||||
|
||||
function normalizeAnthropicServiceTier(value: unknown): AnthropicServiceTier | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeLowercaseStringOrEmpty(value);
|
||||
if (normalized === "auto" || normalized === "standard_only") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasConfiguredAnthropicBeta(extraParams: Record<string, unknown> | undefined): boolean {
|
||||
const configured = extraParams?.anthropicBeta;
|
||||
if (typeof configured === "string") {
|
||||
return configured.trim().length > 0;
|
||||
}
|
||||
if (!Array.isArray(configured)) {
|
||||
return false;
|
||||
}
|
||||
return configured.some((beta) => typeof beta === "string" && beta.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Resolve configured Anthropic beta headers from extra model params. */
|
||||
export function resolveAnthropicBetas(
|
||||
extraParams: Record<string, unknown> | undefined,
|
||||
_modelId: string,
|
||||
): string[] | undefined {
|
||||
const betas = new Set<string>();
|
||||
const configured = extraParams?.anthropicBeta;
|
||||
if (typeof configured === "string" && configured.trim()) {
|
||||
for (const beta of parseHeaderList(configured)) {
|
||||
betas.add(beta);
|
||||
}
|
||||
} else if (Array.isArray(configured)) {
|
||||
for (const beta of configured) {
|
||||
if (typeof beta === "string" && beta.trim()) {
|
||||
for (const betaValue of parseHeaderList(beta)) {
|
||||
betas.add(betaValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Newer Claude 4.x 1M context is GA. Keep context1m as a context-sizing
|
||||
// opt-in, but do not send the retired beta even if it remains in older config.
|
||||
betas.delete(ANTHROPIC_CONTEXT_1M_BETA_LEGACY);
|
||||
|
||||
return betas.size > 0 ? [...betas] : undefined;
|
||||
}
|
||||
|
||||
/** Wrap a stream function to merge OpenClaw and configured Anthropic beta headers. */
|
||||
export function createAnthropicBetaHeadersWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
betas: string[],
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const isOauth = isAnthropicOAuthApiKey(options?.apiKey);
|
||||
const effectiveBetas = betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA_LEGACY);
|
||||
|
||||
const openClawBetas = isOauth
|
||||
? (OPENCLAW_OAUTH_ANTHROPIC_BETAS as readonly string[])
|
||||
: (OPENCLAW_DEFAULT_ANTHROPIC_BETAS as readonly string[]);
|
||||
const allBetas = [...new Set([...openClawBetas, ...effectiveBetas])];
|
||||
return underlying(model, context, {
|
||||
...options,
|
||||
headers: mergeAnthropicBetaHeader(options?.headers, allBetas),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap a stream function with the Anthropic fast-mode service tier. */
|
||||
export function createAnthropicFastModeWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
enabled: DynamicFastMode,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const resolved = typeof enabled === "function" ? enabled() : enabled;
|
||||
if (resolved === undefined) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
return createAnthropicServiceTierWrapper(underlying, resolveAnthropicFastServiceTier(resolved))(
|
||||
model,
|
||||
context,
|
||||
options,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap a stream function with an explicit Anthropic service tier when allowed. */
|
||||
export function createAnthropicServiceTierWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
serviceTier: AnthropicServiceTier,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
if (isAnthropicOAuthApiKey(options?.apiKey)) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: readStringValue(model.provider),
|
||||
api: readStringValue(model.api),
|
||||
baseUrl: readStringValue(model.baseUrl),
|
||||
serviceTier,
|
||||
});
|
||||
if (!payloadPolicy.allowsServiceTier) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
|
||||
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap a stream function to strip trailing assistant prefill before thinking requests. */
|
||||
export function createAnthropicThinkingPrefillWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn {
|
||||
return createAnthropicThinkingPrefillPayloadWrapper(baseStreamFn, (stripped) => {
|
||||
log.warn(
|
||||
`removed ${stripped} trailing assistant prefill message${stripped === 1 ? "" : "s"} because Anthropic extended thinking requires conversations to end with a user turn`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve Anthropic fast-mode setting from model extra params. */
|
||||
export function resolveAnthropicFastMode(
|
||||
extraParams: Record<string, unknown> | undefined,
|
||||
): boolean | undefined {
|
||||
const raw = extraParams?.fastMode ?? extraParams?.fast_mode;
|
||||
const fastMode =
|
||||
typeof raw === "function"
|
||||
? normalizeFastMode((raw as () => unknown)() as string | boolean | null | undefined)
|
||||
: normalizeFastMode(raw as string | boolean | null | undefined);
|
||||
return fastMode === "auto" ? undefined : fastMode;
|
||||
}
|
||||
|
||||
/** Resolve Anthropic service tier from model extra params. */
|
||||
export function resolveAnthropicServiceTier(
|
||||
extraParams: Record<string, unknown> | undefined,
|
||||
): AnthropicServiceTier | undefined {
|
||||
const raw = extraParams?.serviceTier ?? extraParams?.service_tier;
|
||||
const normalized = normalizeAnthropicServiceTier(raw);
|
||||
if (raw !== undefined && normalized === undefined) {
|
||||
const rawSummary = typeof raw === "string" ? raw : typeof raw;
|
||||
log.warn(`ignoring invalid Anthropic service tier param: ${rawSummary}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Compose all Anthropic stream wrappers for one provider/model context. */
|
||||
export function wrapAnthropicProviderStream(
|
||||
ctx: ProviderWrapStreamFnContext,
|
||||
): StreamFn | undefined {
|
||||
const anthropicBetas = resolveAnthropicBetas(ctx.extraParams, ctx.modelId);
|
||||
const needsAnthropicBetaWrapper =
|
||||
anthropicBetas !== undefined ||
|
||||
hasConfiguredAnthropicBeta(ctx.extraParams) ||
|
||||
(ctx.extraParams?.context1m === true && isAnthropic1MModel(ctx.modelId));
|
||||
const serviceTier = resolveAnthropicServiceTier(ctx.extraParams);
|
||||
const hasFastModeParam =
|
||||
ctx.extraParams !== undefined &&
|
||||
(Object.hasOwn(ctx.extraParams, "fastMode") || Object.hasOwn(ctx.extraParams, "fast_mode"));
|
||||
return composeProviderStreamWrappers(
|
||||
ctx.streamFn,
|
||||
needsAnthropicBetaWrapper
|
||||
? (streamFn) => createAnthropicBetaHeadersWrapper(streamFn, anthropicBetas ?? [])
|
||||
: undefined,
|
||||
serviceTier
|
||||
? (streamFn) => createAnthropicServiceTierWrapper(streamFn, serviceTier)
|
||||
: undefined,
|
||||
hasFastModeParam
|
||||
? (streamFn) =>
|
||||
createAnthropicFastModeWrapper(streamFn, () => resolveAnthropicFastMode(ctx.extraParams))
|
||||
: undefined,
|
||||
(streamFn) => createAnthropicThinkingPrefillWrapper(streamFn),
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only hooks for Anthropic stream wrapper behavior. */
|
||||
export const testing = {
|
||||
log,
|
||||
};
|
||||
export { testing as __testing };
|
||||
7
extensions/anthropic/test-api.ts
Normal file
7
extensions/anthropic/test-api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Test API barrel for Anthropic plugin internals. Tests import this path to
|
||||
* avoid reaching into unrelated runtime modules.
|
||||
*/
|
||||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export { normalizeClaudeBackendConfig } from "./cli-shared.js";
|
||||
export { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
16
extensions/anthropic/tsconfig.json
Normal file
16
extensions/anthropic/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user