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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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";

View 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;
}

View 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 });
}

View 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,
};
}

View 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,
};
});
}

View 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;

View 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);
});
});

View 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(", ")}.`]
: []),
],
};
}

View 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);
});
});

View 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,
};
}

View 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,
},
};
}

View 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";

View 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:"],
},
];

File diff suppressed because it is too large Load Diff

View 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);
},
});

View 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,
};

View 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": {}
}
}

View 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,
});
}
});
});

View 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"
]
}
}

View 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",
},
},
],
};
}

View 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;

View 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();
});
});

View 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;
}
}

View 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"));

View 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);
}

View 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());
},
});

View 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");
});
});

View 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 };

View 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";

View 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"
]
}