mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 20:13:14 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
11
openclaw/extensions/anthropic/api.ts
Normal file
11
openclaw/extensions/anthropic/api.ts
Normal 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";
|
||||
13
openclaw/extensions/anthropic/cli-auth-seam.ts
Normal file
13
openclaw/extensions/anthropic/cli-auth-seam.ts
Normal 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 });
|
||||
}
|
||||
6
openclaw/extensions/anthropic/cli-backend-api.ts
Normal file
6
openclaw/extensions/anthropic/cli-backend-api.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
isClaudeCliProvider,
|
||||
normalizeClaudeBackendConfig,
|
||||
} from "./cli-shared.js";
|
||||
76
openclaw/extensions/anthropic/cli-backend.ts
Normal file
76
openclaw/extensions/anthropic/cli-backend.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
348
openclaw/extensions/anthropic/cli-migration.test.ts
Normal file
348
openclaw/extensions/anthropic/cli-migration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
192
openclaw/extensions/anthropic/cli-migration.ts
Normal file
192
openclaw/extensions/anthropic/cli-migration.ts
Normal 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(", ")}.`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
158
openclaw/extensions/anthropic/cli-shared.test.ts
Normal file
158
openclaw/extensions/anthropic/cli-shared.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
177
openclaw/extensions/anthropic/cli-shared.ts
Normal file
177
openclaw/extensions/anthropic/cli-shared.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
274
openclaw/extensions/anthropic/config-defaults.ts
Normal file
274
openclaw/extensions/anthropic/config-defaults.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
9
openclaw/extensions/anthropic/contract-api.ts
Normal file
9
openclaw/extensions/anthropic/contract-api.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
createAnthropicBetaHeadersWrapper,
|
||||
createAnthropicFastModeWrapper,
|
||||
createAnthropicServiceTierWrapper,
|
||||
resolveAnthropicBetas,
|
||||
resolveAnthropicFastMode,
|
||||
resolveAnthropicServiceTier,
|
||||
wrapAnthropicProviderStream,
|
||||
} from "./stream-wrappers.js";
|
||||
301
openclaw/extensions/anthropic/index.test.ts
Normal file
301
openclaw/extensions/anthropic/index.test.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
11
openclaw/extensions/anthropic/index.ts
Normal file
11
openclaw/extensions/anthropic/index.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
48
openclaw/extensions/anthropic/openclaw.plugin.json
Normal file
48
openclaw/extensions/anthropic/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
15
openclaw/extensions/anthropic/package.json
Normal file
15
openclaw/extensions/anthropic/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
59
openclaw/extensions/anthropic/provider-contract-api.ts
Normal file
59
openclaw/extensions/anthropic/provider-contract-api.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
62
openclaw/extensions/anthropic/provider-policy-api.test.ts
Normal file
62
openclaw/extensions/anthropic/provider-policy-api.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
13
openclaw/extensions/anthropic/provider-policy-api.ts
Normal file
13
openclaw/extensions/anthropic/provider-policy-api.ts
Normal 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);
|
||||
}
|
||||
517
openclaw/extensions/anthropic/register.runtime.ts
Normal file
517
openclaw/extensions/anthropic/register.runtime.ts
Normal 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);
|
||||
}
|
||||
9
openclaw/extensions/anthropic/replay-policy.ts
Normal file
9
openclaw/extensions/anthropic/replay-policy.ts
Normal 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 };
|
||||
11
openclaw/extensions/anthropic/setup-api.ts
Normal file
11
openclaw/extensions/anthropic/setup-api.ts
Normal 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());
|
||||
},
|
||||
});
|
||||
218
openclaw/extensions/anthropic/stream-wrappers.test.ts
Normal file
218
openclaw/extensions/anthropic/stream-wrappers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
244
openclaw/extensions/anthropic/stream-wrappers.ts
Normal file
244
openclaw/extensions/anthropic/stream-wrappers.ts
Normal 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 };
|
||||
3
openclaw/extensions/anthropic/test-api.ts
Normal file
3
openclaw/extensions/anthropic/test-api.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
export { normalizeClaudeBackendConfig } from "./cli-shared.js";
|
||||
export { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
16
openclaw/extensions/anthropic/tsconfig.json
Normal file
16
openclaw/extensions/anthropic/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue