重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1,11 @@
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,13 @@
import { readClaudeCliCredentialsCached } from "openclaw/plugin-sdk/provider-auth";
export function readClaudeCliCredentialsForSetup() {
return readClaudeCliCredentialsCached();
}
export function readClaudeCliCredentialsForSetupNonInteractive() {
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
}
export function readClaudeCliCredentialsForRuntime() {
return readClaudeCliCredentialsCached({ allowKeychainPrompt: false });
}

View file

@ -0,0 +1,6 @@
export { buildAnthropicCliBackend } from "./cli-backend.js";
export {
CLAUDE_CLI_BACKEND_ID,
isClaudeCliProvider,
normalizeClaudeBackendConfig,
} from "./cli-shared.js";

View file

@ -0,0 +1,76 @@
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,
} from "./cli-shared.js";
export function buildAnthropicCliBackend(): CliBackendPlugin {
return {
id: CLAUDE_CLI_BACKEND_ID,
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",
config: {
command: "claude",
args: [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
],
resumeArgs: [
"-p",
"--output-format",
"stream-json",
"--include-partial-messages",
"--verbose",
"--setting-sources",
"user",
"--permission-mode",
"bypassPermissions",
"--resume",
"{sessionId}",
],
output: "jsonl",
input: "stdin",
modelArg: "--model",
modelAliases: CLAUDE_CLI_MODEL_ALIASES,
sessionArg: "--session-id",
sessionMode: "always",
sessionIdFields: [...CLAUDE_CLI_SESSION_ID_FIELDS],
systemPromptArg: "--append-system-prompt",
systemPromptMode: "append",
systemPromptWhen: "first",
clearEnv: [...CLAUDE_CLI_CLEAR_ENV],
reliability: {
watchdog: {
fresh: { ...CLI_FRESH_WATCHDOG_DEFAULTS },
resume: { ...CLI_RESUME_WATCHDOG_DEFAULTS },
},
},
serialize: true,
},
normalizeConfig: normalizeClaudeBackendConfig,
};
}

View file

@ -0,0 +1,348 @@
import type {
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { 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, hasClaudeCliAuth } = await import("./cli-migration.js");
const { registerSingleProviderPlugin } =
await import("../../test/helpers/plugins/plugin-registration.js");
const { createTestWizardPrompter } = await import("../../test/helpers/plugins/setup-wizard.js");
const { default: anthropicPlugin } = await import("./index.js");
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("detects local Claude CLI auth", () => {
readClaudeCliCredentialsForSetup.mockReturnValue({ type: "oauth" });
expect(hasClaudeCliAuth()).toBe(true);
});
it("uses the non-interactive Claude auth probe without keychain prompts", () => {
readClaudeCliCredentialsForSetup.mockReset();
readClaudeCliCredentialsForSetupNonInteractive.mockReset();
readClaudeCliCredentialsForSetup.mockReturnValue(null);
readClaudeCliCredentialsForSetupNonInteractive.mockReturnValue({ type: "oauth" });
expect(hasClaudeCliAuth({ allowKeychainPrompt: false })).toBe(true);
expect(readClaudeCliCredentialsForSetup).not.toHaveBeenCalled();
expect(readClaudeCliCredentialsForSetupNonInteractive).toHaveBeenCalledTimes(1);
});
it("rewrites anthropic defaults to claude-cli defaults", () => {
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).toEqual([]);
expect(result.defaultModel).toBe("claude-cli/claude-opus-4-7");
expect(result.configPatch).toEqual({
agents: {
defaults: {
model: {
primary: "claude-cli/claude-opus-4-7",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"claude-cli/claude-opus-4-7": { alias: "Opus" },
"claude-cli/claude-sonnet-4-6": {},
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
"openai/gpt-5.2": {},
},
},
},
});
});
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("claude-cli/claude-opus-4-7");
expect(result.configPatch).toEqual({
agents: {
defaults: {
models: {
"openai/gpt-5.2": {},
"claude-cli/claude-opus-4-7": {},
"claude-cli/claude-sonnet-4-6": {},
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
},
},
},
});
});
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: {
models: {
"claude-cli/claude-opus-4-7": {},
"claude-cli/claude-sonnet-4-6": {},
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
},
},
},
});
});
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 rewrites anthropic fallbacks before setting the claude-cli default", 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": {},
},
},
},
};
await expect(
method.runNonInteractive?.(createProviderAuthMethodNonInteractiveContext(config)),
).resolves.toMatchObject({
agents: {
defaults: {
model: {
primary: "claude-cli/claude-opus-4-7",
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
},
models: {
"claude-cli/claude-opus-4-7": { alias: "Opus" },
"claude-cli/claude-opus-4-6": { alias: "Opus" },
"openai/gpt-5.2": {},
},
},
},
});
});
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,192 @@
import {
CLAUDE_CLI_PROFILE_ID,
type OpenClawConfig,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import {
readClaudeCliCredentialsForSetup,
readClaudeCliCredentialsForSetupNonInteractive,
} from "./cli-auth-seam.js";
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
CLAUDE_CLI_DEFAULT_MODEL_REF,
} 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 toClaudeCliModelRef(raw: string): string | null {
const trimmed = raw.trim();
if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith("anthropic/")) {
return null;
}
const modelId = trimmed.slice("anthropic/".length).trim();
if (!normalizeLowercaseStringOrEmpty(modelId).startsWith("claude-")) {
return null;
}
return `claude-cli/${modelId}`;
}
function rewriteModelSelection(model: AgentDefaultsModel): {
value: AgentDefaultsModel;
primary?: string;
changed: boolean;
} {
if (typeof model === "string") {
const converted = toClaudeCliModelRef(model);
return converted
? { value: converted, primary: converted, changed: true }
: { value: model, changed: false };
}
if (!model || typeof model !== "object" || Array.isArray(model)) {
return { value: model, changed: false };
}
const current = model as Record<string, unknown>;
const next: Record<string, unknown> = { ...current };
let changed = false;
let primary: string | undefined;
if (typeof current.primary === "string") {
const converted = toClaudeCliModelRef(current.primary);
if (converted) {
next.primary = converted;
primary = converted;
changed = true;
}
}
const currentFallbacks = current.fallbacks;
if (Array.isArray(currentFallbacks)) {
const nextFallbacks = currentFallbacks.map((entry) =>
typeof entry === "string" ? (toClaudeCliModelRef(entry) ?? entry) : entry,
);
if (nextFallbacks.some((entry, index) => entry !== currentFallbacks[index])) {
next.fallbacks = nextFallbacks;
changed = true;
}
}
return {
value: changed ? next : model,
...(primary ? { primary } : {}),
changed,
};
}
function rewriteModelEntryMap(models: Record<string, unknown> | undefined): {
value: Record<string, unknown> | undefined;
migrated: string[];
} {
if (!models) {
return { value: models, migrated: [] };
}
const next = { ...models };
const migrated: string[] = [];
for (const [rawKey, value] of Object.entries(models)) {
const converted = toClaudeCliModelRef(rawKey);
if (!converted) {
continue;
}
if (!(converted in next)) {
next[converted] = value;
}
delete next[rawKey];
migrated.push(converted);
}
return {
value: migrated.length > 0 ? next : models,
migrated,
};
}
function seedClaudeCliAllowlist(
models: NonNullable<AgentDefaultsModels>,
): NonNullable<AgentDefaultsModels> {
const next = { ...models };
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
next[ref] = next[ref] ?? {};
}
return next;
}
export function hasClaudeCliAuth(options?: { allowKeychainPrompt?: boolean }): boolean {
return Boolean(
options?.allowKeychainPrompt === false
? readClaudeCliCredentialsForSetupNonInteractive()
: readClaudeCliCredentialsForSetup(),
);
}
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,
},
},
];
}
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);
const defaultModel = rewrittenModel.primary ?? CLAUDE_CLI_DEFAULT_MODEL_REF;
return {
profiles: buildClaudeCliAuthProfiles(credential),
configPatch: {
agents: {
defaults: {
...(rewrittenModel.changed ? { model: rewrittenModel.value } : {}),
models: nextModels,
},
},
},
defaultModel,
notes: [
"Claude CLI auth detected; switched Anthropic model selection to the local Claude CLI backend.",
"Existing Anthropic auth profiles are kept for rollback.",
...(rewrittenModels.migrated.length > 0
? [`Migrated allowlist entries: ${rewrittenModels.migrated.join(", ")}.`]
: []),
],
};
}

View file

@ -0,0 +1,158 @@
import { describe, expect, it } from "vitest";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import {
CLAUDE_CLI_CLEAR_ENV,
normalizeClaudeBackendConfig,
normalizeClaudePermissionArgs,
normalizeClaudeSettingSourcesArgs,
} from "./cli-shared.js";
describe("normalizeClaudePermissionArgs", () => {
it("injects bypassPermissions when args omit permission flags", () => {
expect(
normalizeClaudePermissionArgs(["-p", "--output-format", "stream-json", "--verbose"]),
).toEqual([
"-p",
"--output-format",
"stream-json",
"--verbose",
"--permission-mode",
"bypassPermissions",
]);
});
it("removes legacy skip-permissions and injects bypassPermissions", () => {
expect(
normalizeClaudePermissionArgs(["-p", "--dangerously-skip-permissions", "--verbose"]),
).toEqual(["-p", "--verbose", "--permission-mode", "bypassPermissions"]);
});
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("treats a bare permission-mode flag as malformed and falls back to bypassPermissions", () => {
expect(
normalizeClaudePermissionArgs(["-p", "--permission-mode", "--output-format", "stream-json"]),
).toEqual(["-p", "--output-format", "stream-json", "--permission-mode", "bypassPermissions"]);
});
});
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("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",
]);
});
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("--permission-mode");
expect(normalized?.args).toContain("bypassPermissions");
expect(normalized?.args).toContain("--setting-sources");
expect(normalized?.args).toContain("user");
expect(normalized?.resumeArgs).toContain("--permission-mode");
expect(normalized?.resumeArgs).toContain("bypassPermissions");
expect(normalized?.resumeArgs).toContain("--setting-sources");
expect(normalized?.resumeArgs).toContain("user");
});
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.args).toContain("--setting-sources");
expect(backend.config.args).toContain("user");
expect(backend.config.resumeArgs).toContain("--setting-sources");
expect(backend.config.resumeArgs).toContain("user");
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");
});
});

View file

@ -0,0 +1,177 @@
import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`;
export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [
CLAUDE_CLI_DEFAULT_MODEL_REF,
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`,
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`,
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-5`,
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-5`,
`${CLAUDE_CLI_BACKEND_ID}/claude-haiku-4-5`,
] as const;
export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
opus: "opus",
"opus-4.7": "opus",
"opus-4.6": "opus",
"opus-4.5": "opus",
"opus-4": "opus",
"claude-opus-4-7": "opus",
"claude-opus-4-6": "opus",
"claude-opus-4-5": "opus",
"claude-opus-4": "opus",
sonnet: "sonnet",
"sonnet-4.6": "sonnet",
"sonnet-4.5": "sonnet",
"sonnet-4.1": "sonnet",
"sonnet-4.0": "sonnet",
"claude-sonnet-4-6": "sonnet",
"claude-sonnet-4-5": "sonnet",
"claude-sonnet-4-1": "sonnet",
"claude-sonnet-4-0": "sonnet",
haiku: "haiku",
"haiku-3.5": "haiku",
"claude-haiku-3-5": "haiku",
};
export const CLAUDE_CLI_SESSION_ID_FIELDS = [
"session_id",
"sessionId",
"conversation_id",
"conversationId",
] as const;
// 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.
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_BYPASS_PERMISSIONS_MODE = "bypassPermissions";
const CLAUDE_SETTING_SOURCES_ARG = "--setting-sources";
const CLAUDE_SAFE_SETTING_SOURCES = "user";
export function isClaudeCliProvider(providerId: string): boolean {
return normalizeOptionalLowercaseString(providerId) === CLAUDE_CLI_BACKEND_ID;
}
export function normalizeClaudePermissionArgs(args?: string[]): string[] | undefined {
if (!args) {
return 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;
normalized.push(arg);
normalized.push(maybeValue);
i += 1;
}
continue;
}
if (arg.startsWith(`${CLAUDE_PERMISSION_MODE_ARG}=`)) {
hasPermissionMode = true;
}
normalized.push(arg);
}
if (!hasPermissionMode) {
normalized.push(CLAUDE_PERMISSION_MODE_ARG, CLAUDE_BYPASS_PERMISSIONS_MODE);
}
return normalized;
}
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;
}
export function normalizeClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig {
return {
...config,
args: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.args)),
resumeArgs: normalizeClaudePermissionArgs(normalizeClaudeSettingSourcesArgs(config.resumeArgs)),
};
}

View file

@ -0,0 +1,274 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-shared.js";
const ANTHROPIC_PROVIDER_API = "anthropic-messages";
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 resolveAnthropicPrimaryModelRef(raw?: string): string | null {
if (!raw) {
return null;
}
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const aliasKey = normalizeLowercaseStringOrEmpty(trimmed);
if (aliasKey === "opus") {
return "anthropic/claude-opus-4-7";
}
if (aliasKey === "sonnet") {
return "anthropic/claude-sonnet-4-6";
}
return trimmed;
}
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.keys(config.agents?.defaults?.models ?? {}).some((key) => {
const parsed = parseProviderModelRef(key, "anthropic");
return parsed?.provider === CLAUDE_CLI_BACKEND_ID;
});
}
export 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 };
}
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 = resolveAnthropicPrimaryModelRef(
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;
}
}
}
if (modelsMutated) {
nextDefaults.models = nextModels;
mutated = true;
}
}
if (authMode === "oauth" && usesClaudeCliModelSelection(params.config)) {
const nextModels = defaults.models ? { ...defaults.models } : {};
let modelsMutated = false;
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
if (ref in nextModels) {
continue;
}
nextModels[ref] = {};
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,9 @@
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicServiceTierWrapper,
resolveAnthropicBetas,
resolveAnthropicFastMode,
resolveAnthropicServiceTier,
wrapAnthropicProviderStream,
} from "./stream-wrappers.js";

View file

@ -0,0 +1,301 @@
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import { capturePluginRegistration } from "openclaw/plugin-sdk/testing";
import { describe, expect, it, vi } from "vitest";
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
const { readClaudeCliCredentialsForSetupMock, readClaudeCliCredentialsForRuntimeMock } = vi.hoisted(
() => ({
readClaudeCliCredentialsForSetupMock: vi.fn(),
readClaudeCliCredentialsForRuntimeMock: vi.fn(),
}),
);
vi.mock("./cli-auth-seam.js", () => {
return {
readClaudeCliCredentialsForSetup: readClaudeCliCredentialsForSetupMock,
readClaudeCliCredentialsForRuntime: readClaudeCliCredentialsForRuntimeMock,
};
});
import anthropicPlugin from "./index.js";
function createModelRegistry(models: ProviderRuntimeModel[]) {
return {
find(providerId: string, modelId: string) {
return (
models.find(
(model) =>
model.provider === providerId && model.id.toLowerCase() === modelId.toLowerCase(),
) ?? null
);
},
};
}
describe("anthropic provider replay hooks", () => {
it("registers the claude-cli backend", async () => {
const captured = capturePluginRegistration({ register: anthropicPlugin.register });
expect(captured.cliBackends).toContainEqual(
expect.objectContaining({
id: "claude-cli",
bundleMcp: true,
config: expect.objectContaining({
command: "claude",
modelArg: "--model",
sessionArg: "--session-id",
}),
}),
);
});
it("owns native reasoning output mode for Claude transports", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.resolveReasoningOutputMode?.({
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-sonnet-4-6",
} as never),
).toBe("native");
});
it("owns replay policy for Claude transports", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.buildReplayPolicy?.({
provider: "anthropic",
modelApi: "anthropic-messages",
modelId: "claude-sonnet-4-6",
} as never),
).toEqual({
sanitizeMode: "full",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
preserveNativeAnthropicToolUseIds: true,
preserveSignatures: true,
repairToolUseResultPairing: true,
validateAnthropicTurns: true,
allowSyntheticToolResults: true,
});
});
it("defaults provider api through plugin config normalization", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.normalizeConfig?.({
provider: "anthropic",
providerConfig: {
models: [{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }],
},
} as never),
).toMatchObject({
api: "anthropic-messages",
});
});
it("applies Anthropic pruning defaults through plugin hooks", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const next = provider.applyConfigDefaults?.({
provider: "anthropic",
env: {},
config: {
auth: {
profiles: {
"anthropic:api": { provider: "anthropic", mode: "api_key" },
},
},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
},
},
},
} as never);
expect(next?.agents?.defaults?.contextPruning).toMatchObject({
mode: "cache-ttl",
ttl: "1h",
});
expect(next?.agents?.defaults?.heartbeat).toMatchObject({
every: "30m",
});
expect(
next?.agents?.defaults?.models?.["anthropic/claude-opus-4-5"]?.params?.cacheRetention,
).toBe("short");
});
it("backfills Claude CLI allowlist defaults through plugin hooks for older configs", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const next = provider.applyConfigDefaults?.({
provider: "anthropic",
env: {},
config: {
auth: {
profiles: {
"anthropic:claude-cli": { provider: "claude-cli", mode: "oauth" },
},
},
agents: {
defaults: {
model: { primary: "claude-cli/claude-opus-4-7" },
models: {
"claude-cli/claude-opus-4-7": {},
},
},
},
},
} as never);
expect(next?.agents?.defaults?.heartbeat).toMatchObject({
every: "1h",
});
expect(next?.agents?.defaults?.models).toMatchObject({
"claude-cli/claude-opus-4-7": {},
"claude-cli/claude-sonnet-4-6": {},
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-opus-4-5": {},
"claude-cli/claude-sonnet-4-5": {},
"claude-cli/claude-haiku-4-5": {},
});
});
it("resolves explicit claude-opus-4-7 refs from the 4.6 template family", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const resolved = provider.resolveDynamicModel?.({
provider: "anthropic",
modelId: "claude-opus-4-7",
modelRegistry: createModelRegistry([
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
provider: "anthropic",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_000,
} as ProviderRuntimeModel,
]),
} as ProviderResolveDynamicModelContext);
expect(resolved).toMatchObject({
provider: "anthropic",
id: "claude-opus-4-7",
api: "anthropic-messages",
reasoning: true,
});
expect(
provider.resolveDefaultThinkingLevel?.({
provider: "anthropic",
modelId: "claude-opus-4-7",
} as never),
).toBe("off");
expect(
provider.resolveDefaultThinkingLevel?.({
provider: "anthropic",
modelId: "claude-opus-4-6",
} as never),
).toBe("adaptive");
expect(
provider.supportsXHighThinking?.({
provider: "anthropic",
modelId: "claude-opus-4-7",
} as never),
).toBe(true);
expect(
provider.supportsXHighThinking?.({
provider: "anthropic",
modelId: "claude-opus-4-6",
} as never),
).toBe(false);
});
it("resolves claude-cli synthetic oauth auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "access-token",
refresh: "refresh-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.resolveSyntheticAuth?.({
provider: "claude-cli",
} as never),
).toEqual({
apiKey: "access-token",
source: "Claude CLI native auth",
mode: "oauth",
});
expect(readClaudeCliCredentialsForRuntimeMock).toHaveBeenCalledTimes(1);
});
it("resolves claude-cli synthetic token auth", async () => {
readClaudeCliCredentialsForRuntimeMock.mockReset();
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
type: "token",
provider: "anthropic",
token: "bearer-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
expect(
provider.resolveSyntheticAuth?.({
provider: "claude-cli",
} as never),
).toEqual({
apiKey: "bearer-token",
source: "Claude CLI native auth",
mode: "token",
});
});
it("stores a claude-cli auth profile during anthropic cli migration", async () => {
readClaudeCliCredentialsForSetupMock.mockReset();
readClaudeCliCredentialsForSetupMock.mockReturnValue({
type: "oauth",
provider: "anthropic",
access: "setup-access-token",
refresh: "refresh-token",
expires: 123,
});
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const cliAuth = provider.auth.find((entry) => entry.id === "cli");
expect(cliAuth).toBeDefined();
const result = await cliAuth?.run({
config: {},
} as never);
expect(result?.profiles).toEqual([
{
profileId: "anthropic:claude-cli",
credential: {
type: "oauth",
provider: "claude-cli",
access: "setup-access-token",
refresh: "refresh-token",
expires: 123,
},
},
]);
});
});

View file

@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerAnthropicPlugin } from "./register.runtime.js";
export default definePluginEntry({
id: "anthropic",
name: "Anthropic Provider",
description: "Bundled Anthropic provider plugin",
register(api) {
return registerAnthropicPlugin(api);
},
});

View file

@ -0,0 +1,15 @@
import {
describeImageWithModel,
describeImagesWithModel,
type MediaUnderstandingProvider,
} from "openclaw/plugin-sdk/media-understanding";
export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: "anthropic",
capabilities: ["image"],
defaultModels: { image: "claude-opus-4-7" },
autoPriority: { image: 20 },
nativeDocumentInputs: ["pdf"],
describeImage: describeImageWithModel,
describeImages: describeImagesWithModel,
};

View file

@ -0,0 +1,48 @@
{
"id": "anthropic",
"enabledByDefault": true,
"providers": ["anthropic"],
"modelSupport": {
"modelPrefixes": ["claude-"]
},
"cliBackends": ["claude-cli"],
"syntheticAuthRefs": ["claude-cli"],
"providerAuthEnvVars": {
"anthropic": ["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"
},
{
"provider": "anthropic",
"method": "api-key",
"choiceId": "apiKey",
"choiceLabel": "Anthropic API key",
"groupId": "anthropic",
"groupLabel": "Anthropic",
"groupHint": "Claude CLI + API key",
"optionKey": "anthropicApiKey",
"cliFlag": "--anthropic-api-key",
"cliOption": "--anthropic-api-key <key>",
"cliDescription": "Anthropic API key"
}
],
"contracts": {
"mediaUnderstandingProviders": ["anthropic"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,15 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.4.20",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,59 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
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,62 @@
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-types";
import { describe, expect, it } from "vitest";
import { applyConfigDefaults, normalizeConfig } 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,
};
}
describe("anthropic provider policy public artifact", () => {
it("normalizes Anthropic provider config", () => {
expect(
normalizeConfig({
provider: "anthropic",
providerConfig: {
baseUrl: "https://api.anthropic.com",
models: [createModel("claude-sonnet-4-6", "Claude Sonnet 4.6")],
},
}),
).toMatchObject({
api: "anthropic-messages",
baseUrl: "https://api.anthropic.com",
});
});
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).toMatchObject({
mode: "cache-ttl",
ttl: "1h",
});
});
});

View file

@ -0,0 +1,13 @@
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
import {
applyAnthropicConfigDefaults,
normalizeAnthropicProviderConfig,
} from "./config-defaults.js";
export function normalizeConfig(params: { provider: string; providerConfig: ModelProviderConfig }) {
return normalizeAnthropicProviderConfig(params.providerConfig);
}
export function applyConfigDefaults(params: Parameters<typeof applyAnthropicConfigDefaults>[0]) {
return applyAnthropicConfigDefaults(params);
}

View file

@ -0,0 +1,517 @@
import { formatCliCommand, parseDurationMs } from "openclaw/plugin-sdk/cli-runtime";
import type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/plugin-entry";
import {
applyAuthProfileConfig,
type AuthProfileStore,
buildTokenProfileId,
createProviderApiKeyAuthMethod,
listProfilesForProvider,
type OpenClawConfig as ProviderAuthConfig,
type ProviderAuthResult,
suggestOAuthProfileIdForLegacyDefault,
upsertAuthProfile,
validateAnthropicSetupToken,
} from "openclaw/plugin-sdk/provider-auth";
import {
cloneFirstTemplateModel,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as claudeCliAuth from "./cli-auth-seam.js";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import { buildAnthropicCliMigrationResult } from "./cli-migration.js";
import {
CLAUDE_CLI_BACKEND_ID,
CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS,
CLAUDE_CLI_DEFAULT_MODEL_REF,
} from "./cli-shared.js";
import {
applyAnthropicConfigDefaults,
normalizeAnthropicProviderConfig,
} from "./config-defaults.js";
import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { buildAnthropicReplayPolicy } from "./replay-policy.js";
import { wrapAnthropicProviderStream } from "./stream-wrappers.js";
const PROVIDER_ID = "anthropic";
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-opus-4-7";
const ANTHROPIC_OPUS_47_MODEL_ID = "claude-opus-4-7";
const ANTHROPIC_OPUS_47_DOT_MODEL_ID = "claude-opus-4.7";
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,
"claude-opus-4-5",
"claude-opus-4.5",
] as const;
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] 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_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const;
const ANTHROPIC_MODERN_MODEL_PREFIXES = [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
] as const;
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;
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 Date.now() + 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);
upsertAuthProfile({
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);
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 resolveAnthropicForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
return (
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: "claude-opus-4-5",
dotTemplateId: "claude-opus-4.5",
fallbackTemplateIds: ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS,
}) ??
resolveAnthropic46ForwardCompatModel({
ctx,
dashModelId: ANTHROPIC_SONNET_46_MODEL_ID,
dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID,
dashTemplateId: "claude-sonnet-4-5",
dotTemplateId: "claude-sonnet-4.5",
fallbackTemplateIds: ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS,
})
);
}
function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean {
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
return (
lowerModelId.startsWith(ANTHROPIC_OPUS_46_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_DOT_MODEL_ID)
);
}
function isAnthropicOpus47Model(modelId: string): boolean {
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
return (
lowerModelId.startsWith(ANTHROPIC_OPUS_47_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_OPUS_47_DOT_MODEL_ID)
);
}
function matchesAnthropicModernModel(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
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,
}
: {
apiKey: credential.token,
source: "Claude CLI native auth",
mode: "token" as const,
};
}
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,
},
},
},
};
}
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 switch model selection to claude-cli/*",
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_DEFAULT_ALLOWLIST_REFS],
initialSelections: [CLAUDE_CLI_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: ({ providerConfig }) => normalizeAnthropicProviderConfig(providerConfig),
applyConfigDefaults: ({ config, env }) => applyAnthropicConfigDefaults({ config, env }),
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
resolveSyntheticAuth: ({ provider }) =>
normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID
? resolveClaudeCliSyntheticAuth()
: undefined,
buildReplayPolicy: buildAnthropicReplayPolicy,
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),
resolveReasoningOutputMode: () => "native",
supportsXHighThinking: ({ modelId }) => isAnthropicOpus47Model(modelId),
wrapStreamFn: wrapAnthropicProviderStream,
resolveDefaultThinkingLevel: ({ modelId }) =>
isAnthropicOpus47Model(modelId)
? "off"
: matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId)
? "adaptive"
: undefined,
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
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,
}),
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider(buildAnthropicProvider());
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
}

View file

@ -0,0 +1,9 @@
import { NATIVE_ANTHROPIC_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
const { buildReplayPolicy } = NATIVE_ANTHROPIC_REPLAY_HOOKS;
if (!buildReplayPolicy) {
throw new Error("Expected native Anthropic replay hooks to expose buildReplayPolicy.");
}
export { buildReplayPolicy as buildAnthropicReplayPolicy };

View file

@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildAnthropicCliBackend } from "./cli-backend.js";
export default definePluginEntry({
id: "anthropic",
name: "Anthropic Setup",
description: "Lightweight Anthropic setup hooks",
register(api) {
api.registerCliBackend(buildAnthropicCliBackend());
},
});

View file

@ -0,0 +1,218 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__testing,
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,
createAnthropicServiceTierWrapper,
wrapAnthropicProviderStream,
} from "./stream-wrappers.js";
const CONTEXT_1M_BETA = "context-1m-2025-08-07";
const OAUTH_BETA = "oauth-2025-04-20";
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;
}
describe("anthropic stream wrappers", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("strips context-1m for Claude CLI or legacy token auth and warns", () => {
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).toHaveBeenCalledOnce();
});
it("keeps context-1m 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"]).toContain(CONTEXT_1M_BETA);
expect(warn).not.toHaveBeenCalled();
});
it("skips service_tier for OAuth token in composed stream chain", () => {
const captured: { headers?: Record<string, string>; payload?: Record<string, unknown> } = {};
const base: StreamFn = (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;
};
const wrapped = wrapAnthropicProviderStream({
streamFn: base,
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: "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);
expect(captured.payload?.service_tier).toBeUndefined();
});
it("composes the anthropic provider stream chain from extra params", () => {
const captured: { headers?: Record<string, string>; payload?: Record<string, unknown> } = {};
const base: StreamFn = (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;
};
const wrapped = wrapAnthropicProviderStream({
streamFn: base,
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: "sk-ant-api-123" } as never,
);
expect(captured.headers?.["anthropic-beta"]).toContain(CONTEXT_1M_BETA);
expect(captured.payload).toMatchObject({ service_tier: "auto" });
});
});
describe("createAnthropicFastModeWrapper", () => {
function runFastModeWrapper(params: {
apiKey?: string;
provider?: string;
api?: string;
baseUrl?: string;
enabled?: boolean;
}): Record<string, unknown> | undefined {
const captured: { payload?: Record<string, unknown> } = {};
const base: StreamFn = (_model, _context, options) => {
if (options?.onPayload) {
const payload: Record<string, unknown> = {};
options.onPayload(payload, _model);
captured.payload = payload;
}
return {} as never;
};
const wrapper = createAnthropicFastModeWrapper(base, params.enabled ?? true);
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;
}
it("does not inject service_tier for OAuth token", () => {
const payload = runFastModeWrapper({ apiKey: "sk-ant-oat01-test-token" });
expect(payload?.service_tier).toBeUndefined();
});
it("injects service_tier for regular API keys", () => {
const payload = runFastModeWrapper({ apiKey: "sk-ant-api03-test-key" });
expect(payload?.service_tier).toBe("auto");
});
it("injects service_tier=standard_only when disabled for API keys", () => {
const payload = runFastModeWrapper({ apiKey: "sk-ant-api03-test-key", enabled: false });
expect(payload?.service_tier).toBe("standard_only");
});
it("does not inject service_tier for non-anthropic provider", () => {
const payload = runFastModeWrapper({
apiKey: "sk-ant-api03-test-key",
provider: "openai",
api: "openai-completions",
});
expect(payload?.service_tier).toBeUndefined();
});
});
describe("createAnthropicServiceTierWrapper", () => {
function runServiceTierWrapper(params: {
apiKey?: string;
provider?: string;
api?: string;
serviceTier?: "auto" | "standard_only";
}): Record<string, unknown> | undefined {
const captured: { payload?: Record<string, unknown> } = {};
const base: StreamFn = (_model, _context, options) => {
if (options?.onPayload) {
const payload: Record<string, unknown> = {};
options.onPayload(payload, _model);
captured.payload = payload;
}
return {} as never;
};
const wrapper = createAnthropicServiceTierWrapper(base, params.serviceTier ?? "auto");
void wrapper(
{
provider: params.provider ?? "anthropic",
api: params.api ?? "anthropic-messages",
id: "claude-sonnet-4-6",
} as never,
{} as never,
{ apiKey: params.apiKey } as never,
);
return captured.payload;
}
it("does not inject service_tier for OAuth token", () => {
const payload = runServiceTierWrapper({ apiKey: "sk-ant-oat01-test-token" });
expect(payload?.service_tier).toBeUndefined();
});
it("injects service_tier for regular API keys", () => {
const payload = runServiceTierWrapper({ apiKey: "sk-ant-api03-test-key" });
expect(payload?.service_tier).toBe("auto");
});
it("injects service_tier=standard_only for regular API keys", () => {
const payload = runServiceTierWrapper({
apiKey: "sk-ant-api03-test-key",
serviceTier: "standard_only",
});
expect(payload?.service_tier).toBe("standard_only");
});
it("does not inject service_tier for non-anthropic provider", () => {
const payload = runServiceTierWrapper({
apiKey: "sk-ant-api03-test-key",
provider: "openai",
api: "openai-completions",
});
expect(payload?.service_tier).toBeUndefined();
});
});

View file

@ -0,0 +1,244 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import {
applyAnthropicPayloadPolicyToParams,
composeProviderStreamWrappers,
resolveAnthropicPayloadPolicy,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty, readStringValue } from "openclaw/plugin-sdk/text-runtime";
const log = createSubsystemLogger("anthropic-stream");
const ANTHROPIC_CONTEXT_1M_BETA = "context-1m-2025-08-07";
const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const;
const PI_AI_DEFAULT_ANTHROPIC_BETAS = [
"fine-grained-tool-streaming-2025-05-14",
"interleaved-thinking-2025-05-14",
] as const;
const PI_AI_OAUTH_ANTHROPIC_BETAS = [
"claude-code-20250219",
"oauth-2025-04-20",
...PI_AI_DEFAULT_ANTHROPIC_BETAS,
] as const;
type AnthropicServiceTier = "auto" | "standard_only";
function isAnthropic1MModel(modelId: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(modelId);
return ANTHROPIC_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 normalizeFastMode(raw?: string | boolean | null): boolean | undefined {
if (typeof raw === "boolean") {
return raw;
}
if (!raw) {
return undefined;
}
const key = normalizeLowercaseStringOrEmpty(raw);
if (["off", "false", "no", "0", "disable", "disabled", "normal"].includes(key)) {
return false;
}
if (["on", "true", "yes", "1", "enable", "enabled", "fast"].includes(key)) {
return true;
}
return undefined;
}
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;
}
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()) {
betas.add(configured.trim());
} else if (Array.isArray(configured)) {
for (const beta of configured) {
if (typeof beta === "string" && beta.trim()) {
betas.add(beta.trim());
}
}
}
if (extraParams?.context1m === true) {
if (isAnthropic1MModel(modelId)) {
betas.add(ANTHROPIC_CONTEXT_1M_BETA);
} else {
log.warn(`ignoring context1m for non-opus/sonnet model: anthropic/${modelId}`);
}
}
return betas.size > 0 ? [...betas] : undefined;
}
export function createAnthropicBetaHeadersWrapper(
baseStreamFn: StreamFn | undefined,
betas: string[],
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const isOauth = isAnthropicOAuthApiKey(options?.apiKey);
const requestedContext1m = betas.includes(ANTHROPIC_CONTEXT_1M_BETA);
const effectiveBetas =
isOauth && requestedContext1m
? betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA)
: betas;
if (isOauth && requestedContext1m) {
log.warn(
`ignoring context1m for Anthropic Claude CLI or legacy token auth on ${model.provider}/${model.id}; falling back to the standard context window because Anthropic rejects context-1m beta with non-API-key auth`,
);
}
const piAiBetas = isOauth
? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[])
: (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]);
const allBetas = [...new Set([...piAiBetas, ...effectiveBetas])];
return underlying(model, context, {
...options,
headers: mergeAnthropicBetaHeader(options?.headers, allBetas),
});
};
}
export function createAnthropicFastModeWrapper(
baseStreamFn: StreamFn | undefined,
enabled: boolean,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
const serviceTier = resolveAnthropicFastServiceTier(enabled);
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),
);
};
}
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),
);
};
}
export function resolveAnthropicFastMode(
extraParams: Record<string, unknown> | undefined,
): boolean | undefined {
return normalizeFastMode(
(extraParams?.fastMode ?? extraParams?.fast_mode) as string | boolean | null | undefined,
);
}
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;
}
export function wrapAnthropicProviderStream(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
const anthropicBetas = resolveAnthropicBetas(ctx.extraParams, ctx.modelId);
const serviceTier = resolveAnthropicServiceTier(ctx.extraParams);
const fastMode = resolveAnthropicFastMode(ctx.extraParams);
return composeProviderStreamWrappers(
ctx.streamFn,
anthropicBetas?.length
? (streamFn) => createAnthropicBetaHeadersWrapper(streamFn, anthropicBetas)
: undefined,
serviceTier
? (streamFn) => createAnthropicServiceTierWrapper(streamFn, serviceTier)
: undefined,
fastMode !== undefined
? (streamFn) => createAnthropicFastModeWrapper(streamFn, fastMode)
: undefined,
);
}
export const __testing = { log };

View file

@ -0,0 +1,3 @@
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"
]
}