mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 17:23:25 +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
1
openclaw/extensions/.npmignore
Normal file
1
openclaw/extensions/.npmignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
**/node_modules/
|
||||
75
openclaw/extensions/AGENTS.md
Normal file
75
openclaw/extensions/AGENTS.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Extensions Boundary
|
||||
|
||||
This directory contains bundled plugins. Treat it as the same boundary that
|
||||
third-party plugins see.
|
||||
|
||||
## Public Contracts
|
||||
|
||||
- Docs:
|
||||
- `docs/plugins/building-plugins.md`
|
||||
- `docs/plugins/architecture.md`
|
||||
- `docs/plugins/sdk-overview.md`
|
||||
- `docs/plugins/sdk-entrypoints.md`
|
||||
- `docs/plugins/sdk-runtime.md`
|
||||
- `docs/plugins/sdk-channel-plugins.md`
|
||||
- `docs/plugins/sdk-provider-plugins.md`
|
||||
- `docs/plugins/manifest.md`
|
||||
- Definition files:
|
||||
- `src/plugin-sdk/plugin-entry.ts`
|
||||
- `src/plugin-sdk/core.ts`
|
||||
- `src/plugin-sdk/provider-entry.ts`
|
||||
- `src/plugin-sdk/channel-contract.ts`
|
||||
- `scripts/lib/plugin-sdk-entrypoints.json`
|
||||
- `package.json`
|
||||
|
||||
## Boundary Rules
|
||||
|
||||
- Extension production code should import from `openclaw/plugin-sdk/*` and its
|
||||
own local barrels such as `./api.ts` and `./runtime-api.ts`.
|
||||
- Do not import core internals from `src/**`, `src/channels/**`,
|
||||
`src/plugin-sdk-internal/**`, or another extension's `src/**`.
|
||||
- Do not use relative imports that escape the current extension package root.
|
||||
- Keep plugin metadata accurate in `openclaw.plugin.json` and the package
|
||||
`openclaw` block so discovery and setup work without executing plugin code.
|
||||
- Treat files like `src/**`, `onboard.ts`, and other local helpers as private
|
||||
unless you intentionally promote them through `api.ts` and, if needed, a
|
||||
matching `src/plugin-sdk/<id>.ts` facade.
|
||||
- If core or core tests need a bundled plugin helper, export it from `api.ts`
|
||||
first instead of letting them deep-import extension internals.
|
||||
- For provider plugins, keep auth, onboarding, catalog selection, and
|
||||
vendor-only product behavior local to the plugin. Do not move those into
|
||||
core just because two providers look similar.
|
||||
- Before adding a new provider-local `wrapStreamFn`, `buildReplayPolicy`,
|
||||
`normalizeToolSchemas`, `inspectToolSchemas`, or compat patch helper, check
|
||||
whether the same behavior already exists through `openclaw/plugin-sdk/*`.
|
||||
Reuse shared family helpers first.
|
||||
- If two bundled providers share the same replay policy shape, tool-schema
|
||||
compat rewrite, payload patch, or stream-wrapper chain, stop copying the
|
||||
logic. Extract one shared helper and migrate both call sites in the same
|
||||
change.
|
||||
- Prefer named provider-family helpers over repeating raw option bags. If a
|
||||
provider needs OpenAI-style Anthropic tool payload compat, Gemini schema
|
||||
cleanup, or an XAI compat patch, use a named shared helper instead of
|
||||
inlining the policy knobs again.
|
||||
- Keep control-plane metadata separate from runtime logic. Discovery, config
|
||||
validation, setup hints, onboarding hints, and activation planning should be
|
||||
expressible from manifest/descriptors whenever possible.
|
||||
- If setup truly requires runtime execution, make that explicit in the plugin's
|
||||
declared setup/runtime surface instead of letting metadata flows import
|
||||
runtime code accidentally.
|
||||
- Do not rely on eager global registry seeding or import-time side effects to
|
||||
make a plugin “available”. Plugin availability should come from manifest
|
||||
ownership plus targeted activation.
|
||||
- When core needs plugin-owned static data on a hot path, expose a lightweight
|
||||
top-level artifact such as `gateway-auth-api.ts`, `message-tool-api.ts`, or a
|
||||
similarly narrow `*-api.ts`. Reuse the same local helper from the artifact and
|
||||
the full plugin so fast paths do not drift from runtime behavior.
|
||||
|
||||
## Expanding The Boundary
|
||||
|
||||
- If an extension needs a new seam, add a typed Plugin SDK subpath or additive
|
||||
export instead of reaching into core.
|
||||
- Keep new plugin-facing seams backwards-compatible and versioned. Third-party
|
||||
plugins consume this surface.
|
||||
- When intentionally expanding the contract, update the docs, exported subpath
|
||||
list, package exports, and API/contract checks in the same change.
|
||||
1
openclaw/extensions/CLAUDE.md
Normal file
1
openclaw/extensions/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
54
openclaw/extensions/acpx/AGENTS.md
Normal file
54
openclaw/extensions/acpx/AGENTS.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# ACPX Extension Notes
|
||||
|
||||
This file applies to work under `extensions/acpx/`.
|
||||
|
||||
## Purpose
|
||||
|
||||
The bundled ACPX extension is a thin OpenClaw wrapper around the published `acpx` package. Keep reusable ACP runtime logic in `openclaw/acpx`, not in this extension.
|
||||
|
||||
## Default Version Policy
|
||||
|
||||
- `extensions/acpx/package.json` should point at a published npm release by default.
|
||||
- Do not leave the extension pinned to a temporary GitHub commit or local checkout once the ACPX release exists.
|
||||
- Do not leave temporary pnpm build-script allowlist exceptions behind after switching back to a published ACPX package.
|
||||
|
||||
## Unreleased ACPX Development Flow
|
||||
|
||||
Use this flow when OpenClaw needs unreleased ACPX changes before the ACPX version is published.
|
||||
|
||||
1. Make the ACPX code change in the `openclaw/acpx` repo first.
|
||||
2. In OpenClaw, temporarily point `extensions/acpx/package.json` at the ACPX GitHub commit you need.
|
||||
3. If pnpm blocks ACPX lifecycle/build scripts for that temporary GitHub-sourced package, temporarily add `acpx` to `onlyBuiltDependencies` in both `package.json` and `pnpm-workspace.yaml`.
|
||||
4. Refresh the root workspace lock:
|
||||
- `pnpm install --lockfile-only --filter ./extensions/acpx`
|
||||
5. Refresh the extension-local npm lock for install metadata:
|
||||
- `cd extensions/acpx && npm install --package-lock-only --ignore-scripts`
|
||||
6. Rebuild OpenClaw and restart the gateway before doing live ACP validation.
|
||||
7. Once ACPX is released, switch `extensions/acpx/package.json` back to the published npm version and refresh the same lockfiles again.
|
||||
8. Remove any temporary `acpx` build-script allowlist entries that were only needed for the GitHub-sourced development pin.
|
||||
|
||||
## Lockfile Notes
|
||||
|
||||
- `pnpm-lock.yaml` is the tracked workspace lockfile and must match the ACPX version referenced by `extensions/acpx/package.json`.
|
||||
- `extensions/acpx/package-lock.json` is useful local install metadata for the bundled plugin package.
|
||||
- If `extensions/acpx/package-lock.json` is gitignored in this repo state, regenerating it is still useful for local verification, but it will not appear in `git status`.
|
||||
|
||||
## Local Runtime Validation
|
||||
|
||||
When ACPX integration changes here, prefer this sequence:
|
||||
|
||||
1. `pnpm install --filter ./extensions/acpx`
|
||||
2. `pnpm test:extension acpx`
|
||||
3. `pnpm build`
|
||||
4. Restart the local gateway if ACP runtime behavior or bundled plugin wiring changed.
|
||||
5. If the change affects direct ACP behavior in chat, run a real ACP smoke after restart.
|
||||
|
||||
## Direct ACPX Binary Policy
|
||||
|
||||
- Prefer the plugin-local ACPX binary under `extensions/acpx/node_modules/.bin/acpx`.
|
||||
- Do not rely on a globally installed `acpx` binary for OpenClaw ACP validation.
|
||||
- If the plugin-local ACPX binary is missing or on the wrong version, reinstall it from the version pinned in `extensions/acpx/package.json`.
|
||||
|
||||
## Boundary Rule
|
||||
|
||||
If a change feels like shared ACP runtime behavior instead of OpenClaw-specific glue, move it to `openclaw/acpx` and consume it from here instead of re-implementing it inside `extensions/acpx`.
|
||||
1
openclaw/extensions/acpx/CLAUDE.md
Normal file
1
openclaw/extensions/acpx/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
41
openclaw/extensions/acpx/index.test.ts
Normal file
41
openclaw/extensions/acpx/index.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createAcpxRuntimeServiceMock, tryDispatchAcpReplyHookMock } = vi.hoisted(() => ({
|
||||
createAcpxRuntimeServiceMock: vi.fn(),
|
||||
tryDispatchAcpReplyHookMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./register.runtime.js", () => ({
|
||||
createAcpxRuntimeService: createAcpxRuntimeServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
tryDispatchAcpReplyHook: tryDispatchAcpReplyHookMock,
|
||||
}));
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("acpx plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("registers the runtime service and reply_dispatch hook", () => {
|
||||
const service = { id: "acpx-service", start: vi.fn() };
|
||||
createAcpxRuntimeServiceMock.mockReturnValue(service);
|
||||
|
||||
const api = {
|
||||
pluginConfig: { stateDir: "/tmp/acpx" },
|
||||
registerService: vi.fn(),
|
||||
on: vi.fn(),
|
||||
};
|
||||
|
||||
plugin.register(api as never);
|
||||
|
||||
expect(createAcpxRuntimeServiceMock).toHaveBeenCalledWith({
|
||||
pluginConfig: api.pluginConfig,
|
||||
});
|
||||
expect(api.registerService).toHaveBeenCalledWith(service);
|
||||
expect(api.on).toHaveBeenCalledWith("reply_dispatch", tryDispatchAcpReplyHookMock);
|
||||
});
|
||||
});
|
||||
20
openclaw/extensions/acpx/index.ts
Normal file
20
openclaw/extensions/acpx/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { createAcpxRuntimeService } from "./register.runtime.js";
|
||||
import { tryDispatchAcpReplyHook, type OpenClawPluginApi } from "./runtime-api.js";
|
||||
import { createAcpxPluginConfigSchema } from "./src/config-schema.js";
|
||||
|
||||
const plugin = {
|
||||
id: "acpx",
|
||||
name: "ACPX Runtime",
|
||||
description: "Embedded ACP runtime backend with plugin-owned session and transport management.",
|
||||
configSchema: () => createAcpxPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerService(
|
||||
createAcpxRuntimeService({
|
||||
pluginConfig: api.pluginConfig,
|
||||
}),
|
||||
);
|
||||
api.on("reply_dispatch", tryDispatchAcpReplyHook);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
146
openclaw/extensions/acpx/openclaw.plugin.json
Normal file
146
openclaw/extensions/acpx/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"id": "acpx",
|
||||
"enabledByDefault": true,
|
||||
"name": "ACPX Runtime",
|
||||
"description": "Embedded ACP runtime backend with plugin-owned session and transport management.",
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"stateDir": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"permissionMode": {
|
||||
"type": "string",
|
||||
"enum": ["approve-all", "approve-reads", "deny-all"]
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"type": "string",
|
||||
"enum": ["deny", "fail"]
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0.001,
|
||||
"default": 120
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"mcpServers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Command to run the MCP server"
|
||||
},
|
||||
"args": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Arguments to pass to the command"
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" },
|
||||
"description": "Environment variables for the MCP server"
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"cwd": {
|
||||
"label": "Default Working Directory",
|
||||
"help": "Default working directory for embedded ACP session operations when not set per session."
|
||||
},
|
||||
"stateDir": {
|
||||
"label": "State Directory",
|
||||
"help": "Directory used for embedded ACP session state and persistence."
|
||||
},
|
||||
"permissionMode": {
|
||||
"label": "Permission Mode",
|
||||
"help": "Default permission policy for embedded ACP runtime prompts."
|
||||
},
|
||||
"nonInteractivePermissions": {
|
||||
"label": "Non-Interactive Permission Policy",
|
||||
"help": "Policy when interactive permission prompts are unavailable."
|
||||
},
|
||||
"pluginToolsMcpBridge": {
|
||||
"label": "Plugin Tools MCP Bridge",
|
||||
"help": "Default off. When enabled, inject the built-in OpenClaw plugin-tools MCP server into embedded ACP sessions so ACP agents can call plugin-registered tools.",
|
||||
"advanced": true
|
||||
},
|
||||
"strictWindowsCmdWrapper": {
|
||||
"label": "Strict Windows cmd Wrapper",
|
||||
"help": "Legacy compatibility field. The current embedded acpx/runtime package uses its own Windows command resolution behavior. Setting this to false is accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"timeoutSeconds": {
|
||||
"label": "Prompt Timeout Seconds",
|
||||
"help": "Timeout for each embedded runtime turn. Defaults to 120 seconds so slower Gemini CLI ACP startups have room to initialize.",
|
||||
"advanced": true
|
||||
},
|
||||
"queueOwnerTtlSeconds": {
|
||||
"label": "Queue Owner TTL Seconds",
|
||||
"help": "Reserved compatibility field for the older embedded ACPX queue-owner path. Accepted for compatibility and logged as ignored.",
|
||||
"advanced": true
|
||||
},
|
||||
"mcpServers": {
|
||||
"label": "MCP Servers",
|
||||
"help": "Named MCP server definitions to inject into embedded ACP session bootstrap. Each entry needs a command and can include args and env.",
|
||||
"advanced": true
|
||||
},
|
||||
"agents": {
|
||||
"label": "Agent Commands",
|
||||
"help": "Optional per-agent command overrides for the embedded ACP runtime.",
|
||||
"advanced": true
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"dangerousFlags": [
|
||||
{
|
||||
"path": "permissionMode",
|
||||
"equals": "approve-all"
|
||||
}
|
||||
],
|
||||
"secretInputs": {
|
||||
"bundledDefaultEnabled": false,
|
||||
"paths": [
|
||||
{
|
||||
"path": "mcpServers.*.env.*",
|
||||
"expected": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
17
openclaw/extensions/acpx/package.json
Normal file
17
openclaw/extensions/acpx/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.4.20",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"acpx": "0.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
openclaw/extensions/acpx/register.runtime.ts
Normal file
1
openclaw/extensions/acpx/register.runtime.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createAcpxRuntimeService } from "./src/service.js";
|
||||
46
openclaw/extensions/acpx/runtime-api.ts
Normal file
46
openclaw/extensions/acpx/runtime-api.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime";
|
||||
export {
|
||||
AcpRuntimeError,
|
||||
getAcpRuntimeBackend,
|
||||
tryDispatchAcpReplyHook,
|
||||
registerAcpRuntimeBackend,
|
||||
unregisterAcpRuntimeBackend,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
export type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnAttachment,
|
||||
AcpRuntimeTurnInput,
|
||||
AcpSessionUpdateTag,
|
||||
} from "openclaw/plugin-sdk/acp-runtime";
|
||||
export type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginConfigSchema,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
PluginHookReplyDispatchContext,
|
||||
PluginHookReplyDispatchEvent,
|
||||
PluginHookReplyDispatchResult,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type {
|
||||
WindowsSpawnProgram,
|
||||
WindowsSpawnProgramCandidate,
|
||||
WindowsSpawnResolution,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
applyWindowsSpawnProgramPolicy,
|
||||
materializeWindowsSpawnProgram,
|
||||
resolveWindowsSpawnProgramCandidate,
|
||||
} from "openclaw/plugin-sdk/windows-spawn";
|
||||
export {
|
||||
listKnownProviderAuthEnvVarNames,
|
||||
omitEnvKeysCaseInsensitive,
|
||||
} from "openclaw/plugin-sdk/provider-env-vars";
|
||||
18
openclaw/extensions/acpx/setup-api.ts
Normal file
18
openclaw/extensions/acpx/setup-api.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "acpx",
|
||||
name: "ACPX Setup",
|
||||
description: "Lightweight ACPX setup hooks",
|
||||
register(api) {
|
||||
api.registerAutoEnableProbe(({ config }) => {
|
||||
const backendRaw = normalizeLowercaseStringOrEmpty(config.acp?.backend);
|
||||
const configured =
|
||||
config.acp?.enabled === true ||
|
||||
config.acp?.dispatch?.enabled === true ||
|
||||
backendRaw === "acpx";
|
||||
return configured && (!backendRaw || backendRaw === "acpx") ? "ACP runtime configured" : null;
|
||||
});
|
||||
},
|
||||
});
|
||||
245
openclaw/extensions/acpx/skills/acp-router/SKILL.md
Normal file
245
openclaw/extensions/acpx/skills/acp-router/SKILL.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
---
|
||||
name: acp-router
|
||||
description: Route plain-language requests for Pi, Claude Code, Codex, Cursor, Copilot, OpenClaw ACP, OpenCode, Gemini CLI, Qwen, Kiro, Kimi, iFlow, Factory Droid, Kilocode, or ACP harness work into either OpenClaw ACP runtime sessions or direct acpx-driven sessions ("telephone game" flow). For coding-agent thread requests, read this skill first, then use only `sessions_spawn` for thread creation.
|
||||
user-invocable: false
|
||||
---
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Pi/Claude Code/Codex/Cursor/Copilot/OpenClaw/OpenCode/Gemini/Qwen/Kiro/Kimi/iFlow/Droid/Kilocode (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
|
||||
## Intent detection
|
||||
|
||||
Trigger this skill when the user asks OpenClaw to:
|
||||
|
||||
- run something in Pi / Claude Code / Codex / Cursor / Copilot / OpenClaw / OpenCode / Gemini / Qwen / Kiro / Kimi / iFlow / Droid / Kilocode
|
||||
- continue existing harness work
|
||||
- relay instructions to an external coding harness
|
||||
- keep an external harness conversation in a thread-like conversation
|
||||
|
||||
Mandatory preflight for coding-agent thread requests:
|
||||
|
||||
- Before creating any thread for ACP harness work, read this skill first in the same turn.
|
||||
- After reading, follow `OpenClaw ACP runtime path` below; do not use `message(action="thread-create")` for ACP harness thread spawn.
|
||||
|
||||
## Mode selection
|
||||
|
||||
Choose one of these paths:
|
||||
|
||||
1. OpenClaw ACP runtime path (default): use `sessions_spawn` / ACP runtime tools.
|
||||
2. Direct `acpx` path (telephone game): use `acpx` CLI through `exec` to drive the harness session directly.
|
||||
|
||||
Use direct `acpx` when one of these is true:
|
||||
|
||||
- user explicitly asks for direct `acpx` driving
|
||||
- ACP runtime/plugin path is unavailable or unhealthy
|
||||
- the task is "just relay prompts to harness" and no OpenClaw ACP lifecycle features are needed
|
||||
|
||||
Do not use:
|
||||
|
||||
- `subagents` runtime for harness control
|
||||
- `/acp` command delegation as a requirement for the user
|
||||
- PTY scraping of supported ACP harness CLIs when `acpx` is available
|
||||
|
||||
## AgentId mapping
|
||||
|
||||
Use these defaults when user names a harness directly:
|
||||
|
||||
- "pi" -> `agentId: "pi"`
|
||||
- "openclaw" -> `agentId: "openclaw"`
|
||||
- "claude" or "claude code" -> `agentId: "claude"`
|
||||
- "codex" -> `agentId: "codex"`
|
||||
- "copilot" or "github copilot" -> `agentId: "copilot"`
|
||||
- "cursor" or "cursor cli" -> `agentId: "cursor"`
|
||||
- "droid" or "factory droid" -> `agentId: "droid"`
|
||||
- "opencode" -> `agentId: "opencode"`
|
||||
- "gemini" or "gemini cli" -> `agentId: "gemini"`
|
||||
- "iflow" -> `agentId: "iflow"`
|
||||
- "kilocode" -> `agentId: "kilocode"`
|
||||
- "kimi" or "kimi cli" -> `agentId: "kimi"`
|
||||
- "kiro" or "kiro cli" -> `agentId: "kiro"`
|
||||
- "qwen" or "qwen code" -> `agentId: "qwen"`
|
||||
|
||||
These defaults match current acpx built-in aliases.
|
||||
|
||||
If policy rejects the chosen id, report the policy error clearly and ask for the allowed ACP agent id.
|
||||
|
||||
## OpenClaw ACP runtime path
|
||||
|
||||
Required behavior:
|
||||
|
||||
1. For ACP harness thread spawn requests, read this skill first in the same turn before calling tools.
|
||||
2. Use `sessions_spawn` with:
|
||||
- `runtime: "acp"`
|
||||
- `thread: true`
|
||||
- `mode: "session"` (unless user explicitly wants one-shot)
|
||||
3. For ACP harness thread creation, do not use `message` with `action=thread-create`; `sessions_spawn` is the only thread-create path.
|
||||
4. Put requested work in `task` so the ACP session gets it immediately.
|
||||
5. Set `agentId` explicitly unless ACP default agent is known.
|
||||
6. Do not ask user to run slash commands or CLI when this path works directly.
|
||||
|
||||
Example:
|
||||
|
||||
User: "spawn a test codex session in thread and tell it to say hi"
|
||||
|
||||
Call:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": "Say hi.",
|
||||
"runtime": "acp",
|
||||
"agentId": "codex",
|
||||
"thread": true,
|
||||
"mode": "session"
|
||||
}
|
||||
```
|
||||
|
||||
## Thread spawn recovery policy
|
||||
|
||||
When the user asks to start a coding harness in a thread, treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
|
||||
Required behavior when ACP backend is unavailable:
|
||||
|
||||
1. Do not immediately ask the user to pick an alternate path.
|
||||
2. First attempt automatic local repair:
|
||||
- ensure plugin-local pinned acpx is installed in the bundled ACPX plugin package
|
||||
- verify `${ACPX_CMD} --version`
|
||||
3. After reinstall/repair, restart the gateway and explicitly offer to run that restart for the user.
|
||||
4. Retry ACP thread spawn once after repair.
|
||||
5. Only if repair+retry fails, report the concrete error and then offer fallback options.
|
||||
|
||||
When offering fallback, keep ACP first:
|
||||
|
||||
- Option 1: retry ACP spawn after showing exact failing step
|
||||
- Option 2: direct acpx telephone-game flow
|
||||
|
||||
Do not default to subagent runtime for these requests.
|
||||
|
||||
## ACPX install and version policy (direct acpx path)
|
||||
|
||||
For this repo, direct `acpx` calls must follow the same pinned policy as the `@openclaw/acpx` extension package.
|
||||
|
||||
1. Prefer plugin-local binary, not global PATH:
|
||||
- `${ACPX_PLUGIN_ROOT}/node_modules/.bin/acpx`
|
||||
2. Resolve pinned version from extension dependency:
|
||||
- `node -e "console.log(require(process.env.ACPX_PLUGIN_ROOT + '/package.json').dependencies.acpx)"`
|
||||
3. If binary is missing or version mismatched, install plugin-local pinned version:
|
||||
- `cd "$ACPX_PLUGIN_ROOT" && npm install --omit=dev --no-save acpx@<pinnedVersion>`
|
||||
4. Verify before use:
|
||||
- `${ACPX_PLUGIN_ROOT}/node_modules/.bin/acpx --version`
|
||||
5. If install/repair changed ACPX artifacts, restart the gateway and offer to run the restart.
|
||||
6. Do not run `npm install -g acpx` unless the user explicitly asks for global install.
|
||||
|
||||
Set and reuse:
|
||||
|
||||
```bash
|
||||
ACPX_PLUGIN_ROOT="<bundled-acpx-plugin-root>"
|
||||
ACPX_CMD="$ACPX_PLUGIN_ROOT/node_modules/.bin/acpx"
|
||||
```
|
||||
|
||||
## Direct acpx path ("telephone game")
|
||||
|
||||
Use this path to drive harness sessions without `/acp` or subagent runtime.
|
||||
|
||||
### Rules
|
||||
|
||||
1. Use `exec` commands that call `${ACPX_CMD}`.
|
||||
2. Reuse a stable session name per conversation so follow-up prompts stay in the same harness context.
|
||||
3. Prefer `--format quiet` for clean assistant text to relay back to user.
|
||||
4. Use `exec` (one-shot) only when the user wants one-shot behavior.
|
||||
5. Keep working directory explicit (`--cwd`) when task scope depends on repo context.
|
||||
|
||||
### Session naming
|
||||
|
||||
Use a deterministic name, for example:
|
||||
|
||||
- `oc-<harness>-<conversationId>`
|
||||
|
||||
Where `conversationId` is thread id when available, otherwise channel/conversation id.
|
||||
|
||||
### Command templates
|
||||
|
||||
Persistent session (create if missing, then prompt):
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions show oc-codex-<conversationId> \
|
||||
|| ${ACPX_CMD} codex sessions new --name oc-codex-<conversationId>
|
||||
|
||||
${ACPX_CMD} codex -s oc-codex-<conversationId> --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
One-shot:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex exec --cwd <workspacePath> --format quiet "<prompt>"
|
||||
```
|
||||
|
||||
Cancel in-flight turn:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex cancel -s oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
Close session:
|
||||
|
||||
```bash
|
||||
${ACPX_CMD} codex sessions close oc-codex-<conversationId>
|
||||
```
|
||||
|
||||
### Harness aliases in acpx
|
||||
|
||||
- `claude`
|
||||
- `codex`
|
||||
- `copilot`
|
||||
- `cursor`
|
||||
- `droid`
|
||||
- `gemini`
|
||||
- `iflow`
|
||||
- `kilocode`
|
||||
- `kimi`
|
||||
- `kiro`
|
||||
- `openclaw`
|
||||
- `opencode`
|
||||
- `pi`
|
||||
- `qwen`
|
||||
|
||||
### Built-in adapter commands in acpx
|
||||
|
||||
Defaults are:
|
||||
|
||||
- `openclaw -> openclaw acp`
|
||||
- `claude -> npx -y @zed-industries/claude-agent-acp@0.21.0`
|
||||
- `codex -> npx @zed-industries/codex-acp@^0.9.5`
|
||||
- `copilot -> copilot --acp --stdio`
|
||||
- `cursor -> cursor-agent acp`
|
||||
- `droid -> droid exec --output-format acp`
|
||||
- `gemini -> gemini --acp`
|
||||
- `iflow -> iflow --experimental-acp`
|
||||
- `kilocode -> npx -y @kilocode/cli acp`
|
||||
- `kimi -> kimi acp`
|
||||
- `kiro -> kiro-cli acp`
|
||||
- `opencode -> npx -y opencode-ai acp`
|
||||
- `pi -> npx pi-acp@^0.0.22`
|
||||
- `qwen -> qwen --acp`
|
||||
|
||||
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
|
||||
If your local Cursor install still exposes ACP as `agent acp`, set that as the `cursor` agent override explicitly.
|
||||
|
||||
### Failure handling
|
||||
|
||||
- `acpx: command not found`:
|
||||
- for thread-spawn ACP requests, install plugin-local pinned acpx in the bundled ACPX plugin package immediately
|
||||
- restart gateway after install and offer to run the restart automatically
|
||||
- then retry once
|
||||
- do not ask for install permission first unless policy explicitly requires it
|
||||
- do not install global `acpx` unless explicitly requested
|
||||
- adapter command missing (for example `claude-agent-acp` not found):
|
||||
- for thread-spawn ACP requests, first restore built-in defaults by removing broken `~/.acpx/config.json` agent overrides
|
||||
- then retry once before offering fallback
|
||||
- if user wants binary-based overrides, install exactly the configured adapter binary
|
||||
- `NO_SESSION`: run `${ACPX_CMD} <agent> sessions new --name <sessionName>` then retry prompt.
|
||||
- queue busy: either wait for completion (default) or use `--no-wait` when async behavior is explicitly desired.
|
||||
|
||||
### Output relay
|
||||
|
||||
When relaying to user, return the final assistant text output from `acpx` command result. Avoid relaying raw local tool noise unless user asked for verbose logs.
|
||||
60
openclaw/extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
60
openclaw/extensions/acpx/src/acpx-runtime-compat.d.ts
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
declare module "acpx/runtime" {
|
||||
export const ACPX_BACKEND_ID: string;
|
||||
|
||||
export type AcpRuntimeDoctorReport = import("../runtime-api.js").AcpRuntimeDoctorReport;
|
||||
export type AcpRuntimeEnsureInput = import("../runtime-api.js").AcpRuntimeEnsureInput;
|
||||
export type AcpRuntimeEvent = import("../runtime-api.js").AcpRuntimeEvent;
|
||||
export type AcpRuntimeHandle = import("../runtime-api.js").AcpRuntimeHandle;
|
||||
export type AcpRuntimeCapabilities = import("../runtime-api.js").AcpRuntimeCapabilities;
|
||||
export type AcpRuntimeStatus = import("../runtime-api.js").AcpRuntimeStatus;
|
||||
export type AcpRuntimeTurnInput = import("../runtime-api.js").AcpRuntimeTurnInput;
|
||||
|
||||
export type AcpAgentRegistry = {
|
||||
resolve(agent: string): string | undefined;
|
||||
list(): string[];
|
||||
};
|
||||
|
||||
export type AcpSessionRecord = Record<string, unknown>;
|
||||
|
||||
export type AcpSessionStore = {
|
||||
load(sessionId: string): Promise<AcpSessionRecord | undefined>;
|
||||
save(record: AcpSessionRecord): Promise<void>;
|
||||
};
|
||||
|
||||
export type AcpRuntimeOptions = {
|
||||
cwd: string;
|
||||
sessionStore: AcpSessionStore;
|
||||
agentRegistry: AcpAgentRegistry;
|
||||
mcpServers?: unknown;
|
||||
permissionMode?: unknown;
|
||||
nonInteractivePermissions?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export class AcpxRuntime {
|
||||
constructor(options: AcpRuntimeOptions, testOptions?: unknown);
|
||||
isHealthy(): boolean;
|
||||
probeAvailability(): Promise<void>;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
getCapabilities(input?: {
|
||||
handle?: AcpRuntimeHandle;
|
||||
}): AcpRuntimeCapabilities | Promise<AcpRuntimeCapabilities>;
|
||||
getStatus(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
|
||||
setMode(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
|
||||
setConfigOption(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: {
|
||||
handle: AcpRuntimeHandle;
|
||||
reason?: string;
|
||||
discardPersistentState?: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAcpRuntime(...args: unknown[]): AcpxRuntime;
|
||||
export function createAgentRegistry(params: { overrides?: unknown }): AcpAgentRegistry;
|
||||
export function createFileSessionStore(params: { stateDir: string }): AcpSessionStore;
|
||||
export function decodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
export function encodeAcpxRuntimeHandleState(...args: unknown[]): unknown;
|
||||
}
|
||||
115
openclaw/extensions/acpx/src/config-schema.ts
Normal file
115
openclaw/extensions/acpx/src/config-schema.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { buildPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import type { OpenClawPluginConfigSchema } from "../runtime-api.js";
|
||||
|
||||
export const ACPX_PERMISSION_MODES = ["approve-all", "approve-reads", "deny-all"] as const;
|
||||
export type AcpxPermissionMode = (typeof ACPX_PERMISSION_MODES)[number];
|
||||
|
||||
export const ACPX_NON_INTERACTIVE_POLICIES = ["deny", "fail"] as const;
|
||||
export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_POLICIES)[number];
|
||||
|
||||
export const DEFAULT_ACPX_TIMEOUT_SECONDS = 120;
|
||||
|
||||
export type McpServerConfig = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AcpxMcpServer = {
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Array<{ name: string; value: string }>;
|
||||
};
|
||||
|
||||
export type AcpxPluginConfig = {
|
||||
cwd?: string;
|
||||
stateDir?: string;
|
||||
permissionMode?: AcpxPermissionMode;
|
||||
nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge?: boolean;
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
agents?: Record<string, { command: string }>;
|
||||
};
|
||||
|
||||
export type ResolvedAcpxPluginConfig = {
|
||||
cwd: string;
|
||||
stateDir: string;
|
||||
permissionMode: AcpxPermissionMode;
|
||||
nonInteractivePermissions: AcpxNonInteractivePermissionPolicy;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
strictWindowsCmdWrapper: boolean;
|
||||
timeoutSeconds?: number;
|
||||
queueOwnerTtlSeconds: number;
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper?: boolean;
|
||||
queueOwnerTtlSeconds?: number;
|
||||
};
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
agents: Record<string, string>;
|
||||
};
|
||||
|
||||
const nonEmptyTrimmedString = (message: string) =>
|
||||
z.string({ error: message }).trim().min(1, { error: message });
|
||||
|
||||
const McpServerConfigSchema = z.object({
|
||||
command: nonEmptyTrimmedString("command must be a non-empty string").describe(
|
||||
"Command to run the MCP server",
|
||||
),
|
||||
args: z
|
||||
.array(z.string({ error: "args must be an array of strings" }), {
|
||||
error: "args must be an array of strings",
|
||||
})
|
||||
.optional()
|
||||
.describe("Arguments to pass to the command"),
|
||||
env: z
|
||||
.record(z.string(), z.string({ error: "env values must be strings" }), {
|
||||
error: "env must be an object of strings",
|
||||
})
|
||||
.optional()
|
||||
.describe("Environment variables for the MCP server"),
|
||||
});
|
||||
|
||||
export const AcpxPluginConfigSchema = z.strictObject({
|
||||
cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(),
|
||||
stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(),
|
||||
permissionMode: z
|
||||
.enum(ACPX_PERMISSION_MODES, {
|
||||
error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
nonInteractivePermissions: z
|
||||
.enum(ACPX_NON_INTERACTIVE_POLICIES, {
|
||||
error: `nonInteractivePermissions must be one of: ${ACPX_NON_INTERACTIVE_POLICIES.join(", ")}`,
|
||||
})
|
||||
.optional(),
|
||||
pluginToolsMcpBridge: z.boolean({ error: "pluginToolsMcpBridge must be a boolean" }).optional(),
|
||||
strictWindowsCmdWrapper: z
|
||||
.boolean({ error: "strictWindowsCmdWrapper must be a boolean" })
|
||||
.optional(),
|
||||
timeoutSeconds: z
|
||||
.number({ error: "timeoutSeconds must be a number >= 0.001" })
|
||||
.min(0.001, { error: "timeoutSeconds must be a number >= 0.001" })
|
||||
.default(DEFAULT_ACPX_TIMEOUT_SECONDS),
|
||||
queueOwnerTtlSeconds: z
|
||||
.number({ error: "queueOwnerTtlSeconds must be a number >= 0" })
|
||||
.min(0, { error: "queueOwnerTtlSeconds must be a number >= 0" })
|
||||
.optional(),
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||
agents: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.strictObject({
|
||||
command: nonEmptyTrimmedString("agents.<id>.command must be a non-empty string"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export function createAcpxPluginConfigSchema(): OpenClawPluginConfigSchema {
|
||||
return buildPluginConfigSchema(AcpxPluginConfigSchema);
|
||||
}
|
||||
85
openclaw/extensions/acpx/src/config.test.ts
Normal file
85
openclaw/extensions/acpx/src/config.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAcpxPluginConfig, resolveAcpxPluginRoot } from "./config.js";
|
||||
|
||||
describe("embedded acpx plugin config", () => {
|
||||
it("resolves workspace stateDir and cwd by default", () => {
|
||||
const workspaceDir = "/tmp/openclaw-acpx";
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: undefined,
|
||||
workspaceDir,
|
||||
});
|
||||
|
||||
expect(resolved.cwd).toBe(workspaceDir);
|
||||
expect(resolved.stateDir).toBe(path.join(workspaceDir, "state"));
|
||||
expect(resolved.permissionMode).toBe("approve-reads");
|
||||
expect(resolved.nonInteractivePermissions).toBe("fail");
|
||||
expect(resolved.timeoutSeconds).toBe(120);
|
||||
expect(resolved.agents).toEqual({});
|
||||
});
|
||||
|
||||
it("keeps explicit timeoutSeconds config", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
timeoutSeconds: 300,
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.timeoutSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it("accepts agent command overrides", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
agents: {
|
||||
claude: { command: "claude --acp" },
|
||||
codex: { command: "codex custom-acp" },
|
||||
},
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
expect(resolved.agents).toEqual({
|
||||
claude: "claude --acp",
|
||||
codex: "codex custom-acp",
|
||||
});
|
||||
});
|
||||
|
||||
it("injects the built-in plugin-tools MCP server only when explicitly enabled", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
pluginToolsMcpBridge: true,
|
||||
},
|
||||
workspaceDir: "/tmp/openclaw-acpx",
|
||||
});
|
||||
|
||||
const server = resolved.mcpServers["openclaw-plugin-tools"];
|
||||
expect(server).toBeDefined();
|
||||
expect(server.command).toBe(process.execPath);
|
||||
expect(Array.isArray(server.args)).toBe(true);
|
||||
expect(server.args?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps the runtime json schema in sync with the manifest config schema", () => {
|
||||
const pluginRoot = resolveAcpxPluginRoot();
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "utf8"),
|
||||
) as { configSchema?: unknown };
|
||||
|
||||
expect(manifest.configSchema).toMatchObject({
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: expect.objectContaining({
|
||||
cwd: expect.any(Object),
|
||||
stateDir: expect.any(Object),
|
||||
timeoutSeconds: expect.objectContaining({
|
||||
default: 120,
|
||||
}),
|
||||
agents: expect.any(Object),
|
||||
mcpServers: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
250
openclaw/extensions/acpx/src/config.ts
Normal file
250
openclaw/extensions/acpx/src/config.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { z } from "openclaw/plugin-sdk/zod";
|
||||
import { AcpxPluginConfigSchema, DEFAULT_ACPX_TIMEOUT_SECONDS } from "./config-schema.js";
|
||||
import type {
|
||||
AcpxPluginConfig,
|
||||
AcpxPermissionMode,
|
||||
AcpxNonInteractivePermissionPolicy,
|
||||
McpServerConfig,
|
||||
AcpxMcpServer,
|
||||
ResolvedAcpxPluginConfig,
|
||||
} from "./config-schema.js";
|
||||
export {
|
||||
ACPX_NON_INTERACTIVE_POLICIES,
|
||||
ACPX_PERMISSION_MODES,
|
||||
type AcpxMcpServer,
|
||||
type AcpxNonInteractivePermissionPolicy,
|
||||
type AcpxPermissionMode,
|
||||
type AcpxPluginConfig,
|
||||
type McpServerConfig,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
createAcpxPluginConfigSchema,
|
||||
} from "./config-schema.js";
|
||||
|
||||
export const ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME = "openclaw-plugin-tools";
|
||||
|
||||
function isAcpxPluginRoot(dir: string): boolean {
|
||||
return (
|
||||
fs.existsSync(path.join(dir, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(dir, "package.json"))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNearestAcpxPluginRoot(moduleUrl: string): string {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (isAcpxPluginRoot(cursor)) {
|
||||
return cursor;
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
function resolveWorkspaceAcpxPluginRoot(currentRoot: string): string | null {
|
||||
if (
|
||||
path.basename(currentRoot) !== "acpx" ||
|
||||
path.basename(path.dirname(currentRoot)) !== "extensions" ||
|
||||
path.basename(path.dirname(path.dirname(currentRoot))) !== "dist"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const workspaceRoot = path.resolve(currentRoot, "..", "..", "..", "extensions", "acpx");
|
||||
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||
}
|
||||
|
||||
function resolveRepoAcpxPluginRoot(currentRoot: string): string | null {
|
||||
const workspaceRoot = path.join(currentRoot, "extensions", "acpx");
|
||||
return isAcpxPluginRoot(workspaceRoot) ? workspaceRoot : null;
|
||||
}
|
||||
|
||||
function resolveAcpxPluginRootFromOpenClawLayout(moduleUrl: string): string | null {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const candidates = [
|
||||
path.join(cursor, "extensions", "acpx"),
|
||||
path.join(cursor, "dist", "extensions", "acpx"),
|
||||
path.join(cursor, "dist-runtime", "extensions", "acpx"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (isAcpxPluginRoot(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
const resolvedRoot = resolveNearestAcpxPluginRoot(moduleUrl);
|
||||
// In a live repo checkout, dist/ can be rebuilt out from under the running gateway.
|
||||
// Prefer the stable source plugin root when a built extension is running beside it.
|
||||
return (
|
||||
resolveWorkspaceAcpxPluginRoot(resolvedRoot) ??
|
||||
resolveRepoAcpxPluginRoot(resolvedRoot) ??
|
||||
// Shared dist/dist-runtime chunks can load this module outside the plugin tree.
|
||||
// Scan common OpenClaw layouts before falling back to the nearest path guess.
|
||||
resolveAcpxPluginRootFromOpenClawLayout(moduleUrl) ??
|
||||
resolvedRoot
|
||||
);
|
||||
}
|
||||
|
||||
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
||||
|
||||
const DEFAULT_PERMISSION_MODE: AcpxPermissionMode = "approve-reads";
|
||||
const DEFAULT_NON_INTERACTIVE_POLICY: AcpxNonInteractivePermissionPolicy = "fail";
|
||||
const DEFAULT_QUEUE_OWNER_TTL_SECONDS = 0.1;
|
||||
const DEFAULT_STRICT_WINDOWS_CMD_WRAPPER = true;
|
||||
|
||||
type ParseResult =
|
||||
| { ok: true; value: AcpxPluginConfig | undefined }
|
||||
| { ok: false; message: string };
|
||||
|
||||
function formatAcpxConfigIssue(issue: z.ZodIssue | undefined): string {
|
||||
if (!issue) {
|
||||
return "invalid config";
|
||||
}
|
||||
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
|
||||
return `unknown config key: ${issue.keys[0]}`;
|
||||
}
|
||||
if (issue.code === "invalid_type" && issue.path.length === 0) {
|
||||
return "expected config object";
|
||||
}
|
||||
return issue.message;
|
||||
}
|
||||
|
||||
function parseAcpxPluginConfig(value: unknown): ParseResult {
|
||||
if (value === undefined) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
const parsed = AcpxPluginConfigSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, message: formatAcpxConfigIssue(parsed.error.issues[0]) };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: parsed.data as AcpxPluginConfig,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpenClawRoot(currentRoot: string): string {
|
||||
if (
|
||||
path.basename(currentRoot) === "acpx" &&
|
||||
path.basename(path.dirname(currentRoot)) === "extensions"
|
||||
) {
|
||||
const parent = path.dirname(path.dirname(currentRoot));
|
||||
if (path.basename(parent) === "dist") {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
return path.resolve(currentRoot, "..");
|
||||
}
|
||||
|
||||
export function resolvePluginToolsMcpServerConfig(
|
||||
moduleUrl: string = import.meta.url,
|
||||
): McpServerConfig {
|
||||
const pluginRoot = resolveAcpxPluginRoot(moduleUrl);
|
||||
const openClawRoot = resolveOpenClawRoot(pluginRoot);
|
||||
const distEntry = path.join(openClawRoot, "dist", "mcp", "plugin-tools-serve.js");
|
||||
if (fs.existsSync(distEntry)) {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [distEntry],
|
||||
};
|
||||
}
|
||||
const sourceEntry = path.join(openClawRoot, "src", "mcp", "plugin-tools-serve.ts");
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: ["--import", "tsx", sourceEntry],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredMcpServers(params: {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
pluginToolsMcpBridge: boolean;
|
||||
moduleUrl?: string;
|
||||
}): Record<string, McpServerConfig> {
|
||||
const resolved = { ...params.mcpServers };
|
||||
if (!params.pluginToolsMcpBridge) {
|
||||
return resolved;
|
||||
}
|
||||
if (resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME]) {
|
||||
throw new Error(
|
||||
`mcpServers.${ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME} is reserved when pluginToolsMcpBridge=true`,
|
||||
);
|
||||
}
|
||||
resolved[ACPX_PLUGIN_TOOLS_MCP_SERVER_NAME] = resolvePluginToolsMcpServerConfig(params.moduleUrl);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function toAcpMcpServers(mcpServers: Record<string, McpServerConfig>): AcpxMcpServer[] {
|
||||
return Object.entries(mcpServers).map(([name, server]) => ({
|
||||
name,
|
||||
command: server.command,
|
||||
args: [...(server.args ?? [])],
|
||||
env: Object.entries(server.env ?? {}).map(([envName, value]) => ({
|
||||
name: envName,
|
||||
value,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveAcpxPluginConfig(params: {
|
||||
rawConfig: unknown;
|
||||
workspaceDir?: string;
|
||||
moduleUrl?: string;
|
||||
}): ResolvedAcpxPluginConfig {
|
||||
const parsed = parseAcpxPluginConfig(params.rawConfig);
|
||||
if (!parsed.ok) {
|
||||
throw new Error(parsed.message);
|
||||
}
|
||||
const normalized = parsed.value ?? {};
|
||||
const workspaceDir = params.workspaceDir?.trim() || process.cwd();
|
||||
const fallbackCwd = workspaceDir;
|
||||
const cwd = path.resolve(normalized.cwd?.trim() || fallbackCwd);
|
||||
const stateDir = path.resolve(normalized.stateDir?.trim() || path.join(workspaceDir, "state"));
|
||||
const pluginToolsMcpBridge = normalized.pluginToolsMcpBridge === true;
|
||||
const mcpServers = resolveConfiguredMcpServers({
|
||||
mcpServers: normalized.mcpServers,
|
||||
pluginToolsMcpBridge,
|
||||
moduleUrl: params.moduleUrl,
|
||||
});
|
||||
const agents = Object.fromEntries(
|
||||
Object.entries(normalized.agents ?? {}).map(([name, entry]) => [
|
||||
normalizeLowercaseStringOrEmpty(name),
|
||||
entry.command.trim(),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
cwd,
|
||||
stateDir,
|
||||
permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE,
|
||||
nonInteractivePermissions:
|
||||
normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY,
|
||||
pluginToolsMcpBridge,
|
||||
strictWindowsCmdWrapper:
|
||||
normalized.strictWindowsCmdWrapper ?? DEFAULT_STRICT_WINDOWS_CMD_WRAPPER,
|
||||
timeoutSeconds: normalized.timeoutSeconds ?? DEFAULT_ACPX_TIMEOUT_SECONDS,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds ?? DEFAULT_QUEUE_OWNER_TTL_SECONDS,
|
||||
legacyCompatibilityConfig: {
|
||||
strictWindowsCmdWrapper: normalized.strictWindowsCmdWrapper,
|
||||
queueOwnerTtlSeconds: normalized.queueOwnerTtlSeconds,
|
||||
},
|
||||
mcpServers,
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export function formatErrorMessage(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message || error.name || "Error";
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
const WINDOWS_DIRECT_EXECUTABLE_PATH_RE =
|
||||
/^(?<command>(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:exe|com))(?=\s|$)(?:\s+(?<rest>.*))?$/i;
|
||||
|
||||
// Windows wrapper scripts need their host shell or interpreter (`cmd.exe`,
|
||||
// `powershell.exe`, or `node`) instead of direct spawning.
|
||||
const WINDOWS_WRAPPER_PATH_RE =
|
||||
/^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/]).*?\.(?:bat|cmd|cjs|js|mjs|ps1)$/i;
|
||||
|
||||
function splitCommandParts(value, platform = process.platform) {
|
||||
const parts = [];
|
||||
let current = "";
|
||||
let quote = null;
|
||||
let escaping = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const ch = value[index];
|
||||
const next = value[index + 1];
|
||||
if (escaping) {
|
||||
current += ch;
|
||||
escaping = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
if (quote === "'") {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
if (quote === '"') {
|
||||
if (next === '"' || next === "\\") {
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
if (!quote) {
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
escaping = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
quote = ch;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
|
||||
if (escaping) {
|
||||
current += "\\";
|
||||
}
|
||||
if (quote) {
|
||||
throw new Error("Invalid agent command: unterminated quote");
|
||||
}
|
||||
if (current.length > 0) {
|
||||
parts.push(current);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
function splitWindowsExecutableCommand(value, platform = process.platform) {
|
||||
if (platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed.startsWith('"') || trimmed.startsWith("'")) {
|
||||
return null;
|
||||
}
|
||||
const match = trimmed.match(WINDOWS_DIRECT_EXECUTABLE_PATH_RE);
|
||||
if (!match?.groups?.command) {
|
||||
return null;
|
||||
}
|
||||
const rest = match.groups.rest?.trim() ?? "";
|
||||
return {
|
||||
command: match.groups.command,
|
||||
args: rest ? splitCommandParts(rest, platform) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function assertSupportedWindowsCommand(command, platform = process.platform) {
|
||||
if (platform !== "win32" || !WINDOWS_WRAPPER_PATH_RE.test(command)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported Windows agent command wrapper: ${command}. ` +
|
||||
"Invoke wrapper scripts through their shell or interpreter instead " +
|
||||
"(for example `cmd.exe /c`, `powershell.exe -File`, or `node <script>`).",
|
||||
);
|
||||
}
|
||||
|
||||
export function splitCommandLine(value, platform = process.platform) {
|
||||
const windowsCommand = splitWindowsExecutableCommand(value, platform);
|
||||
const parts = windowsCommand ?? splitCommandParts(value, platform);
|
||||
if (parts.length === 0) {
|
||||
throw new Error("Invalid agent command: empty command");
|
||||
}
|
||||
const parsed = Array.isArray(parts)
|
||||
? {
|
||||
command: parts[0],
|
||||
args: parts.slice(1),
|
||||
}
|
||||
: parts;
|
||||
assertSupportedWindowsCommand(parsed.command, platform);
|
||||
return parsed;
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type SplitCommandLine = (
|
||||
value: string,
|
||||
platform?: string,
|
||||
) => {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
async function loadSplitCommandLine(): Promise<SplitCommandLine> {
|
||||
const moduleUrl = new URL("./mcp-command-line.mjs", import.meta.url);
|
||||
return (await import(moduleUrl.href)).splitCommandLine as SplitCommandLine;
|
||||
}
|
||||
|
||||
describe("mcp-command-line", () => {
|
||||
it("parses quoted Windows executable paths without dropping backslashes", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine(
|
||||
'"C:\\Program Files\\Claude\\claude.exe" --stdio --flag "two words"',
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Program Files\\Claude\\claude.exe",
|
||||
args: ["--stdio", "--flag", "two words"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses unquoted Windows executable paths without mangling backslashes", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine("C:\\Users\\alerl\\.local\\bin\\claude.exe --version", "win32");
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Users\\alerl\\.local\\bin\\claude.exe",
|
||||
args: ["--version"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves unquoted Windows path arguments after the executable", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
const parsed = splitCommandLine(
|
||||
'"C:\\Program Files\\Claude\\claude.exe" --config C:\\Users\\me\\cfg.json',
|
||||
"win32",
|
||||
);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
command: "C:\\Program Files\\Claude\\claude.exe",
|
||||
args: ["--config", "C:\\Users\\me\\cfg.json"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects direct Windows wrapper-script commands with a helpful error", async () => {
|
||||
const splitCommandLine = await loadSplitCommandLine();
|
||||
expect(() =>
|
||||
splitCommandLine('"C:\\Users\\me\\bin\\claude-wrapper.cmd" --stdio', "win32"),
|
||||
).toThrow(/Invoke wrapper scripts through their shell or interpreter instead/);
|
||||
});
|
||||
});
|
||||
113
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
113
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.mjs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatErrorMessage } from "./error-format.mjs";
|
||||
import { splitCommandLine } from "./mcp-command-line.mjs";
|
||||
|
||||
function decodePayload(argv) {
|
||||
const payloadIndex = argv.indexOf("--payload");
|
||||
if (payloadIndex < 0) {
|
||||
throw new Error("Missing --payload");
|
||||
}
|
||||
const encoded = argv[payloadIndex + 1];
|
||||
if (!encoded) {
|
||||
throw new Error("Missing MCP proxy payload value");
|
||||
}
|
||||
const parsed = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid MCP proxy payload");
|
||||
}
|
||||
if (typeof parsed.targetCommand !== "string" || parsed.targetCommand.trim() === "") {
|
||||
throw new Error("MCP proxy payload missing targetCommand");
|
||||
}
|
||||
const mcpServers = Array.isArray(parsed.mcpServers) ? parsed.mcpServers : [];
|
||||
return {
|
||||
targetCommand: parsed.targetCommand,
|
||||
mcpServers,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldInject(method) {
|
||||
return method === "session/new" || method === "session/load" || method === "session/fork";
|
||||
}
|
||||
|
||||
function rewriteLine(line, mcpServers) {
|
||||
if (!line.trim()) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed) ||
|
||||
!shouldInject(parsed.method) ||
|
||||
!parsed.params ||
|
||||
typeof parsed.params !== "object" ||
|
||||
Array.isArray(parsed.params)
|
||||
) {
|
||||
return line;
|
||||
}
|
||||
const next = {
|
||||
...parsed,
|
||||
params: {
|
||||
...parsed.params,
|
||||
mcpServers,
|
||||
},
|
||||
};
|
||||
return JSON.stringify(next);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
|
||||
function isMainModule() {
|
||||
const mainPath = process.argv[1];
|
||||
if (!mainPath) {
|
||||
return false;
|
||||
}
|
||||
return import.meta.url === pathToFileURL(path.resolve(mainPath)).href;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { targetCommand, mcpServers } = decodePayload(process.argv.slice(2));
|
||||
const target = splitCommandLine(targetCommand);
|
||||
const child = spawn(target.command, target.args, {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
if (!child.stdin || !child.stdout) {
|
||||
throw new Error("Failed to create MCP proxy stdio pipes");
|
||||
}
|
||||
|
||||
const input = createInterface({ input: process.stdin });
|
||||
input.on("line", (line) => {
|
||||
child.stdin.write(`${rewriteLine(line, mcpServers)}\n`);
|
||||
});
|
||||
input.on("close", () => {
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
child.stdout.pipe(process.stdout);
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`${formatErrorMessage(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
if (isMainModule()) {
|
||||
main();
|
||||
}
|
||||
114
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
114
openclaw/extensions/acpx/src/runtime-internals/mcp-proxy.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { bundledPluginFile } from "../../../../test/helpers/bundled-plugin-paths.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const proxyPath = path.resolve(bundledPluginFile("acpx", "src/runtime-internals/mcp-proxy.mjs"));
|
||||
|
||||
async function makeTempScript(name: string, content: string): Promise<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-mcp-proxy-"));
|
||||
tempDirs.push(dir);
|
||||
const scriptPath = path.join(dir, name);
|
||||
await writeFile(scriptPath, content, "utf8");
|
||||
await chmod(scriptPath, 0o755);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (!dir) {
|
||||
continue;
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("mcp-proxy", () => {
|
||||
it("injects configured MCP servers into ACP session bootstrap requests", async () => {
|
||||
const echoServerPath = await makeTempScript(
|
||||
"echo-server.cjs",
|
||||
String.raw`#!/usr/bin/env node
|
||||
const { createInterface } = require("node:readline");
|
||||
const rl = createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => process.stdout.write(line + "\n"));
|
||||
`,
|
||||
);
|
||||
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
targetCommand: `${process.execPath} ${echoServerPath}`,
|
||||
mcpServers: [
|
||||
{
|
||||
name: "canva",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: [{ name: "CANVA_TOKEN", value: "secret" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
|
||||
const child = spawn(process.execPath, [proxyPath, "--payload", payload], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "session/new",
|
||||
params: { cwd: process.cwd(), mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "session/load",
|
||||
params: { cwd: process.cwd(), sessionId: "sid-1", mcpServers: [] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.write(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "session/prompt",
|
||||
params: { sessionId: "sid-1", prompt: [{ type: "text", text: "hello" }] },
|
||||
})}\n`,
|
||||
);
|
||||
child.stdin.end();
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolve) => {
|
||||
child.once("close", (code) => resolve(code));
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => JSON.parse(line) as { method: string; params: Record<string, unknown> });
|
||||
|
||||
expect(lines[0].params.mcpServers).toEqual([
|
||||
{
|
||||
name: "canva",
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote@latest", "https://mcp.canva.com/mcp"],
|
||||
env: [{ name: "CANVA_TOKEN", value: "secret" }],
|
||||
},
|
||||
]);
|
||||
expect(lines[1].params.mcpServers).toEqual(lines[0].params.mcpServers);
|
||||
expect(lines[2].method).toBe("session/prompt");
|
||||
expect(lines[2].params.mcpServers).toBeUndefined();
|
||||
});
|
||||
});
|
||||
105
openclaw/extensions/acpx/src/runtime.test.ts
Normal file
105
openclaw/extensions/acpx/src/runtime.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
import { AcpxRuntime } from "./runtime.js";
|
||||
|
||||
type TestSessionStore = {
|
||||
load(sessionId: string): Promise<Record<string, unknown> | undefined>;
|
||||
save(record: Record<string, unknown>): Promise<void>;
|
||||
};
|
||||
|
||||
function makeRuntime(baseStore: TestSessionStore): {
|
||||
runtime: AcpxRuntime;
|
||||
wrappedStore: TestSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
delegate: { close: AcpRuntime["close"] };
|
||||
} {
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: "/tmp",
|
||||
sessionStore: baseStore,
|
||||
agentRegistry: {
|
||||
resolve: () => "codex",
|
||||
list: () => ["codex"],
|
||||
},
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
|
||||
return {
|
||||
runtime,
|
||||
wrappedStore: (
|
||||
runtime as unknown as {
|
||||
sessionStore: TestSessionStore & { markFresh: (sessionKey: string) => void };
|
||||
}
|
||||
).sessionStore,
|
||||
delegate: (runtime as unknown as { delegate: { close: AcpRuntime["close"] } }).delegate,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps stale persistent loads hidden until a fresh record is saved", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const { runtime, wrappedStore } = makeRuntime(baseStore);
|
||||
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await runtime.prepareFreshSession({
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
});
|
||||
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(1);
|
||||
|
||||
await wrappedStore.save({
|
||||
acpxRecordId: "fresh-record",
|
||||
name: "agent:codex:acp:binding:test",
|
||||
} as never);
|
||||
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toEqual({
|
||||
acpxRecordId: "stale",
|
||||
});
|
||||
expect(baseStore.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("marks the session fresh after discardPersistentState close", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => ({ acpxRecordId: "stale" }) as never),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const { runtime, wrappedStore, delegate } = makeRuntime(baseStore);
|
||||
const close = vi.spyOn(delegate, "close").mockResolvedValue(undefined);
|
||||
|
||||
await runtime.close({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
},
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
});
|
||||
|
||||
expect(close).toHaveBeenCalledWith({
|
||||
handle: {
|
||||
sessionKey: "agent:codex:acp:binding:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "agent:codex:acp:binding:test",
|
||||
},
|
||||
reason: "new-in-place-reset",
|
||||
discardPersistentState: true,
|
||||
});
|
||||
expect(await wrappedStore.load("agent:codex:acp:binding:test")).toBeUndefined();
|
||||
expect(baseStore.load).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
153
openclaw/extensions/acpx/src/runtime.ts
Normal file
153
openclaw/extensions/acpx/src/runtime.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import {
|
||||
ACPX_BACKEND_ID,
|
||||
AcpxRuntime as BaseAcpxRuntime,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
type AcpAgentRegistry,
|
||||
type AcpRuntimeDoctorReport,
|
||||
type AcpRuntimeEvent,
|
||||
type AcpRuntimeHandle,
|
||||
type AcpRuntimeOptions,
|
||||
type AcpRuntimeStatus,
|
||||
} from "acpx/runtime";
|
||||
import type { AcpRuntime } from "../runtime-api.js";
|
||||
|
||||
type AcpSessionStore = AcpRuntimeOptions["sessionStore"];
|
||||
type AcpSessionRecord = Parameters<AcpSessionStore["save"]>[0];
|
||||
type AcpLoadedSessionRecord = Awaited<ReturnType<AcpSessionStore["load"]>>;
|
||||
|
||||
type ResetAwareSessionStore = AcpSessionStore & {
|
||||
markFresh: (sessionKey: string) => void;
|
||||
};
|
||||
|
||||
function readSessionRecordName(record: AcpSessionRecord): string {
|
||||
if (typeof record !== "object" || record === null) {
|
||||
return "";
|
||||
}
|
||||
const { name } = record as { name?: unknown };
|
||||
return typeof name === "string" ? name.trim() : "";
|
||||
}
|
||||
|
||||
function createResetAwareSessionStore(baseStore: AcpSessionStore): ResetAwareSessionStore {
|
||||
const freshSessionKeys = new Set<string>();
|
||||
|
||||
return {
|
||||
async load(sessionId: string): Promise<AcpLoadedSessionRecord> {
|
||||
const normalized = sessionId.trim();
|
||||
if (normalized && freshSessionKeys.has(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return await baseStore.load(sessionId);
|
||||
},
|
||||
async save(record: AcpSessionRecord): Promise<void> {
|
||||
await baseStore.save(record);
|
||||
const sessionName = readSessionRecordName(record);
|
||||
if (sessionName) {
|
||||
freshSessionKeys.delete(sessionName);
|
||||
}
|
||||
},
|
||||
markFresh(sessionKey: string): void {
|
||||
const normalized = sessionKey.trim();
|
||||
if (normalized) {
|
||||
freshSessionKeys.add(normalized);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
};
|
||||
|
||||
export class AcpxRuntime implements AcpxRuntimeLike {
|
||||
private readonly sessionStore: ResetAwareSessionStore;
|
||||
private readonly delegate: BaseAcpxRuntime;
|
||||
|
||||
constructor(
|
||||
options: AcpRuntimeOptions,
|
||||
testOptions?: ConstructorParameters<typeof BaseAcpxRuntime>[1],
|
||||
) {
|
||||
this.sessionStore = createResetAwareSessionStore(options.sessionStore);
|
||||
this.delegate = new BaseAcpxRuntime(
|
||||
{
|
||||
...options,
|
||||
sessionStore: this.sessionStore,
|
||||
},
|
||||
testOptions,
|
||||
);
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.delegate.isHealthy();
|
||||
}
|
||||
|
||||
probeAvailability(): Promise<void> {
|
||||
return this.delegate.probeAvailability();
|
||||
}
|
||||
|
||||
doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
return this.delegate.doctor();
|
||||
}
|
||||
|
||||
ensureSession(input: Parameters<AcpRuntime["ensureSession"]>[0]): Promise<AcpRuntimeHandle> {
|
||||
return this.delegate.ensureSession(input);
|
||||
}
|
||||
|
||||
runTurn(input: Parameters<AcpRuntime["runTurn"]>[0]): AsyncIterable<AcpRuntimeEvent> {
|
||||
return this.delegate.runTurn(input);
|
||||
}
|
||||
|
||||
getCapabilities(): ReturnType<BaseAcpxRuntime["getCapabilities"]> {
|
||||
return this.delegate.getCapabilities();
|
||||
}
|
||||
|
||||
getStatus(input: Parameters<NonNullable<AcpRuntime["getStatus"]>>[0]): Promise<AcpRuntimeStatus> {
|
||||
return this.delegate.getStatus(input);
|
||||
}
|
||||
|
||||
setMode(input: Parameters<NonNullable<AcpRuntime["setMode"]>>[0]): Promise<void> {
|
||||
return this.delegate.setMode(input);
|
||||
}
|
||||
|
||||
setConfigOption(input: Parameters<NonNullable<AcpRuntime["setConfigOption"]>>[0]): Promise<void> {
|
||||
return this.delegate.setConfigOption(input);
|
||||
}
|
||||
|
||||
cancel(input: Parameters<AcpRuntime["cancel"]>[0]): Promise<void> {
|
||||
return this.delegate.cancel(input);
|
||||
}
|
||||
|
||||
async prepareFreshSession(input: { sessionKey: string }): Promise<void> {
|
||||
this.sessionStore.markFresh(input.sessionKey);
|
||||
}
|
||||
|
||||
close(input: Parameters<AcpRuntime["close"]>[0]): Promise<void> {
|
||||
return this.delegate
|
||||
.close({
|
||||
handle: input.handle,
|
||||
reason: input.reason,
|
||||
discardPersistentState: input.discardPersistentState,
|
||||
})
|
||||
.then(() => {
|
||||
if (input.discardPersistentState) {
|
||||
this.sessionStore.markFresh(input.handle.sessionKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
ACPX_BACKEND_ID,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
};
|
||||
|
||||
export type { AcpAgentRegistry, AcpRuntimeOptions, AcpSessionRecord, AcpSessionStore };
|
||||
221
openclaw/extensions/acpx/src/service.test.ts
Normal file
221
openclaw/extensions/acpx/src/service.test.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { runtimeRegistry } = vi.hoisted(() => ({
|
||||
runtimeRegistry: new Map<string, { runtime: unknown; healthy?: () => boolean }>(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
|
||||
registerAcpRuntimeBackend: (entry: { id: string; runtime: unknown; healthy?: () => boolean }) => {
|
||||
runtimeRegistry.set(entry.id, entry);
|
||||
},
|
||||
unregisterAcpRuntimeBackend: (id: string) => {
|
||||
runtimeRegistry.delete(id);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
ACPX_BACKEND_ID: "acpx",
|
||||
AcpxRuntime: function AcpxRuntime() {},
|
||||
createAgentRegistry: vi.fn(() => ({})),
|
||||
createFileSessionStore: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { getAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import { createAcpxRuntimeService } from "./service.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-acpx-service-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
runtimeRegistry.clear();
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
|
||||
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createServiceContext(workspaceDir: string) {
|
||||
return {
|
||||
workspaceDir,
|
||||
stateDir: path.join(workspaceDir, ".openclaw-plugin-state"),
|
||||
config: {},
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("createAcpxRuntimeService", () => {
|
||||
it("registers and unregisters the embedded backend", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(getAcpRuntimeBackend("acpx")?.runtime).toBe(runtime);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates the embedded runtime state directory before probing", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const stateDir = path.join(workspaceDir, "custom-state");
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {
|
||||
await fs.access(stateDir);
|
||||
});
|
||||
const service = createAcpxRuntimeService({
|
||||
pluginConfig: { stateDir },
|
||||
runtimeFactory: () =>
|
||||
({
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability,
|
||||
isHealthy: () => true,
|
||||
doctor: async () => ({ ok: true, message: "ok" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).toHaveBeenCalledOnce();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("passes the default runtime timeout to the embedded runtime factory", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const runtimeFactory = vi.fn(() => runtime as never);
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pluginConfig: expect.objectContaining({
|
||||
timeoutSeconds: 120,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("warns when legacy compatibility config is explicitly ignored", async () => {
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtime = {
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability: vi.fn(async () => {}),
|
||||
isHealthy: vi.fn(() => true),
|
||||
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
|
||||
};
|
||||
const service = createAcpxRuntimeService({
|
||||
pluginConfig: {
|
||||
queueOwnerTtlSeconds: 30,
|
||||
strictWindowsCmdWrapper: false,
|
||||
},
|
||||
runtimeFactory: () => runtime as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"embedded acpx runtime ignores legacy compatibility config: queueOwnerTtlSeconds, strictWindowsCmdWrapper=false",
|
||||
),
|
||||
);
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime probe via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const probeAvailability = vi.fn(async () => {});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: () =>
|
||||
({
|
||||
ensureSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
close: vi.fn(),
|
||||
probeAvailability,
|
||||
isHealthy: () => false,
|
||||
doctor: async () => ({ ok: false, message: "nope" }),
|
||||
}) as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(probeAvailability).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeTruthy();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
it("can skip the embedded runtime backend via env", async () => {
|
||||
process.env.OPENCLAW_SKIP_ACPX_RUNTIME = "1";
|
||||
const workspaceDir = await makeTempDir();
|
||||
const ctx = createServiceContext(workspaceDir);
|
||||
const runtimeFactory = vi.fn(() => {
|
||||
throw new Error("runtime factory should not run when ACPX is skipped");
|
||||
});
|
||||
const service = createAcpxRuntimeService({
|
||||
runtimeFactory: runtimeFactory as never,
|
||||
});
|
||||
|
||||
await service.start(ctx);
|
||||
|
||||
expect(runtimeFactory).not.toHaveBeenCalled();
|
||||
expect(getAcpRuntimeBackend("acpx")).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
"skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)",
|
||||
);
|
||||
});
|
||||
});
|
||||
159
openclaw/extensions/acpx/src/service.ts
Normal file
159
openclaw/extensions/acpx/src/service.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import fs from "node:fs/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
AcpRuntime,
|
||||
OpenClawPluginService,
|
||||
OpenClawPluginServiceContext,
|
||||
PluginLogger,
|
||||
} from "../runtime-api.js";
|
||||
import { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } from "../runtime-api.js";
|
||||
import {
|
||||
resolveAcpxPluginConfig,
|
||||
toAcpMcpServers,
|
||||
type ResolvedAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
import {
|
||||
ACPX_BACKEND_ID,
|
||||
AcpxRuntime,
|
||||
createAgentRegistry,
|
||||
createFileSessionStore,
|
||||
} from "./runtime.js";
|
||||
|
||||
type AcpxRuntimeLike = AcpRuntime & {
|
||||
probeAvailability(): Promise<void>;
|
||||
isHealthy(): boolean;
|
||||
doctor?(): Promise<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
type AcpxRuntimeFactoryParams = {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
type CreateAcpxRuntimeServiceParams = {
|
||||
pluginConfig?: unknown;
|
||||
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
|
||||
};
|
||||
|
||||
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
|
||||
return new AcpxRuntime({
|
||||
cwd: params.pluginConfig.cwd,
|
||||
sessionStore: createFileSessionStore({
|
||||
stateDir: params.pluginConfig.stateDir,
|
||||
}),
|
||||
agentRegistry: createAgentRegistry({
|
||||
overrides: params.pluginConfig.agents,
|
||||
}),
|
||||
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
|
||||
permissionMode: params.pluginConfig.permissionMode,
|
||||
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
|
||||
timeoutMs:
|
||||
params.pluginConfig.timeoutSeconds != null
|
||||
? params.pluginConfig.timeoutSeconds * 1_000
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function warnOnIgnoredLegacyCompatibilityConfig(params: {
|
||||
pluginConfig: ResolvedAcpxPluginConfig;
|
||||
logger?: PluginLogger;
|
||||
}): void {
|
||||
const ignoredFields: string[] = [];
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.queueOwnerTtlSeconds != null) {
|
||||
ignoredFields.push("queueOwnerTtlSeconds");
|
||||
}
|
||||
if (params.pluginConfig.legacyCompatibilityConfig.strictWindowsCmdWrapper === false) {
|
||||
ignoredFields.push("strictWindowsCmdWrapper=false");
|
||||
}
|
||||
if (ignoredFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
params.logger?.warn(
|
||||
`embedded acpx runtime ignores legacy compatibility config: ${ignoredFields.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatDoctorFailureMessage(report: { message: string; details?: string[] }): string {
|
||||
const detailText = report.details?.filter(Boolean).join("; ").trim();
|
||||
return detailText ? `${report.message} (${detailText})` : report.message;
|
||||
}
|
||||
|
||||
export function createAcpxRuntimeService(
|
||||
params: CreateAcpxRuntimeServiceParams = {},
|
||||
): OpenClawPluginService {
|
||||
let runtime: AcpxRuntimeLike | null = null;
|
||||
let lifecycleRevision = 0;
|
||||
|
||||
return {
|
||||
id: "acpx-runtime",
|
||||
async start(ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
|
||||
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginConfig = resolveAcpxPluginConfig({
|
||||
rawConfig: params.pluginConfig,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
});
|
||||
await fs.mkdir(pluginConfig.stateDir, { recursive: true });
|
||||
warnOnIgnoredLegacyCompatibilityConfig({
|
||||
pluginConfig,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
|
||||
runtime = runtimeFactory({
|
||||
pluginConfig,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
|
||||
registerAcpRuntimeBackend({
|
||||
id: ACPX_BACKEND_ID,
|
||||
runtime,
|
||||
healthy: () => runtime?.isHealthy() ?? false,
|
||||
});
|
||||
ctx.logger.info(`embedded acpx runtime backend registered (cwd: ${pluginConfig.cwd})`);
|
||||
|
||||
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
lifecycleRevision += 1;
|
||||
const currentRevision = lifecycleRevision;
|
||||
void (async () => {
|
||||
try {
|
||||
await runtime?.probeAvailability();
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
if (runtime?.isHealthy()) {
|
||||
ctx.logger.info("embedded acpx runtime backend ready");
|
||||
return;
|
||||
}
|
||||
const doctorReport = await runtime?.doctor?.();
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(
|
||||
`embedded acpx runtime backend probe failed: ${doctorReport ? formatDoctorFailureMessage(doctorReport) : "backend remained unhealthy after probe"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (currentRevision !== lifecycleRevision) {
|
||||
return;
|
||||
}
|
||||
ctx.logger.warn(`embedded acpx runtime setup failed: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
async stop(_ctx: OpenClawPluginServiceContext): Promise<void> {
|
||||
lifecycleRevision += 1;
|
||||
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
|
||||
runtime = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
16
openclaw/extensions/acpx/tsconfig.json
Normal file
16
openclaw/extensions/acpx/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"
|
||||
]
|
||||
}
|
||||
52
openclaw/extensions/active-memory/config.test.ts
Normal file
52
openclaw/extensions/active-memory/config.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../src/plugins/schema-validator.js";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
|
||||
describe("active-memory manifest config schema", () => {
|
||||
it("accepts modelFallback for CLI and config.patch flows", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.model-fallback",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
modelFallback: "google/gemini-3-flash",
|
||||
modelFallbackPolicy: "resolved-only",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts timeoutMs values at the runtime ceiling", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.timeout-ceiling",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects timeoutMs values above the runtime ceiling", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "active-memory.manifest.timeout-above-ceiling",
|
||||
value: {
|
||||
enabled: true,
|
||||
agents: ["main"],
|
||||
timeoutMs: 120_001,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
1992
openclaw/extensions/active-memory/index.test.ts
Normal file
1992
openclaw/extensions/active-memory/index.test.ts
Normal file
File diff suppressed because it is too large
Load diff
2054
openclaw/extensions/active-memory/index.ts
Normal file
2054
openclaw/extensions/active-memory/index.ts
Normal file
File diff suppressed because it is too large
Load diff
139
openclaw/extensions/active-memory/openclaw.plugin.json
Normal file
139
openclaw/extensions/active-memory/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
{
|
||||
"id": "active-memory",
|
||||
"name": "Active Memory",
|
||||
"description": "Runs a bounded blocking memory sub-agent before eligible conversational replies and injects relevant memory into prompt context.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"model": { "type": "string" },
|
||||
"modelFallback": { "type": "string" },
|
||||
"modelFallbackPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["default-remote", "resolved-only"]
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["direct", "group", "channel"]
|
||||
}
|
||||
},
|
||||
"thinking": {
|
||||
"type": "string",
|
||||
"enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"]
|
||||
},
|
||||
"timeoutMs": { "type": "integer", "minimum": 250, "maximum": 120000 },
|
||||
"queryMode": {
|
||||
"type": "string",
|
||||
"enum": ["message", "recent", "full"]
|
||||
},
|
||||
"promptStyle": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"balanced",
|
||||
"strict",
|
||||
"contextual",
|
||||
"recall-heavy",
|
||||
"precision-heavy",
|
||||
"preference-only"
|
||||
]
|
||||
},
|
||||
"promptOverride": { "type": "string" },
|
||||
"promptAppend": { "type": "string" },
|
||||
"maxSummaryChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentUserTurns": { "type": "integer", "minimum": 0, "maximum": 4 },
|
||||
"recentAssistantTurns": { "type": "integer", "minimum": 0, "maximum": 3 },
|
||||
"recentUserChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"recentAssistantChars": { "type": "integer", "minimum": 40, "maximum": 1000 },
|
||||
"logging": { "type": "boolean" },
|
||||
"persistTranscripts": { "type": "boolean" },
|
||||
"transcriptDir": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"qmd": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"searchMode": {
|
||||
"type": "string",
|
||||
"enum": ["inherit", "search", "vsearch", "query"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"enabled": {
|
||||
"label": "Active Memory Recall",
|
||||
"help": "Globally enable or pause Active Memory recall while keeping the plugin command available."
|
||||
},
|
||||
"agents": {
|
||||
"label": "Target Agents",
|
||||
"help": "Explicit agent ids that may use active memory."
|
||||
},
|
||||
"model": {
|
||||
"label": "Memory Model",
|
||||
"help": "Provider/model used for the blocking memory sub-agent."
|
||||
},
|
||||
"modelFallback": {
|
||||
"label": "Fallback Memory Model",
|
||||
"help": "Optional provider/model to use if no explicit plugin model, session model, or agent primary model resolves."
|
||||
},
|
||||
"modelFallbackPolicy": {
|
||||
"label": "Model Fallback Policy",
|
||||
"help": "Deprecated compatibility field. Active Memory no longer uses a built-in fallback model; set modelFallback explicitly if you want a fallback."
|
||||
},
|
||||
"allowedChatTypes": {
|
||||
"label": "Allowed Chat Types",
|
||||
"help": "Choose which session types may run Active Memory. Defaults to direct-message style sessions only."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"label": "Timeout (ms)"
|
||||
},
|
||||
"queryMode": {
|
||||
"label": "Query Mode",
|
||||
"help": "Choose whether the blocking memory sub-agent sees only the latest user message, a small recent tail, or the full conversation."
|
||||
},
|
||||
"promptStyle": {
|
||||
"label": "Prompt Style",
|
||||
"help": "Choose how eager or strict the blocking memory sub-agent should be when deciding whether to return memory."
|
||||
},
|
||||
"thinking": {
|
||||
"label": "Thinking Override",
|
||||
"help": "Advanced: optional thinking level for the blocking memory sub-agent. Defaults to off for speed."
|
||||
},
|
||||
"promptOverride": {
|
||||
"label": "Prompt Override",
|
||||
"help": "Advanced: replace the default Active Memory sub-agent instructions. Conversation context is still appended."
|
||||
},
|
||||
"promptAppend": {
|
||||
"label": "Prompt Append",
|
||||
"help": "Advanced: append extra operator instructions after the default Active Memory sub-agent instructions."
|
||||
},
|
||||
"maxSummaryChars": {
|
||||
"label": "Max Summary Characters",
|
||||
"help": "Maximum total characters allowed in the active-memory summary."
|
||||
},
|
||||
"logging": {
|
||||
"label": "Enable Logging",
|
||||
"help": "Emit active memory timing and result logs."
|
||||
},
|
||||
"persistTranscripts": {
|
||||
"label": "Persist Transcripts",
|
||||
"help": "Keep blocking memory sub-agent session transcripts on disk in a separate plugin-owned directory."
|
||||
},
|
||||
"transcriptDir": {
|
||||
"label": "Transcript Directory",
|
||||
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
|
||||
},
|
||||
"qmd.searchMode": {
|
||||
"label": "QMD Search Mode",
|
||||
"help": "Override the QMD search mode used by the blocking memory sub-agent. Defaults to fast lexical search; use inherit to match the main memory backend setting."
|
||||
}
|
||||
}
|
||||
}
|
||||
11
openclaw/extensions/alibaba/index.ts
Normal file
11
openclaw/extensions/alibaba/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildAlibabaVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "alibaba",
|
||||
name: "Alibaba Model Studio Plugin",
|
||||
description: "Bundled Alibaba Model Studio video provider plugin",
|
||||
register(api) {
|
||||
api.registerVideoGenerationProvider(buildAlibabaVideoGenerationProvider());
|
||||
},
|
||||
});
|
||||
30
openclaw/extensions/alibaba/openclaw.plugin.json
Normal file
30
openclaw/extensions/alibaba/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"id": "alibaba",
|
||||
"enabledByDefault": true,
|
||||
"providerAuthEnvVars": {
|
||||
"alibaba": ["MODELSTUDIO_API_KEY", "DASHSCOPE_API_KEY", "QWEN_API_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "alibaba",
|
||||
"method": "api-key",
|
||||
"choiceId": "alibaba-model-studio-api-key",
|
||||
"choiceLabel": "Alibaba Model Studio API key",
|
||||
"groupId": "alibaba",
|
||||
"groupLabel": "Alibaba Model Studio",
|
||||
"groupHint": "DashScope / Model Studio API key",
|
||||
"optionKey": "alibabaModelStudioApiKey",
|
||||
"cliFlag": "--alibaba-model-studio-api-key",
|
||||
"cliOption": "--alibaba-model-studio-api-key <key>",
|
||||
"cliDescription": "Alibaba Model Studio API key"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"videoGenerationProviders": ["alibaba"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
openclaw/extensions/alibaba/package.json
Normal file
15
openclaw/extensions/alibaba/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { describePluginRegistrationContract } from "../../test/helpers/plugins/plugin-registration-contract.js";
|
||||
|
||||
describePluginRegistrationContract({
|
||||
pluginId: "alibaba",
|
||||
videoGenerationProviderIds: ["alibaba"],
|
||||
requireGenerateVideo: true,
|
||||
});
|
||||
16
openclaw/extensions/alibaba/tsconfig.json
Normal file
16
openclaw/extensions/alibaba/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"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
expectDashscopeVideoTaskPoll,
|
||||
expectSuccessfulDashscopeVideoResult,
|
||||
mockSuccessfulDashscopeVideoTask,
|
||||
} from "../../test/helpers/media-generation/dashscope-video-provider.js";
|
||||
import { expectExplicitVideoGenerationCapabilities } from "../../test/helpers/media-generation/provider-capability-assertions.js";
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
} from "../../test/helpers/media-generation/provider-http-mocks.js";
|
||||
|
||||
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
|
||||
|
||||
let buildAlibabaVideoGenerationProvider: typeof import("./video-generation-provider.js").buildAlibabaVideoGenerationProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ buildAlibabaVideoGenerationProvider } = await import("./video-generation-provider.js"));
|
||||
});
|
||||
|
||||
installProviderHttpMockCleanup();
|
||||
|
||||
describe("alibaba video generation provider", () => {
|
||||
it("declares explicit mode capabilities", () => {
|
||||
expectExplicitVideoGenerationCapabilities(buildAlibabaVideoGenerationProvider());
|
||||
});
|
||||
|
||||
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
|
||||
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
|
||||
|
||||
const provider = buildAlibabaVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "alibaba",
|
||||
model: "wan2.6-r2v-flash",
|
||||
prompt: "animate this shot",
|
||||
cfg: {},
|
||||
inputImages: [{ url: "https://example.com/ref.png" }],
|
||||
durationSeconds: 6,
|
||||
audio: true,
|
||||
watermark: false,
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
|
||||
body: expect.objectContaining({
|
||||
model: "wan2.6-r2v-flash",
|
||||
input: expect.objectContaining({
|
||||
prompt: "animate this shot",
|
||||
img_url: "https://example.com/ref.png",
|
||||
}),
|
||||
parameters: expect.objectContaining({
|
||||
duration: 6,
|
||||
enable_audio: true,
|
||||
watermark: false,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
|
||||
expectSuccessfulDashscopeVideoResult(result);
|
||||
});
|
||||
|
||||
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
|
||||
const provider = buildAlibabaVideoGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateVideo({
|
||||
provider: "alibaba",
|
||||
model: "wan2.6-i2v",
|
||||
prompt: "animate this local frame",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
|
||||
);
|
||||
expect(postJsonRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
83
openclaw/extensions/alibaba/video-generation-provider.ts
Normal file
83
openclaw/extensions/alibaba/video-generation-provider.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import { resolveProviderHttpRequestConfig } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
DASHSCOPE_WAN_VIDEO_CAPABILITIES,
|
||||
DASHSCOPE_WAN_VIDEO_MODELS,
|
||||
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
|
||||
DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
runDashscopeVideoGenerationTask,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
import type {
|
||||
VideoGenerationProvider,
|
||||
VideoGenerationRequest,
|
||||
VideoGenerationResult,
|
||||
} from "openclaw/plugin-sdk/video-generation";
|
||||
|
||||
const DEFAULT_ALIBABA_VIDEO_BASE_URL = "https://dashscope-intl.aliyuncs.com";
|
||||
const DEFAULT_ALIBABA_VIDEO_MODEL = DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL;
|
||||
|
||||
function resolveAlibabaVideoBaseUrl(req: VideoGenerationRequest): string {
|
||||
return req.cfg?.models?.providers?.alibaba?.baseUrl?.trim() || DEFAULT_ALIBABA_VIDEO_BASE_URL;
|
||||
}
|
||||
|
||||
function resolveDashscopeAigcApiBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
export function buildAlibabaVideoGenerationProvider(): VideoGenerationProvider {
|
||||
return {
|
||||
id: "alibaba",
|
||||
label: "Alibaba Model Studio",
|
||||
defaultModel: DEFAULT_ALIBABA_VIDEO_MODEL,
|
||||
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
|
||||
isConfigured: ({ agentDir }) =>
|
||||
isProviderApiKeyConfigured({
|
||||
provider: "alibaba",
|
||||
agentDir,
|
||||
}),
|
||||
capabilities: DASHSCOPE_WAN_VIDEO_CAPABILITIES,
|
||||
async generateVideo(req): Promise<VideoGenerationResult> {
|
||||
const fetchFn = fetch;
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: "alibaba",
|
||||
cfg: req.cfg,
|
||||
agentDir: req.agentDir,
|
||||
store: req.authStore,
|
||||
});
|
||||
if (!auth.apiKey) {
|
||||
throw new Error("Alibaba Model Studio API key missing");
|
||||
}
|
||||
|
||||
const requestBaseUrl = resolveAlibabaVideoBaseUrl(req);
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveProviderHttpRequestConfig({
|
||||
baseUrl: requestBaseUrl,
|
||||
defaultBaseUrl: DEFAULT_ALIBABA_VIDEO_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
},
|
||||
provider: "alibaba",
|
||||
capability: "video",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const model = req.model?.trim() || DEFAULT_ALIBABA_VIDEO_MODEL;
|
||||
return await runDashscopeVideoGenerationTask({
|
||||
providerLabel: "Alibaba Wan",
|
||||
model,
|
||||
req,
|
||||
url: `${resolveDashscopeAigcApiBaseUrl(baseUrl)}/api/v1/services/aigc/video-generation/video-synthesis`,
|
||||
headers,
|
||||
baseUrl: resolveDashscopeAigcApiBaseUrl(baseUrl),
|
||||
timeoutMs: req.timeoutMs,
|
||||
fetchFn,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
defaultTimeoutMs: DEFAULT_VIDEO_GENERATION_TIMEOUT_MS,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
9
openclaw/extensions/amazon-bedrock-mantle/api.ts
Normal file
9
openclaw/extensions/amazon-bedrock-mantle/api.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
} from "./discovery.js";
|
||||
6
openclaw/extensions/amazon-bedrock-mantle/bedrock-token-generator.d.ts
vendored
Normal file
6
openclaw/extensions/amazon-bedrock-mantle/bedrock-token-generator.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
declare module "@aws/bedrock-token-generator" {
|
||||
export function getTokenProvider(opts?: {
|
||||
region?: string;
|
||||
expiresInSeconds?: number;
|
||||
}): () => Promise<string>;
|
||||
}
|
||||
498
openclaw/extensions/amazon-bedrock-mantle/discovery.test.ts
Normal file
498
openclaw/extensions/amazon-bedrock-mantle/discovery.test.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discoverMantleModels,
|
||||
generateBearerTokenFromIam,
|
||||
mergeImplicitMantleProvider,
|
||||
resetIamTokenCacheForTest,
|
||||
resetMantleDiscoveryCacheForTest,
|
||||
resolveMantleBearerToken,
|
||||
resolveImplicitMantleProvider,
|
||||
} from "./api.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getTokenProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@aws/bedrock-token-generator", () => ({
|
||||
getTokenProvider: mocks.getTokenProvider,
|
||||
}));
|
||||
|
||||
describe("bedrock mantle discovery", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.restoreAllMocks();
|
||||
mocks.getTokenProvider.mockReset();
|
||||
resetMantleDiscoveryCacheForTest();
|
||||
resetIamTokenCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bearer token resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("resolves bearer token from AWS_BEARER_TOKEN_BEDROCK", () => {
|
||||
expect(
|
||||
resolveMantleBearerToken({
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key-abc123", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("bedrock-api-key-abc123");
|
||||
});
|
||||
|
||||
it("returns undefined when no bearer token env var is set", () => {
|
||||
expect(resolveMantleBearerToken({} as NodeJS.ProcessEnv)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims whitespace from bearer token", () => {
|
||||
expect(
|
||||
resolveMantleBearerToken({
|
||||
AWS_BEARER_TOKEN_BEDROCK: " my-token ", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("my-token");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IAM token generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("generates token from IAM credentials when token generation succeeds", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
|
||||
const token = await generateBearerTokenFromIam({ region: "us-east-1" });
|
||||
|
||||
expect(token).toBe("bedrock-api-key-generated");
|
||||
expect(mocks.getTokenProvider).toHaveBeenCalledWith({
|
||||
region: "us-east-1",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caches generated IAM tokens within TTL", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
let now = 1000;
|
||||
|
||||
const t1 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
|
||||
now += 1800_000; // 30 min — within 1hr cache TTL
|
||||
const t2 = await generateBearerTokenFromIam({ region: "us-east-1", now: () => now });
|
||||
|
||||
expect(t1).toEqual(t2);
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reuse an IAM token across regions", async () => {
|
||||
const tokenProvider = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
|
||||
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
|
||||
const east = await generateBearerTokenFromIam({ region: "us-east-1", now: () => 1000 });
|
||||
const west = await generateBearerTokenFromIam({ region: "us-west-2", now: () => 2000 });
|
||||
|
||||
expect(east).toBe("bedrock-api-key-east");
|
||||
expect(west).toBe("bedrock-api-key-west");
|
||||
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(1, {
|
||||
region: "us-east-1",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(mocks.getTokenProvider).toHaveBeenNthCalledWith(2, {
|
||||
region: "us-west-2",
|
||||
expiresInSeconds: 7200,
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns undefined when IAM token generation fails", async () => {
|
||||
mocks.getTokenProvider.mockImplementation(() => {
|
||||
throw new Error("no credentials");
|
||||
});
|
||||
|
||||
await expect(generateBearerTokenFromIam({ region: "us-east-1" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("discovers models from Mantle /v1/models endpoint sorted by id", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "openai.gpt-oss-120b", object: "model", owned_by: "openai" },
|
||||
{ id: "anthropic.claude-sonnet-4-6", object: "model", owned_by: "anthropic" },
|
||||
{ id: "mistral.devstral-2-123b", object: "model", owned_by: "mistral" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toHaveLength(3);
|
||||
// Models should be sorted alphabetically by id
|
||||
expect(models[0]).toMatchObject({
|
||||
id: "anthropic.claude-sonnet-4-6",
|
||||
name: "anthropic.claude-sonnet-4-6",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
});
|
||||
expect(models[1]).toMatchObject({
|
||||
id: "mistral.devstral-2-123b",
|
||||
reasoning: false,
|
||||
});
|
||||
expect(models[2]).toMatchObject({
|
||||
id: "openai.gpt-oss-120b",
|
||||
reasoning: true, // GPT-OSS 120B supports reasoning
|
||||
});
|
||||
|
||||
// Verify correct endpoint and auth header
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("infers reasoning support from model IDs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "moonshotai.kimi-k2-thinking", object: "model" },
|
||||
{ id: "openai.gpt-oss-120b", object: "model" },
|
||||
{ id: "openai.gpt-oss-safeguard-120b", object: "model" },
|
||||
{ id: "deepseek.v3.2", object: "model" },
|
||||
{ id: "mistral.mistral-large-3-675b-instruct", object: "model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const byId = Object.fromEntries(models.map((m) => [m.id, m]));
|
||||
expect(byId["moonshotai.kimi-k2-thinking"]?.reasoning).toBe(true);
|
||||
expect(byId["openai.gpt-oss-120b"]?.reasoning).toBe(true);
|
||||
expect(byId["openai.gpt-oss-safeguard-120b"]?.reasoning).toBe(true);
|
||||
expect(byId["deepseek.v3.2"]?.reasoning).toBe(false);
|
||||
expect(byId["mistral.mistral-large-3-675b-instruct"]?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty array on permission error", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array on network error", async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out models with empty IDs", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ id: "anthropic.claude-sonnet-4-6", object: "model" },
|
||||
{ id: "", object: "model" },
|
||||
{ id: " ", object: "model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery caching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("returns cached models on subsequent calls within refresh interval", async () => {
|
||||
let now = 1000000;
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
});
|
||||
|
||||
// First call — hits the network
|
||||
const first = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(first).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call within refresh interval — uses cache
|
||||
now += 60_000; // 1 minute later
|
||||
const second = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(second).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch
|
||||
|
||||
// Third call after refresh interval — re-fetches
|
||||
now += 3600_000; // 1 hour later
|
||||
const third = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(third).toHaveLength(1);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2); // Re-fetched
|
||||
});
|
||||
|
||||
it("returns stale cache on fetch failure", async () => {
|
||||
let now = 1000000;
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
})
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
||||
|
||||
// First call — succeeds
|
||||
await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
// Second call after expiry — fails but returns stale cache
|
||||
now += 7200_000;
|
||||
const stale = await discoverMantleModels({
|
||||
region: "us-east-1",
|
||||
bearerToken: "test-token",
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
now: () => now,
|
||||
});
|
||||
expect(stale).toHaveLength(1);
|
||||
expect(stale[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("resolves implicit provider when bearer token is set", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
||||
}),
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider).not.toBeNull();
|
||||
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
||||
expect(provider?.api).toBe("openai-completions");
|
||||
expect(provider?.auth).toBe("api-key");
|
||||
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
|
||||
expect(provider?.models).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns null when no auth is available", async () => {
|
||||
mocks.getTokenProvider.mockImplementation(() => {
|
||||
throw new Error("no credentials");
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a generated IAM token when no explicit token is set", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
|
||||
}),
|
||||
});
|
||||
mocks.getTokenProvider.mockReturnValue(tokenProvider);
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_PROFILE: "default",
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider).not.toBeNull();
|
||||
expect(provider?.apiKey).toBe("bedrock-api-key-iam");
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer bedrock-api-key-iam",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for unsupported regions", async () => {
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
AWS_REGION: "af-south-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
expect(provider).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to us-east-1 when no region is set", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "openai.gpt-oss-120b", object: "model" }] }),
|
||||
});
|
||||
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
||||
} as NodeJS.ProcessEnv,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://bedrock-mantle.us-east-1.api.aws/v1/models",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider merging
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("merges implicit models when existing provider has empty models", () => {
|
||||
const result = mergeImplicitMantleProvider({
|
||||
existing: {
|
||||
baseUrl: "https://custom.example.com/v1",
|
||||
models: [],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
apiKey: "env:AWS_BEARER_TOKEN_BEDROCK",
|
||||
models: [
|
||||
{
|
||||
id: "openai.gpt-oss-120b",
|
||||
name: "GPT-OSS 120B",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.baseUrl).toBe("https://custom.example.com/v1");
|
||||
expect(result.models?.map((m) => m.id)).toEqual(["openai.gpt-oss-120b"]);
|
||||
});
|
||||
|
||||
it("preserves existing models over implicit ones", () => {
|
||||
const result = mergeImplicitMantleProvider({
|
||||
existing: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
models: [
|
||||
{
|
||||
id: "custom-model",
|
||||
name: "My Custom Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 64000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
models: [
|
||||
{
|
||||
id: "openai.gpt-oss-120b",
|
||||
name: "GPT-OSS 120B",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.models?.map((m) => m.id)).toEqual(["custom-model"]);
|
||||
});
|
||||
});
|
||||
314
openclaw/extensions/amazon-bedrock-mantle/discovery.ts
Normal file
314
openclaw/extensions/amazon-bedrock-mantle/discovery.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const log = createSubsystemLogger("bedrock-mantle-discovery");
|
||||
|
||||
const DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const DEFAULT_CONTEXT_WINDOW = 32000;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600; // 1 hour
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mantle region & endpoint helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MANTLE_SUPPORTED_REGIONS = [
|
||||
"us-east-1",
|
||||
"us-east-2",
|
||||
"us-west-2",
|
||||
"ap-northeast-1",
|
||||
"ap-south-1",
|
||||
"ap-southeast-3",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-south-1",
|
||||
"eu-north-1",
|
||||
"sa-east-1",
|
||||
] as const;
|
||||
|
||||
function mantleEndpoint(region: string): string {
|
||||
return `https://bedrock-mantle.${region}.api.aws`;
|
||||
}
|
||||
|
||||
function isSupportedRegion(region: string): boolean {
|
||||
return (MANTLE_SUPPORTED_REGIONS as readonly string[]).includes(region);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bearer token resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MantleBearerTokenProvider = () => Promise<string>;
|
||||
|
||||
/**
|
||||
* Resolve a bearer token for Mantle authentication.
|
||||
*
|
||||
* Returns the value of AWS_BEARER_TOKEN_BEDROCK if set, undefined otherwise.
|
||||
* When no explicit token is set, `resolveImplicitMantleProvider` will attempt
|
||||
* to generate one from IAM credentials via `@aws/bedrock-token-generator`.
|
||||
*/
|
||||
export function resolveMantleBearerToken(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const explicitToken = env.AWS_BEARER_TOKEN_BEDROCK?.trim();
|
||||
if (explicitToken) {
|
||||
return explicitToken;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Token cache for IAM-derived bearer tokens, keyed by region. */
|
||||
const iamTokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
const IAM_TOKEN_TTL_MS = 3600_000; // Refresh every 1 hour (tokens valid up to 12h)
|
||||
|
||||
/**
|
||||
* Generate a bearer token from IAM credentials using `@aws/bedrock-token-generator`.
|
||||
*
|
||||
* Uses the AWS default credential chain (instance roles, SSO, access keys, EKS IRSA).
|
||||
* Returns undefined if the package is not installed or credentials are unavailable.
|
||||
*/
|
||||
export async function generateBearerTokenFromIam(params: {
|
||||
region: string;
|
||||
now?: () => number;
|
||||
}): Promise<string | undefined> {
|
||||
const now = params.now?.() ?? Date.now();
|
||||
const cached = iamTokenCache.get(params.region);
|
||||
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
try {
|
||||
const { getTokenProvider } = (await import("@aws/bedrock-token-generator")) as {
|
||||
getTokenProvider: (opts?: {
|
||||
region?: string;
|
||||
expiresInSeconds?: number;
|
||||
}) => () => Promise<string>;
|
||||
};
|
||||
const token = await getTokenProvider({
|
||||
region: params.region,
|
||||
expiresInSeconds: 7200, // 2 hours
|
||||
})();
|
||||
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
|
||||
return token;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
region: params.region,
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the IAM token cache (for testing). */
|
||||
export function resetIamTokenCacheForTest(): void {
|
||||
iamTokenCache.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI-format model list response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OpenAIModelEntry {
|
||||
id: string;
|
||||
object?: string;
|
||||
owned_by?: string;
|
||||
created?: number;
|
||||
}
|
||||
|
||||
interface OpenAIModelsResponse {
|
||||
data?: OpenAIModelEntry[];
|
||||
object?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reasoning heuristic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Model ID substrings that indicate reasoning/thinking support. */
|
||||
const REASONING_PATTERNS = [
|
||||
"thinking",
|
||||
"reasoner",
|
||||
"reasoning",
|
||||
"deepseek.r",
|
||||
"gpt-oss-120b", // GPT-OSS 120B supports reasoning
|
||||
"gpt-oss-safeguard-120b",
|
||||
];
|
||||
|
||||
function inferReasoningSupport(modelId: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return REASONING_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Discovery cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MantleCacheEntry {
|
||||
models: ModelDefinitionConfig[];
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const discoveryCache = new Map<string, MantleCacheEntry>();
|
||||
|
||||
/** Clear the discovery cache (for testing). */
|
||||
export function resetMantleDiscoveryCacheForTest(): void {
|
||||
discoveryCache.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Discover available models from the Mantle `/v1/models` endpoint.
|
||||
*
|
||||
* The response is in standard OpenAI format:
|
||||
* ```json
|
||||
* { "data": [{ "id": "anthropic.claude-sonnet-4-6", "object": "model", "owned_by": "anthropic" }] }
|
||||
* ```
|
||||
*
|
||||
* Results are cached per region for `DEFAULT_REFRESH_INTERVAL_SECONDS`.
|
||||
* Returns an empty array if the request fails (no permission, network error, etc.).
|
||||
*/
|
||||
export async function discoverMantleModels(params: {
|
||||
region: string;
|
||||
bearerToken: string;
|
||||
fetchFn?: typeof fetch;
|
||||
now?: () => number;
|
||||
}): Promise<ModelDefinitionConfig[]> {
|
||||
const { region, bearerToken, fetchFn = fetch, now = Date.now } = params;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = region;
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached && now() - cached.fetchedAt < DEFAULT_REFRESH_INTERVAL_SECONDS * 1000) {
|
||||
return cached.models;
|
||||
}
|
||||
|
||||
const endpoint = `${mantleEndpoint(region)}/v1/models`;
|
||||
|
||||
try {
|
||||
const response = await fetchFn(endpoint, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug?.("Mantle model discovery failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
|
||||
const body = (await response.json()) as OpenAIModelsResponse;
|
||||
const rawModels = body.data ?? [];
|
||||
|
||||
const models = rawModels
|
||||
.filter((m) => m.id?.trim())
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.id, // Mantle doesn't return display names
|
||||
reasoning: inferReasoningSupport(m.id),
|
||||
input: ["text" as const],
|
||||
cost: DEFAULT_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
}))
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
discoveryCache.set(cacheKey, { models, fetchedAt: now() });
|
||||
return models;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle model discovery error", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return cached?.models ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Implicit provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve an implicit Bedrock Mantle provider if authentication is available.
|
||||
*
|
||||
* Detection priority:
|
||||
* 1. AWS_BEARER_TOKEN_BEDROCK env var → use directly
|
||||
* 2. IAM credentials → generate bearer token via `@aws/bedrock-token-generator`
|
||||
* - Region from AWS_REGION / AWS_DEFAULT_REGION / default us-east-1
|
||||
* - Models discovered from `/v1/models`
|
||||
*/
|
||||
export async function resolveImplicitMantleProvider(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<ModelProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const region = env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
||||
const explicitBearerToken = resolveMantleBearerToken(env);
|
||||
|
||||
if (!isSupportedRegion(region)) {
|
||||
log.debug?.("Mantle not available in region", { region });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try explicit token first, then generate from IAM credentials
|
||||
const bearerToken = explicitBearerToken ?? (await generateBearerTokenFromIam({ region }));
|
||||
|
||||
if (!bearerToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const models = await discoverMantleModels({
|
||||
region,
|
||||
bearerToken,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug?.("Mantle provider resolved", { region, modelCount: models.length });
|
||||
|
||||
return {
|
||||
baseUrl: `${mantleEndpoint(region)}/v1`,
|
||||
api: "openai-completions",
|
||||
auth: "api-key",
|
||||
apiKey: explicitBearerToken ? "env:AWS_BEARER_TOKEN_BEDROCK" : bearerToken,
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeImplicitMantleProvider(params: {
|
||||
existing: ModelProviderConfig | undefined;
|
||||
implicit: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
}
|
||||
return {
|
||||
...implicit,
|
||||
...existing,
|
||||
models:
|
||||
Array.isArray(existing.models) && existing.models.length > 0
|
||||
? existing.models
|
||||
: implicit.models,
|
||||
};
|
||||
}
|
||||
24
openclaw/extensions/amazon-bedrock-mantle/index.test.ts
Normal file
24
openclaw/extensions/amazon-bedrock-mantle/index.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
|
||||
import bedrockMantlePlugin from "./index.js";
|
||||
|
||||
describe("amazon-bedrock-mantle provider plugin", () => {
|
||||
it("registers with correct provider ID and label", async () => {
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
expect(provider.id).toBe("amazon-bedrock-mantle");
|
||||
expect(provider.label).toBe("Amazon Bedrock Mantle (OpenAI-compatible)");
|
||||
});
|
||||
|
||||
it("classifies rate limit errors for failover", async () => {
|
||||
const provider = await registerSingleProviderPlugin(bedrockMantlePlugin);
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "rate_limit exceeded" } as never),
|
||||
).toBe("rate_limit");
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "429 Too Many Requests" } as never),
|
||||
).toBe("rate_limit");
|
||||
expect(
|
||||
provider.classifyFailoverReason?.({ errorMessage: "some other error" } as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
11
openclaw/extensions/amazon-bedrock-mantle/index.ts
Normal file
11
openclaw/extensions/amazon-bedrock-mantle/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerBedrockMantlePlugin } from "./register.sync.runtime.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "amazon-bedrock-mantle",
|
||||
name: "Amazon Bedrock Mantle Provider",
|
||||
description: "Bundled Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
register(api) {
|
||||
registerBedrockMantlePlugin(api);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "amazon-bedrock-mantle",
|
||||
"enabledByDefault": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
},
|
||||
"providers": ["amazon-bedrock-mantle"]
|
||||
}
|
||||
21
openclaw/extensions/amazon-bedrock-mantle/package.json
Normal file
21
openclaw/extensions/amazon-bedrock-mantle/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws/bedrock-token-generator": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
},
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
mergeImplicitMantleProvider,
|
||||
resolveImplicitMantleProvider,
|
||||
resolveMantleBearerToken,
|
||||
} from "./discovery.js";
|
||||
|
||||
export function registerBedrockMantlePlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "amazon-bedrock-mantle";
|
||||
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Amazon Bedrock Mantle (OpenAI-compatible)",
|
||||
docsPath: "/providers/bedrock-mantle",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const implicit = await resolveImplicitMantleProvider({
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!implicit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: mergeImplicitMantleProvider({
|
||||
existing: ctx.config.models?.providers?.[providerId],
|
||||
implicit,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) =>
|
||||
resolveMantleBearerToken(env) ? "AWS_BEARER_TOKEN_BEDROCK" : undefined,
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
/context_length_exceeded|max.*tokens.*exceeded/i.test(errorMessage),
|
||||
classifyFailoverReason: ({ errorMessage }) => {
|
||||
if (/rate_limit|too many requests|429/i.test(errorMessage)) {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (/overloaded|503/i.test(errorMessage)) {
|
||||
return "overloaded";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
16
openclaw/extensions/amazon-bedrock-mantle/tsconfig.json
Normal file
16
openclaw/extensions/amazon-bedrock-mantle/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"
|
||||
]
|
||||
}
|
||||
7
openclaw/extensions/amazon-bedrock/api.ts
Normal file
7
openclaw/extensions/amazon-bedrock/api.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export {
|
||||
discoverBedrockModels,
|
||||
mergeImplicitBedrockProvider,
|
||||
resetBedrockDiscoveryCacheForTest,
|
||||
resolveBedrockConfigApiKey,
|
||||
resolveImplicitBedrockProvider,
|
||||
} from "./discovery.js";
|
||||
4
openclaw/extensions/amazon-bedrock/config-api.ts
Normal file
4
openclaw/extensions/amazon-bedrock/config-api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Narrow barrel for config compatibility helpers consumed outside the plugin.
|
||||
// Keep this separate from runtime exports so doctor/config code stays lightweight.
|
||||
|
||||
export { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";
|
||||
81
openclaw/extensions/amazon-bedrock/config-compat.test.ts
Normal file
81
openclaw/extensions/amazon-bedrock/config-compat.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";
|
||||
|
||||
describe("amazon-bedrock config migration", () => {
|
||||
it("moves legacy models.bedrockDiscovery into plugin-owned discovery config", () => {
|
||||
const result = migrateAmazonBedrockLegacyConfig({
|
||||
models: {
|
||||
mode: "merge",
|
||||
bedrockDiscovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
refreshInterval: 3600,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({
|
||||
models: {
|
||||
mode: "merge",
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"amazon-bedrock": {
|
||||
config: {
|
||||
discovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
refreshInterval: 3600,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
"Moved models.bedrockDiscovery → plugins.entries.amazon-bedrock.config.discovery.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges missing fields into existing plugin discovery config", () => {
|
||||
const result = migrateAmazonBedrockLegacyConfig({
|
||||
models: {
|
||||
bedrockDiscovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
providerFilter: ["anthropic"],
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"amazon-bedrock": {
|
||||
config: {
|
||||
discovery: {
|
||||
region: "us-west-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.config).toEqual({
|
||||
plugins: {
|
||||
entries: {
|
||||
"amazon-bedrock": {
|
||||
config: {
|
||||
discovery: {
|
||||
enabled: true,
|
||||
region: "us-west-2",
|
||||
providerFilter: ["anthropic"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
"Merged models.bedrockDiscovery → plugins.entries.amazon-bedrock.config.discovery (filled missing fields from legacy; kept explicit plugin config values).",
|
||||
]);
|
||||
});
|
||||
});
|
||||
107
openclaw/extensions/amazon-bedrock/config-compat.ts
Normal file
107
openclaw/extensions/amazon-bedrock/config-compat.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const LEGACY_PATH = "models.bedrockDiscovery";
|
||||
const TARGET_PATH = "plugins.entries.amazon-bedrock.config.discovery";
|
||||
const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
function isBlockedObjectKey(key: string): boolean {
|
||||
return BLOCKED_OBJECT_KEYS.has(key);
|
||||
}
|
||||
|
||||
function getRecord(value: unknown): JsonRecord | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function ensureRecord(root: JsonRecord, key: string): JsonRecord {
|
||||
const existing = root[key];
|
||||
if (isRecord(existing)) {
|
||||
return existing;
|
||||
}
|
||||
const next: JsonRecord = {};
|
||||
root[key] = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function mergeMissing(target: JsonRecord, source: JsonRecord): void {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined || isBlockedObjectKey(key)) {
|
||||
continue;
|
||||
}
|
||||
const existing = target[key];
|
||||
if (existing === undefined) {
|
||||
target[key] = value;
|
||||
continue;
|
||||
}
|
||||
if (isRecord(existing) && isRecord(value)) {
|
||||
mergeMissing(existing, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cloneRecord<T extends JsonRecord>(value: T | undefined): T {
|
||||
return { ...value } as T;
|
||||
}
|
||||
|
||||
function resolveLegacyBedrockDiscoveryConfig(raw: unknown): JsonRecord | undefined {
|
||||
if (!isRecord(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const models = getRecord(raw.models);
|
||||
return getRecord(models?.bedrockDiscovery) ?? undefined;
|
||||
}
|
||||
|
||||
function pruneEmptyModelsRoot(root: JsonRecord): void {
|
||||
const models = getRecord(root.models);
|
||||
if (models && Object.keys(models).length === 0) {
|
||||
delete root.models;
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateAmazonBedrockLegacyConfig<T>(raw: T): { config: T; changes: string[] } {
|
||||
if (!isRecord(raw)) {
|
||||
return { config: raw, changes: [] };
|
||||
}
|
||||
|
||||
const legacy = resolveLegacyBedrockDiscoveryConfig(raw);
|
||||
if (!legacy) {
|
||||
return { config: raw, changes: [] };
|
||||
}
|
||||
|
||||
const nextRoot = structuredClone(raw) as JsonRecord;
|
||||
const models = ensureRecord(nextRoot, "models");
|
||||
delete models.bedrockDiscovery;
|
||||
pruneEmptyModelsRoot(nextRoot);
|
||||
|
||||
const changes: string[] = [];
|
||||
if (Object.keys(legacy).length === 0) {
|
||||
changes.push(`Removed empty ${LEGACY_PATH}.`);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
|
||||
const plugins = ensureRecord(nextRoot, "plugins");
|
||||
const entries = ensureRecord(plugins, "entries");
|
||||
const entry = ensureRecord(entries, "amazon-bedrock");
|
||||
const config = ensureRecord(entry, "config");
|
||||
const existing = getRecord(config.discovery) ?? undefined;
|
||||
|
||||
if (!existing) {
|
||||
config.discovery = cloneRecord(legacy);
|
||||
changes.push(`Moved ${LEGACY_PATH} → ${TARGET_PATH}.`);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
|
||||
const merged = cloneRecord(existing);
|
||||
mergeMissing(merged, legacy);
|
||||
config.discovery = merged;
|
||||
if (JSON.stringify(merged) !== JSON.stringify(existing)) {
|
||||
changes.push(
|
||||
`Merged ${LEGACY_PATH} → ${TARGET_PATH} (filled missing fields from legacy; kept explicit plugin config values).`,
|
||||
);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
|
||||
changes.push(`Removed ${LEGACY_PATH} (${TARGET_PATH} already set).`);
|
||||
return { config: nextRoot as T, changes };
|
||||
}
|
||||
436
openclaw/extensions/amazon-bedrock/discovery.test.ts
Normal file
436
openclaw/extensions/amazon-bedrock/discovery.test.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import type { BedrockClient } from "@aws-sdk/client-bedrock";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discoverBedrockModels,
|
||||
mergeImplicitBedrockProvider,
|
||||
resetBedrockDiscoveryCacheForTest,
|
||||
resolveBedrockConfigApiKey,
|
||||
resolveImplicitBedrockProvider,
|
||||
} from "./api.js";
|
||||
|
||||
const sendMock = vi.fn();
|
||||
const clientFactory = () => ({ send: sendMock }) as unknown as BedrockClient;
|
||||
|
||||
const baseActiveAnthropicSummary = {
|
||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
modelName: "Claude 3.7 Sonnet",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
};
|
||||
|
||||
function mockSingleActiveSummary(overrides: Partial<typeof baseActiveAnthropicSummary> = {}): void {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [{ ...baseActiveAnthropicSummary, ...overrides }],
|
||||
})
|
||||
// ListInferenceProfiles response (empty — no inference profiles in basic tests).
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
}
|
||||
|
||||
describe("bedrock discovery", () => {
|
||||
beforeEach(() => {
|
||||
sendMock.mockClear();
|
||||
resetBedrockDiscoveryCacheForTest();
|
||||
});
|
||||
|
||||
it("filters to active streaming text models and maps modalities", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
modelName: "Claude 3.7 Sonnet",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
{
|
||||
modelId: "anthropic.claude-3-haiku-20240307-v1:0",
|
||||
modelName: "Claude 3 Haiku",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: false,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
{
|
||||
modelId: "meta.llama3-8b-instruct-v1:0",
|
||||
modelName: "Llama 3 8B",
|
||||
providerName: "meta",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "INACTIVE" },
|
||||
},
|
||||
{
|
||||
modelId: "amazon.titan-embed-text-v1",
|
||||
modelName: "Titan Embed",
|
||||
providerName: "amazon",
|
||||
inputModalities: ["TEXT"],
|
||||
outputModalities: ["EMBEDDING"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]).toMatchObject({
|
||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it("applies provider filter", async () => {
|
||||
mockSingleActiveSummary();
|
||||
|
||||
const models = await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { providerFilter: ["amazon"] },
|
||||
clientFactory,
|
||||
});
|
||||
expect(models).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses configured defaults for context and max tokens", async () => {
|
||||
mockSingleActiveSummary();
|
||||
|
||||
const models = await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { defaultContextWindow: 64000, defaultMaxTokens: 8192 },
|
||||
clientFactory,
|
||||
});
|
||||
expect(models[0]).toMatchObject({ contextWindow: 64000, maxTokens: 8192 });
|
||||
});
|
||||
|
||||
it("caches results when refreshInterval is enabled", async () => {
|
||||
mockSingleActiveSummary();
|
||||
|
||||
await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
// 2 calls on first discovery (ListFoundationModels + ListInferenceProfiles), 0 on cached second.
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips cache when refreshInterval is 0", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 0 },
|
||||
clientFactory,
|
||||
});
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 0 },
|
||||
clientFactory,
|
||||
});
|
||||
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles) × 2 runs.
|
||||
expect(sendMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("resolves the Bedrock config apiKey from AWS auth env vars", () => {
|
||||
expect(
|
||||
resolveBedrockConfigApiKey({
|
||||
AWS_BEARER_TOKEN_BEDROCK: "bearer", // pragma: allowlist secret
|
||||
AWS_PROFILE: "default",
|
||||
}),
|
||||
).toBe("AWS_BEARER_TOKEN_BEDROCK");
|
||||
|
||||
// When no AWS env vars are present (e.g. instance role), no marker should be injected.
|
||||
// The aws-sdk credential chain handles auth at request time. (#49891)
|
||||
expect(resolveBedrockConfigApiKey({} as NodeJS.ProcessEnv)).toBeUndefined();
|
||||
|
||||
// When AWS_PROFILE is explicitly set, it should return the marker.
|
||||
expect(resolveBedrockConfigApiKey({ AWS_PROFILE: "default" } as NodeJS.ProcessEnv)).toBe(
|
||||
"AWS_PROFILE",
|
||||
);
|
||||
});
|
||||
|
||||
it("discovers inference profiles and inherits foundation model capabilities", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
modelName: "Claude Sonnet 4.6",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "US Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:us-east-1::inference-profile/us.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
inferenceProfileId: "eu.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "EU Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:eu-west-1::inference-profile/eu.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:eu-west-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
|
||||
inferenceProfileArn:
|
||||
"arn:aws:bedrock:us-east-1::inference-profile/global.anthropic.claude-sonnet-4-6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
// Inactive profile should be filtered out.
|
||||
{
|
||||
inferenceProfileId: "ap.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "AP Claude Sonnet 4.6",
|
||||
status: "LEGACY",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
|
||||
// Foundation model + 3 active inference profiles = 4 models.
|
||||
expect(models).toHaveLength(4);
|
||||
|
||||
// Global profiles should be sorted first (recommended for most users).
|
||||
expect(models[0]?.id).toBe("global.anthropic.claude-sonnet-4-6");
|
||||
|
||||
const foundationModel = models.find((m) => m.id === "anthropic.claude-sonnet-4-6");
|
||||
const usProfile = models.find((m) => m.id === "us.anthropic.claude-sonnet-4-6");
|
||||
const euProfile = models.find((m) => m.id === "eu.anthropic.claude-sonnet-4-6");
|
||||
const globalProfile = models.find((m) => m.id === "global.anthropic.claude-sonnet-4-6");
|
||||
|
||||
// Foundation model has image input.
|
||||
expect(foundationModel).toMatchObject({ input: ["text", "image"] });
|
||||
|
||||
// Inference profiles inherit image input from the foundation model.
|
||||
expect(usProfile).toMatchObject({
|
||||
name: "US Anthropic Claude Sonnet 4.6",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
expect(euProfile).toMatchObject({ input: ["text", "image"] });
|
||||
expect(globalProfile).toMatchObject({ input: ["text", "image"] });
|
||||
|
||||
// Inactive profile should not be present.
|
||||
expect(models.find((m) => m.id === "ap.anthropic.claude-sonnet-4-6")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gracefully handles ListInferenceProfiles permission errors", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [baseActiveAnthropicSummary],
|
||||
})
|
||||
// Simulate AccessDeniedException for ListInferenceProfiles.
|
||||
.mockRejectedValueOnce(new Error("AccessDeniedException"));
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
// Foundation model should still be discovered despite profile discovery failure.
|
||||
expect(models).toHaveLength(1);
|
||||
expect(models[0]?.id).toBe("anthropic.claude-3-7-sonnet-20250219-v1:0");
|
||||
});
|
||||
|
||||
it("keeps matching inference profiles when provider filters are enabled", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
modelName: "Claude Sonnet 4.6",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "global.anthropic.claude-sonnet-4-6",
|
||||
inferenceProfileName: "Global Anthropic Claude Sonnet 4.6",
|
||||
status: "ACTIVE",
|
||||
type: "SYSTEM_DEFINED",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { providerFilter: ["anthropic"] },
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
"global.anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prefers backing model ARNs for application profiles with region-like ids", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({
|
||||
modelSummaries: [
|
||||
{
|
||||
modelId: "anthropic.claude-sonnet-4-6",
|
||||
modelName: "Claude Sonnet 4.6",
|
||||
providerName: "anthropic",
|
||||
inputModalities: ["TEXT", "IMAGE"],
|
||||
outputModalities: ["TEXT"],
|
||||
responseStreamingSupported: true,
|
||||
modelLifecycle: { status: "ACTIVE" },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
inferenceProfileSummaries: [
|
||||
{
|
||||
inferenceProfileId: "us.my-prod-profile",
|
||||
inferenceProfileName: "Prod Claude Profile",
|
||||
status: "ACTIVE",
|
||||
type: "APPLICATION",
|
||||
models: [
|
||||
{
|
||||
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
|
||||
const profile = models.find((model) => model.id === "us.my-prod-profile");
|
||||
|
||||
expect(profile).toMatchObject({
|
||||
id: "us.my-prod-profile",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 32000,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges implicit Bedrock models into explicit provider overrides", () => {
|
||||
expect(
|
||||
mergeImplicitBedrockProvider({
|
||||
existing: {
|
||||
baseUrl: "https://override.example.com",
|
||||
headers: { "x-test-header": "1" },
|
||||
models: [],
|
||||
},
|
||||
implicit: {
|
||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||
api: "bedrock-converse-stream",
|
||||
auth: "aws-sdk",
|
||||
models: [
|
||||
{
|
||||
id: "amazon.nova-micro-v1:0",
|
||||
name: "Nova",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1,
|
||||
maxTokens: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
}).models?.map((model) => model.id),
|
||||
).toEqual(["amazon.nova-micro-v1:0"]);
|
||||
});
|
||||
|
||||
it("prefers plugin-owned discovery config and still honors legacy fallback", async () => {
|
||||
mockSingleActiveSummary();
|
||||
|
||||
const pluginEnabled = await resolveImplicitBedrockProvider({
|
||||
config: {
|
||||
models: {
|
||||
bedrockDiscovery: {
|
||||
enabled: false,
|
||||
region: "us-west-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
discovery: {
|
||||
enabled: true,
|
||||
region: "us-east-1",
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
expect(pluginEnabled?.baseUrl).toBe("https://bedrock-runtime.us-east-1.amazonaws.com");
|
||||
// 2 calls per discovery (ListFoundationModels + ListInferenceProfiles).
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
mockSingleActiveSummary();
|
||||
|
||||
const legacyEnabled = await resolveImplicitBedrockProvider({
|
||||
config: {
|
||||
models: {
|
||||
bedrockDiscovery: {
|
||||
enabled: true,
|
||||
region: "us-west-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
clientFactory,
|
||||
});
|
||||
|
||||
expect(legacyEnabled?.baseUrl).toBe("https://bedrock-runtime.us-west-2.amazonaws.com");
|
||||
expect(sendMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
485
openclaw/extensions/amazon-bedrock/discovery.ts
Normal file
485
openclaw/extensions/amazon-bedrock/discovery.ts
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
import {
|
||||
BedrockClient,
|
||||
ListFoundationModelsCommand,
|
||||
type ListFoundationModelsCommandOutput,
|
||||
ListInferenceProfilesCommand,
|
||||
type ListInferenceProfilesCommandOutput,
|
||||
} from "@aws-sdk/client-bedrock";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveAwsSdkEnvVarName } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import type {
|
||||
BedrockDiscoveryConfig,
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const log = createSubsystemLogger("bedrock-discovery");
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_SECONDS = 3600;
|
||||
const DEFAULT_CONTEXT_WINDOW = 32000;
|
||||
const DEFAULT_MAX_TOKENS = 4096;
|
||||
const DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
type BedrockModelSummary = NonNullable<ListFoundationModelsCommandOutput["modelSummaries"]>[number];
|
||||
|
||||
type InferenceProfileSummary = NonNullable<
|
||||
ListInferenceProfilesCommandOutput["inferenceProfileSummaries"]
|
||||
>[number];
|
||||
|
||||
type BedrockDiscoveryCacheEntry = {
|
||||
expiresAt: number;
|
||||
value?: ModelDefinitionConfig[];
|
||||
inFlight?: Promise<ModelDefinitionConfig[]>;
|
||||
};
|
||||
|
||||
const discoveryCache = new Map<string, BedrockDiscoveryCacheEntry>();
|
||||
let hasLoggedBedrockError = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function normalizeProviderFilter(filter?: string[]): string[] {
|
||||
if (!filter || filter.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const normalized = new Set(
|
||||
filter
|
||||
.map((entry) => normalizeOptionalLowercaseString(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
);
|
||||
return Array.from(normalized).toSorted();
|
||||
}
|
||||
|
||||
function buildCacheKey(params: {
|
||||
region: string;
|
||||
providerFilter: string[];
|
||||
refreshIntervalSeconds: number;
|
||||
defaultContextWindow: number;
|
||||
defaultMaxTokens: number;
|
||||
}): string {
|
||||
return JSON.stringify(params);
|
||||
}
|
||||
|
||||
function includesTextModalities(modalities?: Array<string>): boolean {
|
||||
return (modalities ?? []).some((entry) => normalizeOptionalLowercaseString(entry) === "text");
|
||||
}
|
||||
|
||||
function isActive(summary: BedrockModelSummary): boolean {
|
||||
const status = summary.modelLifecycle?.status;
|
||||
return typeof status === "string" ? status.toUpperCase() === "ACTIVE" : false;
|
||||
}
|
||||
|
||||
function mapInputModalities(summary: BedrockModelSummary): Array<"text" | "image"> {
|
||||
const inputs = summary.inputModalities ?? [];
|
||||
const mapped = new Set<"text" | "image">();
|
||||
for (const modality of inputs) {
|
||||
const lower = normalizeOptionalLowercaseString(modality);
|
||||
if (lower === "text") {
|
||||
mapped.add("text");
|
||||
}
|
||||
if (lower === "image") {
|
||||
mapped.add("image");
|
||||
}
|
||||
}
|
||||
if (mapped.size === 0) {
|
||||
mapped.add("text");
|
||||
}
|
||||
return Array.from(mapped);
|
||||
}
|
||||
|
||||
function inferReasoningSupport(summary: BedrockModelSummary): boolean {
|
||||
const haystack = normalizeLowercaseStringOrEmpty(
|
||||
`${summary.modelId ?? ""} ${summary.modelName ?? ""}`,
|
||||
);
|
||||
return haystack.includes("reasoning") || haystack.includes("thinking");
|
||||
}
|
||||
|
||||
function resolveDefaultContextWindow(config?: BedrockDiscoveryConfig): number {
|
||||
const value = Math.floor(config?.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW);
|
||||
return value > 0 ? value : DEFAULT_CONTEXT_WINDOW;
|
||||
}
|
||||
|
||||
function resolveDefaultMaxTokens(config?: BedrockDiscoveryConfig): number {
|
||||
const value = Math.floor(config?.defaultMaxTokens ?? DEFAULT_MAX_TOKENS);
|
||||
return value > 0 ? value : DEFAULT_MAX_TOKENS;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foundation model helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function matchesProviderFilter(summary: BedrockModelSummary, filter: string[]): boolean {
|
||||
if (filter.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const providerName =
|
||||
summary.providerName ??
|
||||
(typeof summary.modelId === "string" ? summary.modelId.split(".")[0] : undefined);
|
||||
const normalized = normalizeOptionalLowercaseString(providerName);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return filter.includes(normalized);
|
||||
}
|
||||
|
||||
function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): boolean {
|
||||
if (!summary.modelId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
if (!matchesProviderFilter(summary, filter)) {
|
||||
return false;
|
||||
}
|
||||
if (summary.responseStreamingSupported !== true) {
|
||||
return false;
|
||||
}
|
||||
if (!includesTextModalities(summary.outputModalities)) {
|
||||
return false;
|
||||
}
|
||||
if (!isActive(summary)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toModelDefinition(
|
||||
summary: BedrockModelSummary,
|
||||
defaults: { contextWindow: number; maxTokens: number },
|
||||
): ModelDefinitionConfig {
|
||||
const id = summary.modelId?.trim() ?? "";
|
||||
return {
|
||||
id,
|
||||
name: summary.modelName?.trim() || id,
|
||||
reasoning: inferReasoningSupport(summary),
|
||||
input: mapInputModalities(summary),
|
||||
cost: DEFAULT_COST,
|
||||
contextWindow: defaults.contextWindow,
|
||||
maxTokens: defaults.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inference profile helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the base foundation model ID from an inference profile.
|
||||
*
|
||||
* System-defined profiles use a region prefix:
|
||||
* "us.anthropic.claude-sonnet-4-6" → "anthropic.claude-sonnet-4-6"
|
||||
*
|
||||
* Application profiles carry the model ARN in their models[] array:
|
||||
* models[0].modelArn = "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6"
|
||||
* → "anthropic.claude-sonnet-4-6"
|
||||
*/
|
||||
function resolveBaseModelId(profile: InferenceProfileSummary): string | undefined {
|
||||
const firstArn = profile.models?.[0]?.modelArn;
|
||||
if (firstArn) {
|
||||
const arnMatch = /foundation-model\/(.+)$/.exec(firstArn);
|
||||
if (arnMatch) {
|
||||
return arnMatch[1];
|
||||
}
|
||||
}
|
||||
if (profile.type === "SYSTEM_DEFINED") {
|
||||
const id = profile.inferenceProfileId ?? "";
|
||||
const prefixMatch = /^(?:us|eu|ap|jp|global)\.(.+)$/i.exec(id);
|
||||
if (prefixMatch) {
|
||||
return prefixMatch[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch raw inference profile summaries from the Bedrock control plane.
|
||||
* Handles pagination. Best-effort: silently returns empty array if IAM lacks
|
||||
* bedrock:ListInferenceProfiles permission.
|
||||
*/
|
||||
async function fetchInferenceProfileSummaries(
|
||||
client: BedrockClient,
|
||||
): Promise<InferenceProfileSummary[]> {
|
||||
try {
|
||||
const profiles: InferenceProfileSummary[] = [];
|
||||
let nextToken: string | undefined;
|
||||
do {
|
||||
const response: ListInferenceProfilesCommandOutput = await client.send(
|
||||
new ListInferenceProfilesCommand({ nextToken }),
|
||||
);
|
||||
for (const summary of response.inferenceProfileSummaries ?? []) {
|
||||
profiles.push(summary);
|
||||
}
|
||||
nextToken = response.nextToken;
|
||||
} while (nextToken);
|
||||
return profiles;
|
||||
} catch (error) {
|
||||
log.debug?.("Skipping inference profile discovery", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert raw inference profile summaries into model definitions.
|
||||
*
|
||||
* Each profile inherits capabilities (modalities, reasoning, context window,
|
||||
* cost) from its underlying foundation model. This ensures that
|
||||
* "us.anthropic.claude-sonnet-4-6" has the same capabilities as
|
||||
* "anthropic.claude-sonnet-4-6" — including image input, reasoning support,
|
||||
* and token limits.
|
||||
*
|
||||
* When the foundation model isn't found in the map (e.g. the model is only
|
||||
* available via inference profiles in this region), safe defaults are used.
|
||||
*/
|
||||
function resolveInferenceProfiles(
|
||||
profiles: InferenceProfileSummary[],
|
||||
defaults: { contextWindow: number; maxTokens: number },
|
||||
providerFilter: string[],
|
||||
foundationModels: Map<string, ModelDefinitionConfig>,
|
||||
): ModelDefinitionConfig[] {
|
||||
const discovered: ModelDefinitionConfig[] = [];
|
||||
for (const profile of profiles) {
|
||||
if (!profile.inferenceProfileId?.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (profile.status !== "ACTIVE") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply provider filter: check if any of the underlying models match.
|
||||
if (providerFilter.length > 0) {
|
||||
const models = profile.models ?? [];
|
||||
const matchesFilter = models.some((m) => {
|
||||
const provider = m.modelArn?.split("/")?.[1]?.split(".")?.[0];
|
||||
return provider
|
||||
? providerFilter.includes(normalizeOptionalLowercaseString(provider) ?? "")
|
||||
: false;
|
||||
});
|
||||
if (!matchesFilter) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the underlying foundation model to inherit its capabilities.
|
||||
const baseModelId = resolveBaseModelId(profile);
|
||||
const baseModel = baseModelId
|
||||
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
|
||||
: undefined;
|
||||
|
||||
discovered.push({
|
||||
id: profile.inferenceProfileId,
|
||||
name: profile.inferenceProfileName?.trim() || profile.inferenceProfileId,
|
||||
reasoning: baseModel?.reasoning ?? false,
|
||||
input: baseModel?.input ?? ["text"],
|
||||
cost: baseModel?.cost ?? DEFAULT_COST,
|
||||
contextWindow: baseModel?.contextWindow ?? defaults.contextWindow,
|
||||
maxTokens: baseModel?.maxTokens ?? defaults.maxTokens,
|
||||
});
|
||||
}
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resetBedrockDiscoveryCacheForTest(): void {
|
||||
discoveryCache.clear();
|
||||
hasLoggedBedrockError = false;
|
||||
}
|
||||
|
||||
export function resolveBedrockConfigApiKey(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
// When no AWS auth env marker is present, Bedrock should fall back to the
|
||||
// AWS SDK default credential chain instead of persisting a fake apiKey marker.
|
||||
return resolveAwsSdkEnvVarName(env);
|
||||
}
|
||||
|
||||
export async function discoverBedrockModels(params: {
|
||||
region: string;
|
||||
config?: BedrockDiscoveryConfig;
|
||||
now?: () => number;
|
||||
clientFactory?: (region: string) => BedrockClient;
|
||||
}): Promise<ModelDefinitionConfig[]> {
|
||||
const refreshIntervalSeconds = Math.max(
|
||||
0,
|
||||
Math.floor(params.config?.refreshInterval ?? DEFAULT_REFRESH_INTERVAL_SECONDS),
|
||||
);
|
||||
const providerFilter = normalizeProviderFilter(params.config?.providerFilter);
|
||||
const defaultContextWindow = resolveDefaultContextWindow(params.config);
|
||||
const defaultMaxTokens = resolveDefaultMaxTokens(params.config);
|
||||
const cacheKey = buildCacheKey({
|
||||
region: params.region,
|
||||
providerFilter,
|
||||
refreshIntervalSeconds,
|
||||
defaultContextWindow,
|
||||
defaultMaxTokens,
|
||||
});
|
||||
const now = params.now?.() ?? Date.now();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached?.value && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached?.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
const clientFactory = params.clientFactory ?? ((region: string) => new BedrockClient({ region }));
|
||||
const client = clientFactory(params.region);
|
||||
|
||||
const discoveryPromise = (async () => {
|
||||
// Discover foundation models and inference profiles in parallel.
|
||||
// Both API calls are independent, but we need the foundation model data
|
||||
// to resolve inference profile capabilities — so we fetch in parallel,
|
||||
// then build the lookup map before processing profiles.
|
||||
const [foundationResponse, profileSummaries] = await Promise.all([
|
||||
client.send(new ListFoundationModelsCommand({})),
|
||||
fetchInferenceProfileSummaries(client),
|
||||
]);
|
||||
|
||||
const discovered: ModelDefinitionConfig[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const foundationModels = new Map<string, ModelDefinitionConfig>();
|
||||
|
||||
// Foundation models first — build both the results list and the lookup map.
|
||||
for (const summary of foundationResponse.modelSummaries ?? []) {
|
||||
if (!shouldIncludeSummary(summary, providerFilter)) {
|
||||
continue;
|
||||
}
|
||||
const def = toModelDefinition(summary, {
|
||||
contextWindow: defaultContextWindow,
|
||||
maxTokens: defaultMaxTokens,
|
||||
});
|
||||
discovered.push(def);
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(def.id);
|
||||
seenIds.add(normalizedId);
|
||||
foundationModels.set(normalizedId, def);
|
||||
}
|
||||
|
||||
// Merge inference profiles — inherit capabilities from foundation models.
|
||||
const inferenceProfiles = resolveInferenceProfiles(
|
||||
profileSummaries,
|
||||
{ contextWindow: defaultContextWindow, maxTokens: defaultMaxTokens },
|
||||
providerFilter,
|
||||
foundationModels,
|
||||
);
|
||||
for (const profile of inferenceProfiles) {
|
||||
const normalizedId = normalizeLowercaseStringOrEmpty(profile.id);
|
||||
if (!seenIds.has(normalizedId)) {
|
||||
discovered.push(profile);
|
||||
seenIds.add(normalizedId);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: global cross-region profiles first (recommended for most users —
|
||||
// better capacity, automatic failover, no data sovereignty constraints),
|
||||
// then remaining profiles/models alphabetically.
|
||||
return discovered.toSorted((a, b) => {
|
||||
const aGlobal = a.id.startsWith("global.") ? 0 : 1;
|
||||
const bGlobal = b.id.startsWith("global.") ? 0 : 1;
|
||||
if (aGlobal !== bGlobal) {
|
||||
return aGlobal - bGlobal;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
})();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await discoveryPromise;
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
value,
|
||||
});
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
discoveryCache.delete(cacheKey);
|
||||
}
|
||||
if (!hasLoggedBedrockError) {
|
||||
hasLoggedBedrockError = true;
|
||||
log.warn("Failed to discover Bedrock models", {
|
||||
error: formatErrorMessage(error),
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveImplicitBedrockProvider(params: {
|
||||
config?: { models?: { bedrockDiscovery?: BedrockDiscoveryConfig } };
|
||||
pluginConfig?: { discovery?: BedrockDiscoveryConfig };
|
||||
env?: NodeJS.ProcessEnv;
|
||||
clientFactory?: (region: string) => BedrockClient;
|
||||
}): Promise<ModelProviderConfig | null> {
|
||||
const env = params.env ?? process.env;
|
||||
const discoveryConfig = {
|
||||
...params.config?.models?.bedrockDiscovery,
|
||||
...params.pluginConfig?.discovery,
|
||||
};
|
||||
const enabled = discoveryConfig?.enabled;
|
||||
const hasAwsCreds = resolveAwsSdkEnvVarName(env) !== undefined;
|
||||
if (enabled === false) {
|
||||
return null;
|
||||
}
|
||||
if (enabled !== true && !hasAwsCreds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1";
|
||||
const models = await discoverBedrockModels({
|
||||
region,
|
||||
config: discoveryConfig,
|
||||
clientFactory: params.clientFactory,
|
||||
});
|
||||
if (models.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `https://bedrock-runtime.${region}.amazonaws.com`,
|
||||
api: "bedrock-converse-stream",
|
||||
auth: "aws-sdk",
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeImplicitBedrockProvider(params: {
|
||||
existing: ModelProviderConfig | undefined;
|
||||
implicit: ModelProviderConfig;
|
||||
}): ModelProviderConfig {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
}
|
||||
return {
|
||||
...implicit,
|
||||
...existing,
|
||||
models:
|
||||
Array.isArray(existing.models) && existing.models.length > 0
|
||||
? existing.models
|
||||
: implicit.models,
|
||||
};
|
||||
}
|
||||
401
openclaw/extensions/amazon-bedrock/embedding-provider.ts
Normal file
401
openclaw/extensions/amazon-bedrock/embedding-provider.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import {
|
||||
debugEmbeddingsLog,
|
||||
sanitizeAndNormalizeEmbedding,
|
||||
type MemoryEmbeddingProvider,
|
||||
type MemoryEmbeddingProviderCreateOptions,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BedrockEmbeddingClient = {
|
||||
region: string;
|
||||
model: string;
|
||||
dimensions?: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_BEDROCK_EMBEDDING_MODEL = "amazon.titan-embed-text-v2:0";
|
||||
|
||||
/** Request/response format family — each has a different API shape. */
|
||||
type Family = "titan-v1" | "titan-v2" | "cohere-v3" | "cohere-v4" | "nova" | "twelvelabs";
|
||||
|
||||
interface ModelSpec {
|
||||
maxTokens: number;
|
||||
dims: number;
|
||||
validDims?: number[];
|
||||
family: Family;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODELS: Record<string, ModelSpec> = {
|
||||
"amazon.titan-embed-text-v2:0": {
|
||||
maxTokens: 8192,
|
||||
dims: 1024,
|
||||
validDims: [256, 512, 1024],
|
||||
family: "titan-v2",
|
||||
},
|
||||
"amazon.titan-embed-text-v1": { maxTokens: 8000, dims: 1536, family: "titan-v1" },
|
||||
"amazon.titan-embed-g1-text-02": { maxTokens: 8000, dims: 1536, family: "titan-v1" },
|
||||
"amazon.titan-embed-image-v1": { maxTokens: 128, dims: 1024, family: "titan-v1" },
|
||||
"cohere.embed-english-v3": { maxTokens: 512, dims: 1024, family: "cohere-v3" },
|
||||
"cohere.embed-multilingual-v3": { maxTokens: 512, dims: 1024, family: "cohere-v3" },
|
||||
"cohere.embed-v4:0": {
|
||||
maxTokens: 128000,
|
||||
dims: 1536,
|
||||
validDims: [256, 384, 512, 768, 1024, 1536],
|
||||
family: "cohere-v4",
|
||||
},
|
||||
"amazon.nova-2-multimodal-embeddings-v1:0": {
|
||||
maxTokens: 8192,
|
||||
dims: 1024,
|
||||
validDims: [256, 384, 1024, 3072],
|
||||
family: "nova",
|
||||
},
|
||||
"twelvelabs.marengo-embed-2-7-v1:0": { maxTokens: 512, dims: 1024, family: "twelvelabs" },
|
||||
"twelvelabs.marengo-embed-3-0-v1:0": { maxTokens: 512, dims: 512, family: "twelvelabs" },
|
||||
};
|
||||
|
||||
/** Resolve spec, stripping throughput suffixes like `:2:8k` or `:0:512`. */
|
||||
function resolveSpec(modelId: string): ModelSpec | undefined {
|
||||
if (MODELS[modelId]) {
|
||||
return MODELS[modelId];
|
||||
}
|
||||
const parts = modelId.split(":");
|
||||
for (let i = parts.length - 1; i >= 1; i--) {
|
||||
const spec = MODELS[parts.slice(0, i).join(":")];
|
||||
if (spec) {
|
||||
return spec;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Infer family from model ID prefix when not in catalog. */
|
||||
function inferFamily(modelId: string): Family {
|
||||
const id = normalizeLowercaseStringOrEmpty(modelId);
|
||||
if (id.startsWith("amazon.titan-embed-text-v2")) {
|
||||
return "titan-v2";
|
||||
}
|
||||
if (id.startsWith("amazon.titan-embed")) {
|
||||
return "titan-v1";
|
||||
}
|
||||
if (id.startsWith("amazon.nova")) {
|
||||
return "nova";
|
||||
}
|
||||
if (id.startsWith("cohere.embed-v4")) {
|
||||
return "cohere-v4";
|
||||
}
|
||||
if (id.startsWith("cohere.embed")) {
|
||||
return "cohere-v3";
|
||||
}
|
||||
if (id.startsWith("twelvelabs.")) {
|
||||
return "twelvelabs";
|
||||
}
|
||||
return "titan-v1"; // safest default — simplest request format
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AWS SDK lazy loader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SdkClient = import("@aws-sdk/client-bedrock-runtime").BedrockRuntimeClient;
|
||||
type SdkCommand = import("@aws-sdk/client-bedrock-runtime").InvokeModelCommand;
|
||||
|
||||
interface AwsSdk {
|
||||
BedrockRuntimeClient: new (config: { region: string }) => SdkClient;
|
||||
InvokeModelCommand: new (input: {
|
||||
modelId: string;
|
||||
body: string;
|
||||
contentType: string;
|
||||
accept: string;
|
||||
}) => SdkCommand;
|
||||
}
|
||||
|
||||
interface AwsCredentialProviderSdk {
|
||||
defaultProvider: (init?: { timeout?: number; maxRetries?: number }) => () => Promise<{
|
||||
accessKeyId?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
let sdkCache: AwsSdk | null = null;
|
||||
let credentialProviderSdkCache: AwsCredentialProviderSdk | null | undefined;
|
||||
|
||||
async function loadSdk(): Promise<AwsSdk> {
|
||||
if (sdkCache) {
|
||||
return sdkCache;
|
||||
}
|
||||
try {
|
||||
sdkCache = (await import("@aws-sdk/client-bedrock-runtime")) as unknown as AwsSdk;
|
||||
return sdkCache;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"No API key found for provider bedrock: @aws-sdk/client-bedrock-runtime is not installed. " +
|
||||
"Install it with: npm install @aws-sdk/client-bedrock-runtime",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCredentialProviderSdk(): Promise<AwsCredentialProviderSdk | null> {
|
||||
if (credentialProviderSdkCache !== undefined) {
|
||||
return credentialProviderSdkCache;
|
||||
}
|
||||
try {
|
||||
credentialProviderSdkCache =
|
||||
(await import("@aws-sdk/credential-provider-node")) as unknown as AwsCredentialProviderSdk;
|
||||
} catch {
|
||||
credentialProviderSdkCache = null;
|
||||
}
|
||||
return credentialProviderSdkCache;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MODEL_PREFIX_RE = /^(?:bedrock|amazon-bedrock|aws)\//;
|
||||
const REGION_RE = /bedrock-runtime\.([a-z0-9-]+)\./;
|
||||
|
||||
export function normalizeBedrockEmbeddingModel(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
return trimmed ? trimmed.replace(MODEL_PREFIX_RE, "") : DEFAULT_BEDROCK_EMBEDDING_MODEL;
|
||||
}
|
||||
|
||||
function regionFromUrl(url: string | undefined): string | undefined {
|
||||
return url?.trim() ? REGION_RE.exec(url)?.[1] : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildBody(family: Family, text: string, dims?: number): string {
|
||||
switch (family) {
|
||||
case "titan-v2": {
|
||||
const b: Record<string, unknown> = { inputText: text };
|
||||
if (dims != null) {
|
||||
b.dimensions = dims;
|
||||
b.normalize = true;
|
||||
}
|
||||
return JSON.stringify(b);
|
||||
}
|
||||
case "titan-v1":
|
||||
return JSON.stringify({ inputText: text });
|
||||
case "nova":
|
||||
return JSON.stringify({
|
||||
taskType: "SINGLE_EMBEDDING",
|
||||
singleEmbeddingParams: {
|
||||
embeddingPurpose: "GENERIC_INDEX",
|
||||
embeddingDimension: dims ?? 1024,
|
||||
text: { truncationMode: "END", value: text },
|
||||
},
|
||||
});
|
||||
case "twelvelabs":
|
||||
return JSON.stringify({ inputType: "text", text: { inputText: text } });
|
||||
default:
|
||||
return JSON.stringify({ inputText: text });
|
||||
}
|
||||
}
|
||||
|
||||
function buildCohereBody(
|
||||
family: Family,
|
||||
texts: string[],
|
||||
inputType: "search_query" | "search_document",
|
||||
dims?: number,
|
||||
): string {
|
||||
const body: Record<string, unknown> = { texts, input_type: inputType, truncate: "END" };
|
||||
if (family === "cohere-v4") {
|
||||
body.embedding_types = ["float"];
|
||||
if (dims != null) {
|
||||
body.output_dimension = dims;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response parsers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseSingle(family: Family, raw: string): number[] {
|
||||
const data = JSON.parse(raw);
|
||||
switch (family) {
|
||||
case "nova":
|
||||
return data.embeddings?.[0]?.embedding ?? [];
|
||||
case "twelvelabs": {
|
||||
if (Array.isArray(data.data)) {
|
||||
return data.data[0]?.embedding ?? [];
|
||||
}
|
||||
if (Array.isArray(data.data?.embedding)) {
|
||||
return data.data.embedding;
|
||||
}
|
||||
return data.embedding ?? [];
|
||||
}
|
||||
default:
|
||||
return data.embedding ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseCohereBatch(family: Family, raw: string): number[][] {
|
||||
const data = JSON.parse(raw);
|
||||
const embeddings = data.embeddings;
|
||||
if (!embeddings) {
|
||||
return [];
|
||||
}
|
||||
if (family === "cohere-v4" && !Array.isArray(embeddings)) {
|
||||
return embeddings.float ?? [];
|
||||
}
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createBedrockEmbeddingProvider(
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): Promise<{ provider: MemoryEmbeddingProvider; client: BedrockEmbeddingClient }> {
|
||||
const client = resolveBedrockEmbeddingClient(options);
|
||||
const { BedrockRuntimeClient, InvokeModelCommand } = await loadSdk();
|
||||
const sdk = new BedrockRuntimeClient({ region: client.region });
|
||||
const spec = resolveSpec(client.model);
|
||||
const family = spec?.family ?? inferFamily(client.model);
|
||||
|
||||
debugEmbeddingsLog("memory embeddings: bedrock client", {
|
||||
region: client.region,
|
||||
model: client.model,
|
||||
dimensions: client.dimensions,
|
||||
family,
|
||||
});
|
||||
|
||||
const invoke = async (body: string): Promise<string> => {
|
||||
const res = await sdk.send(
|
||||
new InvokeModelCommand({
|
||||
modelId: client.model,
|
||||
body,
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
}),
|
||||
);
|
||||
return new TextDecoder().decode(res.body);
|
||||
};
|
||||
|
||||
const isCohere = family === "cohere-v3" || family === "cohere-v4";
|
||||
|
||||
const embedSingle = async (text: string): Promise<number[]> => {
|
||||
const raw = await invoke(buildBody(family, text, client.dimensions));
|
||||
return sanitizeAndNormalizeEmbedding(parseSingle(family, raw));
|
||||
};
|
||||
|
||||
const embedCohere = async (
|
||||
texts: string[],
|
||||
inputType: "search_query" | "search_document",
|
||||
): Promise<number[][]> => {
|
||||
const raw = await invoke(buildCohereBody(family, texts, inputType, client.dimensions));
|
||||
return parseCohereBatch(family, raw).map((e) => sanitizeAndNormalizeEmbedding(e));
|
||||
};
|
||||
|
||||
const embedQuery = async (text: string): Promise<number[]> => {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return (await embedCohere([text], "search_query"))[0] ?? [];
|
||||
}
|
||||
return embedSingle(text);
|
||||
};
|
||||
|
||||
const embedBatch = async (texts: string[]): Promise<number[][]> => {
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (isCohere) {
|
||||
return embedCohere(texts, "search_document");
|
||||
}
|
||||
return Promise.all(texts.map((t) => (t.trim() ? embedSingle(t) : Promise.resolve([]))));
|
||||
};
|
||||
|
||||
return {
|
||||
provider: {
|
||||
id: "bedrock",
|
||||
model: client.model,
|
||||
maxInputTokens: spec?.maxTokens,
|
||||
embedQuery,
|
||||
embedBatch,
|
||||
},
|
||||
client,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function resolveBedrockEmbeddingClient(
|
||||
options: MemoryEmbeddingProviderCreateOptions,
|
||||
): BedrockEmbeddingClient {
|
||||
const model = normalizeBedrockEmbeddingModel(options.model);
|
||||
const spec = resolveSpec(model);
|
||||
const providerConfig = options.config.models?.providers?.["amazon-bedrock"];
|
||||
|
||||
const region =
|
||||
regionFromUrl(options.remote?.baseUrl) ??
|
||||
regionFromUrl(providerConfig?.baseUrl) ??
|
||||
process.env.AWS_REGION ??
|
||||
process.env.AWS_DEFAULT_REGION ??
|
||||
"us-east-1";
|
||||
|
||||
let dimensions: number | undefined;
|
||||
if (options.outputDimensionality != null) {
|
||||
if (spec?.validDims && !spec.validDims.includes(options.outputDimensionality)) {
|
||||
throw new Error(
|
||||
`Invalid dimensions ${options.outputDimensionality} for ${model}. Valid values: ${spec.validDims.join(", ")}`,
|
||||
);
|
||||
}
|
||||
dimensions = options.outputDimensionality;
|
||||
} else {
|
||||
dimensions = spec?.dims;
|
||||
}
|
||||
|
||||
return { region, model, dimensions };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CREDENTIAL_ENV_VARS = [
|
||||
"AWS_PROFILE",
|
||||
"AWS_BEARER_TOKEN_BEDROCK",
|
||||
"AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
|
||||
"AWS_CONTAINER_CREDENTIALS_FULL_URI",
|
||||
"AWS_EC2_METADATA_SERVICE_ENDPOINT",
|
||||
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
||||
"AWS_ROLE_ARN",
|
||||
] as const;
|
||||
|
||||
export async function hasAwsCredentials(env: NodeJS.ProcessEnv = process.env): Promise<boolean> {
|
||||
if (env.AWS_ACCESS_KEY_ID?.trim() && env.AWS_SECRET_ACCESS_KEY?.trim()) {
|
||||
return true;
|
||||
}
|
||||
if (CREDENTIAL_ENV_VARS.some((k) => env[k]?.trim())) {
|
||||
return true;
|
||||
}
|
||||
const credentialProviderSdk = await loadCredentialProviderSdk();
|
||||
if (!credentialProviderSdk) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const credentials = await credentialProviderSdk.defaultProvider({
|
||||
timeout: 1000,
|
||||
maxRetries: 0,
|
||||
})();
|
||||
return typeof credentials.accessKeyId === "string" && credentials.accessKeyId.trim().length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
300
openclaw/extensions/amazon-bedrock/index.test.ts
Normal file
300
openclaw/extensions/amazon-bedrock/index.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../src/config/config.js";
|
||||
import { buildPluginApi } from "../../src/plugins/api-builder.js";
|
||||
import type { PluginRuntime } from "../../src/plugins/runtime/types.js";
|
||||
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
|
||||
import amazonBedrockPlugin from "./index.js";
|
||||
|
||||
type RegisteredProviderPlugin = Awaited<ReturnType<typeof registerSingleProviderPlugin>>;
|
||||
|
||||
/** Register the amazon-bedrock plugin with an optional pluginConfig override. */
|
||||
async function registerWithConfig(
|
||||
pluginConfig?: Record<string, unknown>,
|
||||
): Promise<RegisteredProviderPlugin> {
|
||||
const providers: RegisteredProviderPlugin[] = [];
|
||||
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
const api = buildPluginApi({
|
||||
id: "amazon-bedrock",
|
||||
name: "Amazon Bedrock Provider",
|
||||
source: "test",
|
||||
registrationMode: "full",
|
||||
config: {} as OpenClawConfig,
|
||||
pluginConfig,
|
||||
runtime: {} as PluginRuntime,
|
||||
logger: noopLogger,
|
||||
resolvePath: (input) => input,
|
||||
handlers: {
|
||||
registerProvider(provider: RegisteredProviderPlugin) {
|
||||
providers.push(provider);
|
||||
},
|
||||
},
|
||||
});
|
||||
await amazonBedrockPlugin.register(api);
|
||||
const provider = providers[0];
|
||||
if (!provider) {
|
||||
throw new Error("provider registration missing");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** Spy streamFn that returns the options it receives. */
|
||||
const spyStreamFn = (_model: unknown, _context: unknown, options: Record<string, unknown>) =>
|
||||
options;
|
||||
|
||||
const ANTHROPIC_MODEL = "us.anthropic.claude-sonnet-4-6-v1";
|
||||
const NON_ANTHROPIC_MODEL = "amazon.nova-micro-v1:0";
|
||||
|
||||
const MODEL_DESCRIPTOR = {
|
||||
api: "openai-completions",
|
||||
provider: "amazon-bedrock",
|
||||
id: NON_ANTHROPIC_MODEL,
|
||||
} as never;
|
||||
|
||||
const ANTHROPIC_MODEL_DESCRIPTOR = {
|
||||
api: "openai-completions",
|
||||
provider: "amazon-bedrock",
|
||||
id: ANTHROPIC_MODEL,
|
||||
} as never;
|
||||
|
||||
/**
|
||||
* Call wrapStreamFn and then invoke the returned stream function, capturing
|
||||
* the payload via the onPayload hook that streamWithPayloadPatch installs.
|
||||
*/
|
||||
function callWrappedStream(
|
||||
provider: RegisteredProviderPlugin,
|
||||
modelId: string,
|
||||
modelDescriptor: never,
|
||||
): Record<string, unknown> {
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "amazon-bedrock",
|
||||
modelId,
|
||||
streamFn: spyStreamFn,
|
||||
} as never);
|
||||
|
||||
// The wrapped stream returns the options object (from spyStreamFn).
|
||||
// For guardrail-wrapped streams, streamWithPayloadPatch intercepts onPayload,
|
||||
// so we need to invoke onPayload on the returned options to trigger the patch.
|
||||
const result = wrapped?.(modelDescriptor, { messages: [] } as never, {}) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// If onPayload was installed by streamWithPayloadPatch, call it to apply the patch.
|
||||
if (typeof result?.onPayload === "function") {
|
||||
const payload: Record<string, unknown> = {};
|
||||
(result.onPayload as (p: Record<string, unknown>) => void)(payload);
|
||||
return { ...result, _capturedPayload: payload };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("amazon-bedrock provider plugin", () => {
|
||||
it("marks Claude 4.6 Bedrock models as adaptive by default", async () => {
|
||||
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
|
||||
|
||||
expect(
|
||||
provider.resolveDefaultThinkingLevel?.({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "us.anthropic.claude-opus-4-6-v1",
|
||||
} as never),
|
||||
).toBe("adaptive");
|
||||
expect(
|
||||
provider.resolveDefaultThinkingLevel?.({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "amazon.nova-micro-v1:0",
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("owns Anthropic-style replay policy for Claude Bedrock models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
|
||||
|
||||
expect(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "amazon-bedrock",
|
||||
modelApi: "bedrock-converse-stream",
|
||||
modelId: ANTHROPIC_MODEL,
|
||||
} as never),
|
||||
).toEqual({
|
||||
sanitizeMode: "full",
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
preserveSignatures: true,
|
||||
repairToolUseResultPairing: true,
|
||||
validateAnthropicTurns: true,
|
||||
allowSyntheticToolResults: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("disables prompt caching for non-Anthropic Bedrock models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "amazon.nova-micro-v1:0",
|
||||
streamFn: (_model: unknown, _context: unknown, options: Record<string, unknown>) => options,
|
||||
} as never);
|
||||
|
||||
expect(
|
||||
wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "amazon-bedrock",
|
||||
id: "amazon.nova-micro-v1:0",
|
||||
} as never,
|
||||
{ messages: [] } as never,
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
cacheRetention: "none",
|
||||
});
|
||||
});
|
||||
|
||||
describe("guardrail config schema", () => {
|
||||
it("defines discovery and guardrail objects with the expected shape", () => {
|
||||
const pluginJson = JSON.parse(
|
||||
readFileSync(resolve(import.meta.dirname, "openclaw.plugin.json"), "utf-8"),
|
||||
);
|
||||
const discovery = pluginJson.configSchema?.properties?.discovery;
|
||||
const guardrail = pluginJson.configSchema?.properties?.guardrail;
|
||||
|
||||
expect(discovery).toBeDefined();
|
||||
expect(discovery.type).toBe("object");
|
||||
expect(discovery.additionalProperties).toBe(false);
|
||||
expect(discovery.properties.enabled).toEqual({ type: "boolean" });
|
||||
expect(discovery.properties.region).toEqual({ type: "string" });
|
||||
expect(discovery.properties.providerFilter).toEqual({
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
});
|
||||
expect(discovery.properties.refreshInterval).toEqual({
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
});
|
||||
expect(discovery.properties.defaultContextWindow).toEqual({
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
});
|
||||
expect(discovery.properties.defaultMaxTokens).toEqual({
|
||||
type: "integer",
|
||||
minimum: 1,
|
||||
});
|
||||
|
||||
expect(guardrail).toBeDefined();
|
||||
expect(guardrail.type).toBe("object");
|
||||
expect(guardrail.additionalProperties).toBe(false);
|
||||
|
||||
// Required fields
|
||||
expect(guardrail.required).toEqual(["guardrailIdentifier", "guardrailVersion"]);
|
||||
|
||||
// Property types
|
||||
expect(guardrail.properties.guardrailIdentifier).toEqual({ type: "string" });
|
||||
expect(guardrail.properties.guardrailVersion).toEqual({ type: "string" });
|
||||
|
||||
// Enum constraints
|
||||
expect(guardrail.properties.streamProcessingMode).toEqual({
|
||||
type: "string",
|
||||
enum: ["sync", "async"],
|
||||
});
|
||||
expect(guardrail.properties.trace).toEqual({
|
||||
type: "string",
|
||||
enum: ["enabled", "disabled", "enabled_full"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("guardrail payload injection", () => {
|
||||
it("does not inject guardrailConfig when guardrail is absent from plugin config", async () => {
|
||||
const provider = await registerWithConfig(undefined);
|
||||
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
|
||||
|
||||
expect(result).not.toHaveProperty("_capturedPayload");
|
||||
// The onPayload hook should not exist when no guardrail is configured
|
||||
expect(result).toMatchObject({ cacheRetention: "none" });
|
||||
});
|
||||
|
||||
it("injects all four fields when guardrail config includes optional fields", async () => {
|
||||
const provider = await registerWithConfig({
|
||||
guardrail: {
|
||||
guardrailIdentifier: "my-guardrail-id",
|
||||
guardrailVersion: "1",
|
||||
streamProcessingMode: "sync",
|
||||
trace: "enabled",
|
||||
},
|
||||
});
|
||||
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
|
||||
|
||||
expect(result._capturedPayload).toEqual({
|
||||
guardrailConfig: {
|
||||
guardrailIdentifier: "my-guardrail-id",
|
||||
guardrailVersion: "1",
|
||||
streamProcessingMode: "sync",
|
||||
trace: "enabled",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("injects only required fields when optional fields are omitted", async () => {
|
||||
const provider = await registerWithConfig({
|
||||
guardrail: {
|
||||
guardrailIdentifier: "abc123",
|
||||
guardrailVersion: "DRAFT",
|
||||
},
|
||||
});
|
||||
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
|
||||
|
||||
expect(result._capturedPayload).toEqual({
|
||||
guardrailConfig: {
|
||||
guardrailIdentifier: "abc123",
|
||||
guardrailVersion: "DRAFT",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("injects guardrailConfig for Anthropic models without cacheRetention: none", async () => {
|
||||
const provider = await registerWithConfig({
|
||||
guardrail: {
|
||||
guardrailIdentifier: "guardrail-anthropic",
|
||||
guardrailVersion: "2",
|
||||
streamProcessingMode: "async",
|
||||
trace: "disabled",
|
||||
},
|
||||
});
|
||||
const result = callWrappedStream(provider, ANTHROPIC_MODEL, ANTHROPIC_MODEL_DESCRIPTOR);
|
||||
|
||||
// Anthropic models should get guardrailConfig
|
||||
expect(result._capturedPayload).toEqual({
|
||||
guardrailConfig: {
|
||||
guardrailIdentifier: "guardrail-anthropic",
|
||||
guardrailVersion: "2",
|
||||
streamProcessingMode: "async",
|
||||
trace: "disabled",
|
||||
},
|
||||
});
|
||||
// Anthropic models should NOT get cacheRetention: "none"
|
||||
expect(result).not.toHaveProperty("cacheRetention", "none");
|
||||
});
|
||||
|
||||
it("injects guardrailConfig for non-Anthropic models with cacheRetention: none", async () => {
|
||||
const provider = await registerWithConfig({
|
||||
guardrail: {
|
||||
guardrailIdentifier: "guardrail-nova",
|
||||
guardrailVersion: "3",
|
||||
},
|
||||
});
|
||||
const result = callWrappedStream(provider, NON_ANTHROPIC_MODEL, MODEL_DESCRIPTOR);
|
||||
|
||||
// Non-Anthropic models should get guardrailConfig
|
||||
expect(result._capturedPayload).toEqual({
|
||||
guardrailConfig: {
|
||||
guardrailIdentifier: "guardrail-nova",
|
||||
guardrailVersion: "3",
|
||||
},
|
||||
});
|
||||
// Non-Anthropic models should also get cacheRetention: "none"
|
||||
expect(result).toMatchObject({ cacheRetention: "none" });
|
||||
});
|
||||
});
|
||||
});
|
||||
11
openclaw/extensions/amazon-bedrock/index.ts
Normal file
11
openclaw/extensions/amazon-bedrock/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { registerAmazonBedrockPlugin } from "./register.sync.runtime.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "amazon-bedrock",
|
||||
name: "Amazon Bedrock Provider",
|
||||
description: "Bundled Amazon Bedrock provider policy plugin",
|
||||
register(api) {
|
||||
registerAmazonBedrockPlugin(api);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
isMissingEmbeddingApiKeyError,
|
||||
type MemoryEmbeddingProviderAdapter,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
createBedrockEmbeddingProvider,
|
||||
DEFAULT_BEDROCK_EMBEDDING_MODEL,
|
||||
} from "./embedding-provider.js";
|
||||
|
||||
export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
id: "bedrock",
|
||||
defaultModel: DEFAULT_BEDROCK_EMBEDDING_MODEL,
|
||||
transport: "remote",
|
||||
authProviderId: "amazon-bedrock",
|
||||
autoSelectPriority: 60,
|
||||
allowExplicitWhenConfiguredAuto: true,
|
||||
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
|
||||
create: async (options) => {
|
||||
const { provider, client } = await createBedrockEmbeddingProvider({
|
||||
...options,
|
||||
provider: "bedrock",
|
||||
fallback: "none",
|
||||
});
|
||||
return {
|
||||
provider,
|
||||
runtime: {
|
||||
id: "bedrock",
|
||||
cacheKeyData: {
|
||||
provider: "bedrock",
|
||||
region: client.region,
|
||||
model: client.model,
|
||||
dimensions: client.dimensions,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
77
openclaw/extensions/amazon-bedrock/openclaw.plugin.json
Normal file
77
openclaw/extensions/amazon-bedrock/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"id": "amazon-bedrock",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["amazon-bedrock"],
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["bedrock"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"discovery": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"region": { "type": "string" },
|
||||
"providerFilter": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"refreshInterval": { "type": "integer", "minimum": 0 },
|
||||
"defaultContextWindow": { "type": "integer", "minimum": 1 },
|
||||
"defaultMaxTokens": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"guardrail": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"guardrailIdentifier": { "type": "string" },
|
||||
"guardrailVersion": { "type": "string" },
|
||||
"streamProcessingMode": { "type": "string", "enum": ["sync", "async"] },
|
||||
"trace": { "type": "string", "enum": ["enabled", "disabled", "enabled_full"] }
|
||||
},
|
||||
"required": ["guardrailIdentifier", "guardrailVersion"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"configContracts": {
|
||||
"compatibilityMigrationPaths": ["models.bedrockDiscovery"]
|
||||
},
|
||||
"uiHints": {
|
||||
"discovery": {
|
||||
"label": "Model Discovery",
|
||||
"help": "Plugin-owned controls for Amazon Bedrock model auto-discovery."
|
||||
},
|
||||
"discovery.enabled": {
|
||||
"label": "Enable Discovery",
|
||||
"help": "When false, OpenClaw keeps the Amazon Bedrock plugin available but skips implicit startup discovery. When true, discovery can run even without AWS auth env markers."
|
||||
},
|
||||
"discovery.region": {
|
||||
"label": "Discovery Region",
|
||||
"help": "AWS region to use for Bedrock model discovery. Defaults to AWS_REGION, AWS_DEFAULT_REGION, then us-east-1."
|
||||
},
|
||||
"discovery.providerFilter": {
|
||||
"label": "Provider Filter",
|
||||
"help": "Optional Bedrock provider-name allowlist for discovery, such as anthropic or amazon."
|
||||
},
|
||||
"discovery.refreshInterval": {
|
||||
"label": "Discovery Refresh Interval (s)",
|
||||
"help": "How long to cache Bedrock discovery results in seconds. Set to 0 to disable caching."
|
||||
},
|
||||
"discovery.defaultContextWindow": {
|
||||
"label": "Default Context Window",
|
||||
"help": "Fallback context window to assign to discovered Bedrock models."
|
||||
},
|
||||
"discovery.defaultMaxTokens": {
|
||||
"label": "Default Max Tokens",
|
||||
"help": "Fallback max output tokens to assign to discovered Bedrock models."
|
||||
},
|
||||
"guardrail": {
|
||||
"label": "Guardrail",
|
||||
"help": "Amazon Bedrock Guardrails settings applied to Bedrock model invocations."
|
||||
}
|
||||
}
|
||||
}
|
||||
23
openclaw/extensions/amazon-bedrock/package.json
Normal file
23
openclaw/extensions/amazon-bedrock/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1032.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1032.0",
|
||||
"@aws-sdk/credential-provider-node": "3.972.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
},
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
197
openclaw/extensions/amazon-bedrock/register.sync.runtime.ts
Normal file
197
openclaw/extensions/amazon-bedrock/register.sync.runtime.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
ANTHROPIC_BY_MODEL_REPLAY_HOOKS,
|
||||
normalizeProviderId,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createBedrockNoCacheWrapper,
|
||||
isAnthropicBedrockModel,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import {
|
||||
mergeImplicitBedrockProvider,
|
||||
resolveBedrockConfigApiKey,
|
||||
resolveImplicitBedrockProvider,
|
||||
} from "./api.js";
|
||||
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
|
||||
|
||||
type GuardrailConfig = {
|
||||
guardrailIdentifier: string;
|
||||
guardrailVersion: string;
|
||||
streamProcessingMode?: "sync" | "async";
|
||||
trace?: "enabled" | "disabled" | "enabled_full";
|
||||
};
|
||||
|
||||
type AmazonBedrockPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
region?: string;
|
||||
providerFilter?: string[];
|
||||
refreshInterval?: number;
|
||||
defaultContextWindow?: number;
|
||||
defaultMaxTokens?: number;
|
||||
};
|
||||
guardrail?: GuardrailConfig;
|
||||
};
|
||||
|
||||
function createGuardrailWrapStreamFn(
|
||||
innerWrapStreamFn: (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined,
|
||||
guardrailConfig: GuardrailConfig,
|
||||
): (ctx: { modelId: string; streamFn?: StreamFn }) => StreamFn | null | undefined {
|
||||
return (ctx) => {
|
||||
const inner = innerWrapStreamFn(ctx);
|
||||
if (!inner) {
|
||||
return inner;
|
||||
}
|
||||
return (model, context, options) => {
|
||||
return streamWithPayloadPatch(inner, model, context, options, (payload) => {
|
||||
const gc: Record<string, unknown> = {
|
||||
guardrailIdentifier: guardrailConfig.guardrailIdentifier,
|
||||
guardrailVersion: guardrailConfig.guardrailVersion,
|
||||
};
|
||||
if (guardrailConfig.streamProcessingMode) {
|
||||
gc.streamProcessingMode = guardrailConfig.streamProcessingMode;
|
||||
}
|
||||
if (guardrailConfig.trace) {
|
||||
gc.trace = guardrailConfig.trace;
|
||||
}
|
||||
payload.guardrailConfig = gc;
|
||||
});
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
|
||||
// Keep registration-local constants inside the function so partial module
|
||||
// initialization during test bootstrap cannot trip TDZ reads.
|
||||
const providerId = "amazon-bedrock";
|
||||
const claude46ModelRe = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||
// Match region from bedrock-runtime (Converse API) URLs.
|
||||
// e.g. https://bedrock-runtime.us-east-1.amazonaws.com
|
||||
const bedrockRegionRe = /bedrock-runtime\.([a-z0-9-]+)\.amazonaws\./;
|
||||
const bedrockContextOverflowPatterns = [
|
||||
/ValidationException.*(?:input is too long|max input token|input token.*exceed)/i,
|
||||
/ValidationException.*(?:exceeds? the (?:maximum|max) (?:number of )?(?:input )?tokens)/i,
|
||||
/ModelStreamErrorException.*(?:Input is too long|too many input tokens)/i,
|
||||
] as const;
|
||||
const anthropicByModelReplayHooks = ANTHROPIC_BY_MODEL_REPLAY_HOOKS;
|
||||
const pluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
|
||||
const guardrail = pluginConfig.guardrail;
|
||||
|
||||
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
|
||||
|
||||
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
|
||||
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);
|
||||
|
||||
const cacheWrapStreamFn =
|
||||
guardrail?.guardrailIdentifier && guardrail?.guardrailVersion
|
||||
? createGuardrailWrapStreamFn(baseWrapStreamFn, guardrail)
|
||||
: baseWrapStreamFn;
|
||||
|
||||
/** Extract the AWS region from a bedrock-runtime baseUrl. */
|
||||
function extractRegionFromBaseUrl(baseUrl: string | undefined): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return bedrockRegionRe.exec(baseUrl)?.[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AWS region for Bedrock API calls.
|
||||
* Provider-specific baseUrl wins over global bedrockDiscovery to avoid signing
|
||||
* with the wrong region when discovery and provider target different regions.
|
||||
*/
|
||||
function resolveBedrockRegion(
|
||||
config:
|
||||
| { models?: { bedrockDiscovery?: { region?: string }; providers?: Record<string, unknown> } }
|
||||
| undefined,
|
||||
): string | undefined {
|
||||
// Try provider-specific baseUrl first.
|
||||
const providers = config?.models?.providers;
|
||||
if (providers) {
|
||||
const exact = (providers[providerId] as { baseUrl?: string } | undefined)?.baseUrl;
|
||||
if (exact) {
|
||||
const region = extractRegionFromBaseUrl(exact);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
// Fall back to alias matches (e.g. "bedrock" instead of "amazon-bedrock").
|
||||
for (const [key, value] of Object.entries(providers)) {
|
||||
if (key === providerId || normalizeProviderId(key) !== providerId) {
|
||||
continue;
|
||||
}
|
||||
const region = extractRegionFromBaseUrl((value as { baseUrl?: string }).baseUrl);
|
||||
if (region) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
return config?.models?.bedrockDiscovery?.region;
|
||||
}
|
||||
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
label: "Amazon Bedrock",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const implicit = await resolveImplicitBedrockProvider({
|
||||
config: ctx.config,
|
||||
pluginConfig,
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!implicit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: mergeImplicitBedrockProvider({
|
||||
existing: ctx.config.models?.providers?.[providerId],
|
||||
implicit,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
|
||||
...anthropicByModelReplayHooks,
|
||||
wrapStreamFn: ({ modelId, config, model, streamFn }) => {
|
||||
// Apply cache + guardrail wrapping.
|
||||
const wrapped = cacheWrapStreamFn({ modelId, streamFn });
|
||||
const region = resolveBedrockRegion(config) ?? extractRegionFromBaseUrl(model?.baseUrl);
|
||||
|
||||
if (!region) {
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
// Wrap to inject the region into every stream call so pi-ai's Bedrock
|
||||
// client connects to the right region for inference profile IDs.
|
||||
const underlying = wrapped ?? streamFn;
|
||||
if (!underlying) {
|
||||
return wrapped;
|
||||
}
|
||||
return (streamModel, context, options) => {
|
||||
// pi-ai's bedrock provider reads `options.region` at runtime but the
|
||||
// StreamFn type does not declare it. Merge via Object.assign to avoid
|
||||
// an unsafe type assertion.
|
||||
const merged = Object.assign({}, options, { region });
|
||||
return underlying(streamModel, context, merged);
|
||||
};
|
||||
},
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
bedrockContextOverflowPatterns.some((pattern) => pattern.test(errorMessage)),
|
||||
classifyFailoverReason: ({ errorMessage }) => {
|
||||
if (/ThrottlingException|Too many concurrent requests/i.test(errorMessage)) {
|
||||
return "rate_limit";
|
||||
}
|
||||
if (/ModelNotReadyException/i.test(errorMessage)) {
|
||||
return "overloaded";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
resolveDefaultThinkingLevel: ({ modelId }) =>
|
||||
claude46ModelRe.test(modelId.trim()) ? "adaptive" : undefined,
|
||||
});
|
||||
}
|
||||
18
openclaw/extensions/amazon-bedrock/setup-api.ts
Normal file
18
openclaw/extensions/amazon-bedrock/setup-api.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { migrateAmazonBedrockLegacyConfig } from "./config-api.js";
|
||||
import { resolveBedrockConfigApiKey } from "./discovery.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "amazon-bedrock",
|
||||
name: "Amazon Bedrock Setup",
|
||||
description: "Lightweight Amazon Bedrock setup hooks",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "amazon-bedrock",
|
||||
label: "Amazon Bedrock",
|
||||
auth: [],
|
||||
resolveConfigApiKey: ({ env }) => resolveBedrockConfigApiKey(env),
|
||||
});
|
||||
api.registerConfigMigration((config) => migrateAmazonBedrockLegacyConfig(config));
|
||||
},
|
||||
});
|
||||
16
openclaw/extensions/amazon-bedrock/tsconfig.json
Normal file
16
openclaw/extensions/amazon-bedrock/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"
|
||||
]
|
||||
}
|
||||
42
openclaw/extensions/anthropic-vertex/api.ts
Normal file
42
openclaw/extensions/anthropic-vertex/api.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export {
|
||||
ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
|
||||
buildAnthropicVertexProvider,
|
||||
} from "./provider-catalog.js";
|
||||
export {
|
||||
hasAnthropicVertexAvailableAuth,
|
||||
hasAnthropicVertexCredentials,
|
||||
resolveAnthropicVertexClientRegion,
|
||||
resolveAnthropicVertexConfigApiKey,
|
||||
resolveAnthropicVertexProjectId,
|
||||
resolveAnthropicVertexRegion,
|
||||
resolveAnthropicVertexRegionFromBaseUrl,
|
||||
} from "./region.js";
|
||||
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
|
||||
import { hasAnthropicVertexAvailableAuth } from "./region.js";
|
||||
|
||||
export function mergeImplicitAnthropicVertexProvider(params: {
|
||||
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
}) {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
}
|
||||
return {
|
||||
...implicit,
|
||||
...existing,
|
||||
models:
|
||||
Array.isArray(existing.models) && existing.models.length > 0
|
||||
? existing.models
|
||||
: implicit.models,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
|
||||
const env = params?.env ?? process.env;
|
||||
if (!hasAnthropicVertexAvailableAuth(env)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildAnthropicVertexProvider({ env });
|
||||
}
|
||||
80
openclaw/extensions/anthropic-vertex/index.test.ts
Normal file
80
openclaw/extensions/anthropic-vertex/index.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
|
||||
import anthropicVertexPlugin from "./index.js";
|
||||
|
||||
describe("anthropic-vertex provider plugin", () => {
|
||||
it("resolves the ADC marker through the provider hook", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
expect(
|
||||
provider.resolveConfigApiKey?.({
|
||||
provider: "anthropic-vertex",
|
||||
env: {
|
||||
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
|
||||
} as NodeJS.ProcessEnv,
|
||||
} as never),
|
||||
).toBe("gcp-vertex-credentials");
|
||||
});
|
||||
|
||||
it("merges the implicit Vertex catalog into explicit provider overrides", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
const result = await provider.catalog?.run({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
"anthropic-vertex": {
|
||||
baseUrl: "https://europe-west4-aiplatform.googleapis.com",
|
||||
headers: { "x-test-header": "1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: {
|
||||
api: "anthropic-messages",
|
||||
apiKey: "gcp-vertex-credentials",
|
||||
baseUrl: "https://europe-west4-aiplatform.googleapis.com",
|
||||
headers: { "x-test-header": "1" },
|
||||
models: [
|
||||
expect.objectContaining({ id: "claude-opus-4-6" }),
|
||||
expect.objectContaining({ id: "claude-sonnet-4-6" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("owns Anthropic-style replay policy", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicVertexPlugin);
|
||||
|
||||
expect(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "anthropic-vertex",
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
42
openclaw/extensions/anthropic-vertex/index.ts
Normal file
42
openclaw/extensions/anthropic-vertex/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { NATIVE_ANTHROPIC_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
mergeImplicitAnthropicVertexProvider,
|
||||
resolveAnthropicVertexConfigApiKey,
|
||||
resolveImplicitAnthropicVertexProvider,
|
||||
} from "./api.js";
|
||||
|
||||
const PROVIDER_ID = "anthropic-vertex";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Anthropic Vertex Provider",
|
||||
description: "Bundled Anthropic Vertex provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Anthropic Vertex",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const implicit = resolveImplicitAnthropicVertexProvider({
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!implicit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: mergeImplicitAnthropicVertexProvider({
|
||||
existing: ctx.config.models?.providers?.[PROVIDER_ID],
|
||||
implicit,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
|
||||
...NATIVE_ANTHROPIC_REPLAY_HOOKS,
|
||||
});
|
||||
},
|
||||
});
|
||||
12
openclaw/extensions/anthropic-vertex/openclaw.plugin.json
Normal file
12
openclaw/extensions/anthropic-vertex/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": "anthropic-vertex",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["anthropic-vertex"],
|
||||
"providerDiscoveryEntry": "./provider-discovery.ts",
|
||||
"nonSecretAuthMarkers": ["gcp-vertex-credentials"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
15
openclaw/extensions/anthropic-vertex/package.json
Normal file
15
openclaw/extensions/anthropic-vertex/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
66
openclaw/extensions/anthropic-vertex/provider-catalog.ts
Normal file
66
openclaw/extensions/anthropic-vertex/provider-catalog.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveAnthropicVertexRegion } from "./region.js";
|
||||
export const ANTHROPIC_VERTEX_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
|
||||
const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000;
|
||||
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
|
||||
|
||||
function buildAnthropicVertexModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ModelDefinitionConfig["input"];
|
||||
cost: ModelDefinitionConfig["cost"];
|
||||
maxTokens: number;
|
||||
}): ModelDefinitionConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: params.reasoning,
|
||||
input: params.input,
|
||||
cost: params.cost,
|
||||
contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: params.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] {
|
||||
return [
|
||||
buildAnthropicVertexModel({
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
maxTokens: 128000,
|
||||
}),
|
||||
buildAnthropicVertexModel({
|
||||
id: ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
|
||||
name: "Claude Sonnet 4.6",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
maxTokens: 128000,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAnthropicVertexProvider(params?: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ModelProviderConfig {
|
||||
const region = resolveAnthropicVertexRegion(params?.env);
|
||||
const baseUrl =
|
||||
normalizeLowercaseStringOrEmpty(region) === "global"
|
||||
? "https://aiplatform.googleapis.com"
|
||||
: `https://${region}-aiplatform.googleapis.com`;
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
api: "anthropic-messages",
|
||||
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
|
||||
models: buildAnthropicVertexCatalog(),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("anthropic-vertex provider discovery entry", () => {
|
||||
it("imports without loading the full plugin entry", async () => {
|
||||
const module = await import("./provider-discovery.js");
|
||||
|
||||
expect(module.default.id).toBe("anthropic-vertex");
|
||||
expect(module.default.catalog.order).toBe("simple");
|
||||
});
|
||||
});
|
||||
215
openclaw/extensions/anthropic-vertex/provider-discovery.ts
Normal file
215
openclaw/extensions/anthropic-vertex/provider-discovery.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const PROVIDER_ID = "anthropic-vertex";
|
||||
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
|
||||
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
|
||||
const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000;
|
||||
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
|
||||
const GCLOUD_DEFAULT_ADC_PATH = join(
|
||||
homedir(),
|
||||
".config",
|
||||
"gcloud",
|
||||
"application_default_credentials.json",
|
||||
);
|
||||
|
||||
type AnthropicVertexProviderPlugin = {
|
||||
id: string;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
auth: [];
|
||||
catalog: {
|
||||
order: "simple";
|
||||
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runAnthropicVertexCatalog>;
|
||||
};
|
||||
resolveConfigApiKey: (params: { env: NodeJS.ProcessEnv }) => string | undefined;
|
||||
};
|
||||
|
||||
type AdcProjectFile = {
|
||||
project_id?: unknown;
|
||||
quota_project_id?: unknown;
|
||||
};
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalString(value)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const region =
|
||||
normalizeOptionalString(env.GOOGLE_CLOUD_LOCATION) ||
|
||||
normalizeOptionalString(env.CLOUD_ML_REGION);
|
||||
|
||||
return region && ANTHROPIC_VERTEX_REGION_RE.test(region)
|
||||
? region
|
||||
: ANTHROPIC_VERTEX_DEFAULT_REGION;
|
||||
}
|
||||
|
||||
function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const explicitMetadataOptIn = normalizeOptionalString(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
|
||||
return (
|
||||
explicitMetadataOptIn === "1" ||
|
||||
normalizeLowercaseStringOrEmpty(explicitMetadataOptIn) === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return platform() === "win32"
|
||||
? join(
|
||||
env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
|
||||
"gcloud",
|
||||
"application_default_credentials.json",
|
||||
)
|
||||
: GCLOUD_DEFAULT_ADC_PATH;
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexAdcCredentialsPathCandidate(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const explicit = normalizeOptionalString(env.GOOGLE_APPLICATION_CREDENTIALS);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (env !== process.env) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveAnthropicVertexDefaultAdcPath(env);
|
||||
}
|
||||
|
||||
function readAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): AdcProjectFile | null {
|
||||
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
|
||||
if (!credentialsPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return hasAnthropicVertexMetadataServerAdc(env) || readAnthropicVertexAdc(env) !== null;
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexConfigApiKey(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
return hasAnthropicVertexAvailableAuth(env) ? GCP_VERTEX_CREDENTIALS_MARKER : undefined;
|
||||
}
|
||||
|
||||
function buildAnthropicVertexModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ModelDefinitionConfig["input"];
|
||||
cost: ModelDefinitionConfig["cost"];
|
||||
maxTokens: number;
|
||||
}): ModelDefinitionConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: params.reasoning,
|
||||
input: params.input,
|
||||
cost: params.cost,
|
||||
contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: params.maxTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }): ModelProviderConfig {
|
||||
const region = resolveAnthropicVertexRegion(params?.env);
|
||||
const baseUrl =
|
||||
normalizeLowercaseStringOrEmpty(region) === "global"
|
||||
? "https://aiplatform.googleapis.com"
|
||||
: `https://${region}-aiplatform.googleapis.com`;
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
api: "anthropic-messages",
|
||||
apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
|
||||
models: [
|
||||
buildAnthropicVertexModel({
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
maxTokens: 128000,
|
||||
}),
|
||||
buildAnthropicVertexModel({
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
maxTokens: 128000,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mergeImplicitAnthropicVertexProvider(params: {
|
||||
existing?: ModelProviderConfig;
|
||||
implicit: ModelProviderConfig;
|
||||
}) {
|
||||
const { existing, implicit } = params;
|
||||
if (!existing) {
|
||||
return implicit;
|
||||
}
|
||||
return {
|
||||
...implicit,
|
||||
...existing,
|
||||
models:
|
||||
Array.isArray(existing.models) && existing.models.length > 0
|
||||
? existing.models
|
||||
: implicit.models,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveImplicitAnthropicVertexProvider(params?: { env?: NodeJS.ProcessEnv }) {
|
||||
const env = params?.env ?? process.env;
|
||||
if (!hasAnthropicVertexAvailableAuth(env)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildAnthropicVertexProvider({ env });
|
||||
}
|
||||
|
||||
async function runAnthropicVertexCatalog(ctx: ProviderCatalogContext) {
|
||||
const implicit = resolveImplicitAnthropicVertexProvider({
|
||||
env: ctx.env,
|
||||
});
|
||||
if (!implicit) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: mergeImplicitAnthropicVertexProvider({
|
||||
existing: ctx.config.models?.providers?.[PROVIDER_ID],
|
||||
implicit,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const anthropicVertexProviderDiscovery: AnthropicVertexProviderPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
label: "Anthropic Vertex",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: runAnthropicVertexCatalog,
|
||||
},
|
||||
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
|
||||
};
|
||||
|
||||
export default anthropicVertexProviderDiscovery;
|
||||
49
openclaw/extensions/anthropic-vertex/region.adc.test.ts
Normal file
49
openclaw/extensions/anthropic-vertex/region.adc.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({
|
||||
existsSyncMock: vi.fn(),
|
||||
readFileSyncMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
existsSyncMock.mockImplementation((pathname) => actual.existsSync(pathname));
|
||||
readFileSyncMock.mockImplementation((pathname, options) =>
|
||||
String(pathname) === "/tmp/vertex-adc.json"
|
||||
? '{"project_id":"vertex-project"}'
|
||||
: actual.readFileSync(pathname, options as never),
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: existsSyncMock,
|
||||
readFileSync: readFileSyncMock,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexProjectId } from "./region.js";
|
||||
|
||||
describe("anthropic-vertex ADC reads", () => {
|
||||
afterEach(() => {
|
||||
existsSyncMock.mockClear();
|
||||
readFileSyncMock.mockClear();
|
||||
});
|
||||
|
||||
it("reads explicit ADC credentials without an existsSync preflight", () => {
|
||||
const env = {
|
||||
GOOGLE_APPLICATION_CREDENTIALS: "/tmp/vertex-adc.json",
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
existsSyncMock.mockClear();
|
||||
readFileSyncMock.mockClear();
|
||||
|
||||
expect(resolveAnthropicVertexProjectId(env)).toBe("vertex-project");
|
||||
expect(hasAnthropicVertexAvailableAuth(env)).toBe(true);
|
||||
expect(existsSyncMock).not.toHaveBeenCalled();
|
||||
expect(readFileSyncMock).toHaveBeenCalledWith("/tmp/vertex-adc.json", "utf8");
|
||||
});
|
||||
});
|
||||
38
openclaw/extensions/anthropic-vertex/region.test.ts
Normal file
38
openclaw/extensions/anthropic-vertex/region.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAnthropicVertexRegion, resolveAnthropicVertexRegionFromBaseUrl } from "./api.js";
|
||||
|
||||
describe("anthropic vertex region helpers", () => {
|
||||
it("accepts well-formed regional env values", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegion({
|
||||
GOOGLE_CLOUD_LOCATION: "us-east1",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("us-east1");
|
||||
});
|
||||
|
||||
it("falls back to the default region for malformed env values", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegion({
|
||||
GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example",
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe("global");
|
||||
});
|
||||
|
||||
it("parses regional Vertex endpoints", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegionFromBaseUrl("https://europe-west4-aiplatform.googleapis.com"),
|
||||
).toBe("europe-west4");
|
||||
});
|
||||
|
||||
it("treats the global Vertex endpoint as global", () => {
|
||||
expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe(
|
||||
"global",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not infer a Vertex region from custom proxy hosts", () => {
|
||||
expect(
|
||||
resolveAnthropicVertexRegionFromBaseUrl("https://proxy.example.com/google/aiplatform"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
140
openclaw/extensions/anthropic-vertex/region.ts
Normal file
140
openclaw/extensions/anthropic-vertex/region.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
|
||||
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
|
||||
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
|
||||
const GCLOUD_DEFAULT_ADC_PATH = join(
|
||||
homedir(),
|
||||
".config",
|
||||
"gcloud",
|
||||
"application_default_credentials.json",
|
||||
);
|
||||
|
||||
type AdcProjectFile = {
|
||||
project_id?: unknown;
|
||||
quota_project_id?: unknown;
|
||||
};
|
||||
|
||||
function normalizeOptionalSecretInput(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const region =
|
||||
normalizeOptionalSecretInput(env.GOOGLE_CLOUD_LOCATION) ||
|
||||
normalizeOptionalSecretInput(env.CLOUD_ML_REGION);
|
||||
|
||||
return region && ANTHROPIC_VERTEX_REGION_RE.test(region)
|
||||
? region
|
||||
: ANTHROPIC_VERTEX_DEFAULT_REGION;
|
||||
}
|
||||
|
||||
export function resolveAnthropicVertexProjectId(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
return (
|
||||
normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_PROJECT_ID) ||
|
||||
normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT) ||
|
||||
normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT_ID) ||
|
||||
resolveAnthropicVertexProjectIdFromAdc(env)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined {
|
||||
const endpoint = resolveProviderEndpoint(baseUrl);
|
||||
return endpoint.endpointClass === "google-vertex" ? endpoint.googleVertexRegion : undefined;
|
||||
}
|
||||
|
||||
export function resolveAnthropicVertexClientRegion(params?: {
|
||||
baseUrl?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
return (
|
||||
resolveAnthropicVertexRegionFromBaseUrl(params?.baseUrl) ||
|
||||
resolveAnthropicVertexRegion(params?.env)
|
||||
);
|
||||
}
|
||||
|
||||
function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
|
||||
return (
|
||||
explicitMetadataOptIn === "1" ||
|
||||
normalizeLowercaseStringOrEmpty(explicitMetadataOptIn) === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return platform() === "win32"
|
||||
? join(
|
||||
env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
|
||||
"gcloud",
|
||||
"application_default_credentials.json",
|
||||
)
|
||||
: GCLOUD_DEFAULT_ADC_PATH;
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexAdcCredentialsPathCandidate(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const explicit = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS);
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
if (env !== process.env) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveAnthropicVertexDefaultAdcPath(env);
|
||||
}
|
||||
|
||||
function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
|
||||
if (!credentialsPath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
readFileSync(credentialsPath, "utf8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnthropicVertexProjectIdFromAdc(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env);
|
||||
if (!credentialsPath) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile;
|
||||
return (
|
||||
normalizeOptionalSecretInput(parsed.project_id) ||
|
||||
normalizeOptionalSecretInput(parsed.quota_project_id)
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAnthropicVertexCredentials(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return hasAnthropicVertexMetadataServerAdc(env) || canReadAnthropicVertexAdc(env);
|
||||
}
|
||||
|
||||
export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return hasAnthropicVertexCredentials(env);
|
||||
}
|
||||
|
||||
export function resolveAnthropicVertexConfigApiKey(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
return hasAnthropicVertexAvailableAuth(env) ? GCP_VERTEX_CREDENTIALS_MARKER : undefined;
|
||||
}
|
||||
16
openclaw/extensions/anthropic-vertex/setup-api.ts
Normal file
16
openclaw/extensions/anthropic-vertex/setup-api.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolveAnthropicVertexConfigApiKey } from "./region.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "anthropic-vertex",
|
||||
name: "Anthropic Vertex Setup",
|
||||
description: "Lightweight Anthropic Vertex setup hooks",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "anthropic-vertex",
|
||||
label: "Anthropic Vertex",
|
||||
auth: [],
|
||||
resolveConfigApiKey: ({ env }) => resolveAnthropicVertexConfigApiKey(env),
|
||||
});
|
||||
},
|
||||
});
|
||||
16
openclaw/extensions/anthropic-vertex/tsconfig.json
Normal file
16
openclaw/extensions/anthropic-vertex/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"
|
||||
]
|
||||
}
|
||||
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";
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue