diff --git a/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml b/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml index 0c536000df0..1276a418a6f 100644 --- a/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml +++ b/.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml @@ -28,7 +28,7 @@ paths: - src/agents/agent-tools.abort.ts - src/agents/agent-tools.before-tool-call*.ts - src/agents/agent-tools.read.ts - - src/agents/agent-tools-parameter-schema.ts + - packages/ai/src/providers/agent-tools-parameter-schema.ts - src/agents/sessions/tools/** - src/agents/embedded-agent-runner/effective-tool-policy.ts - src/agents/embedded-agent-runner/tool-name-allowlist.ts diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 06237d5a93a..eacd9c91b8d 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -170,6 +170,23 @@ jobs: - name: Build run: pnpm build + - name: Pack AI runtime package + id: ai_runtime_tarballs + run: | + set -euo pipefail + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + package_version="$(node -p "require('./packages/ai/package.json').version")" + if [[ "$package_version" != "$PACKAGE_VERSION" ]]; then + echo "packages/ai version ${package_version} does not match openclaw ${PACKAGE_VERSION}." >&2 + exit 1 + fi + ARTIFACT_DIR="$RUNNER_TEMP/openclaw-ai-runtime-packages" + rm -rf "$ARTIFACT_DIR" + mkdir -p "$ARTIFACT_DIR" + pnpm --dir packages/ai pack --pack-destination "$ARTIFACT_DIR" + (cd "$ARTIFACT_DIR" && sha256sum ./*.tgz > SHA256SUMS) + echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" + - name: Build Control UI run: pnpm ui:build @@ -233,10 +250,11 @@ jobs: RELEASE_REF: ${{ inputs.tag }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }} + AI_RUNTIME_TARBALL_DIR: ${{ steps.ai_runtime_tarballs.outputs.dir }} run: | set -euo pipefail PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt" - npm pack --json 2>&1 | tee "$PACK_OUTPUT" + pnpm pack --json 2>&1 | tee "$PACK_OUTPUT" PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); @@ -244,19 +262,23 @@ jobs: function resolveTarballFileName(value) { const fileName = typeof value === "string" ? value.trim() : ""; + const resolved = path.resolve(fileName); if ( !fileName.endsWith(".tgz") || fileName.includes("\0") || fileName !== path.basename(fileName) || - fileName !== path.win32.basename(fileName) + fileName !== path.win32.basename(fileName) || + path.dirname(resolved) !== process.cwd() ) { console.error(`npm pack reported unsafe tarball filename ${JSON.stringify(fileName)}.`); process.exit(1); } - return fileName; + return path.basename(resolved); } - function arrayEndFrom(start) { + function jsonEndFrom(start) { + const opening = input[start]; + const closing = opening === "[" ? "]" : "}"; let depth = 0; let inString = false; let escape = false; @@ -274,9 +296,9 @@ jobs: } if (char === "\"") { inString = true; - } else if (char === "[") { + } else if (char === opening) { depth += 1; - } else if (char === "]") { + } else if (char === closing) { depth -= 1; if (depth === 0) { return i + 1; @@ -286,15 +308,15 @@ jobs: return -1; } - for (const match of input.matchAll(/\[/g)) { + for (const match of input.matchAll(/[\[{]/g)) { const start = match.index; - const end = arrayEndFrom(start); + const end = jsonEndFrom(start); if (end === -1) { continue; } try { const parsed = JSON.parse(input.slice(start, end)); - const first = Array.isArray(parsed) ? parsed[0] : null; + const first = Array.isArray(parsed) ? parsed[0] : parsed; if (first && Object.prototype.hasOwnProperty.call(first, "filename")) { process.stdout.write(resolveTarballFileName(first.filename)); process.exit(0); @@ -326,6 +348,8 @@ jobs: rm -rf "$ARTIFACT_DIR" mkdir -p "$ARTIFACT_DIR" cp "$PACK_PATH" "$ARTIFACT_DIR/" + cp "$AI_RUNTIME_TARBALL_DIR"/*.tgz "$ARTIFACT_DIR/" + cp "$AI_RUNTIME_TARBALL_DIR/SHA256SUMS" "$ARTIFACT_DIR/ai-runtime-SHA256SUMS" cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence" printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt" printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt" @@ -358,14 +382,16 @@ jobs: PREFLIGHT_ARTIFACT_DIR: ${{ steps.packed_tarball.outputs.dir }} run: | set -euo pipefail - TARBALL_PATH="$(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f -name '*.tgz' -print | sort | tail -n 1)" + TARBALL_PATH="$(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f -name 'openclaw-[0-9]*.tgz' -print | sort | tail -n 1)" if [[ -z "$TARBALL_PATH" ]]; then echo "Prepared preflight tarball not found." >&2 ls -la "$PREFLIGHT_ARTIFACT_DIR" >&2 || true exit 1 fi PACKAGE_VERSION="$(node -p "require('./package.json').version")" - node --import tsx scripts/openclaw-npm-prepublish-verify.ts "$TARBALL_PATH" "$PACKAGE_VERSION" + AI_TARBALL="$(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f -name 'openclaw-ai-*.tgz' -print | head -n 1)" + node --import tsx scripts/openclaw-npm-prepublish-verify.ts \ + "$TARBALL_PATH" "$PACKAGE_VERSION" "$AI_TARBALL" - name: Upload dependency release evidence uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -716,6 +742,11 @@ jobs: echo "Prepared preflight tarball digest mismatch." >&2 exit 1 fi + if [[ ! -f preflight-tarball/ai-runtime-SHA256SUMS ]]; then + echo "Prepared AI runtime dependency checksums are missing." >&2 + exit 1 + fi + (cd preflight-tarball && sha256sum -c ai-runtime-SHA256SUMS) echo "tarball_name=$ARTIFACT_TARBALL_NAME" >> "$GITHUB_OUTPUT" echo "tarball_sha256=$ARTIFACT_TARBALL_SHA256" >> "$GITHUB_OUTPUT" echo "tarball_path=$ARTIFACT_TARBALL_PATH" >> "$GITHUB_OUTPUT" @@ -779,6 +810,22 @@ jobs: if [[ -n "${publish_target}" ]]; then publish_target="./${publish_target}" fi + publish_if_missing() { + local package_name="$1" + local tarball_path="$2" + local package_version + if [[ -z "$tarball_path" || ! -f "$tarball_path" ]]; then + echo "Prepared tarball for ${package_name} was not found." >&2 + exit 1 + fi + package_version="$(node -p "require('./package.json').version")" + if npm view "${package_name}@${package_version}" version >/dev/null 2>&1; then + echo "${package_name}@${package_version} is already published; reusing it." + return 0 + fi + bash scripts/openclaw-npm-publish.sh --publish "./${tarball_path}" + } + publish_if_missing "@openclaw/ai" "$(find preflight-tarball -type f -name 'openclaw-ai-*.tgz' -print | head -n 1)" bash scripts/openclaw-npm-publish.sh --publish "${publish_target}" - name: Verify extended-stable registry readback diff --git a/config/knip.config.ts b/config/knip.config.ts index 46d89d0b5f7..08015b1f670 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -138,9 +138,13 @@ const config = { entry: rootEntries, ignoreDependencies: [ "@openclaw/*", + // Docker packaging stages @openclaw/ai without nested dependencies after + // verifying the root owns its exact runtime dependency versions. + "@mistralai/mistralai", "cross-spawn", "file-type", "playwright-core", + "partial-json", "sqlite-vec", "tree-sitter-bash", ...rootBundledPluginRuntimeDependencies, @@ -164,6 +168,19 @@ const config = { ignoreDependencies: ["three"], project: ["src/**/*.{ts,tsx}!"], }, + "packages/ai": { + // Mirror the published export map so knip sees every dist entry point. + entry: [ + "src/index.ts!", + "src/providers.ts!", + "src/types.ts!", + "src/validation.ts!", + "src/utils/diagnostics.ts!", + "src/utils/event-stream.ts!", + "src/internal/*.ts!", + ], + project: ["src/**/*.ts!"], + }, "packages/sdk": { entry: ["src/index.ts!"], project: ["src/**/*.ts!"], diff --git a/docs/docs.json b/docs/docs.json index 4053b676165..99d73a99292 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1015,7 +1015,11 @@ "groups": [ { "group": "Overview", - "pages": ["index", "start/showcase", "concepts/features"] + "pages": [ + "index", + "start/showcase", + "concepts/features" + ] }, { "group": "First steps", @@ -1041,7 +1045,11 @@ "groups": [ { "group": "Install overview", - "pages": ["install/index", "install/installer", "install/node"] + "pages": [ + "install/index", + "install/installer", + "install/node" + ] }, { "group": "Maintenance", @@ -1094,7 +1102,10 @@ }, { "group": "Advanced setup", - "pages": ["start/setup", "openclaw-agent-runtime"] + "pages": [ + "start/setup", + "openclaw-agent-runtime" + ] } ] }, @@ -1103,7 +1114,10 @@ "groups": [ { "group": "Overview", - "pages": ["channels/index", "announcements/bluebubbles-imessage"] + "pages": [ + "channels/index", + "announcements/bluebubbles-imessage" + ] }, { "group": "Mainstream messaging", @@ -1240,7 +1254,9 @@ "groups": [ { "group": "Overview", - "pages": ["tools/index"] + "pages": [ + "tools/index" + ] }, { "group": "Plugins", @@ -1381,7 +1397,11 @@ "groups": [ { "group": "Overview", - "pages": ["clawhub/index", "clawhub/quickstart", "clawhub/how-it-works"] + "pages": [ + "clawhub/index", + "clawhub/quickstart", + "clawhub/how-it-works" + ] }, { "group": "Using ClawHub", @@ -1411,11 +1431,18 @@ "groups": [ { "group": "Overview", - "pages": ["providers/index", "providers/models"] + "pages": [ + "providers/index", + "providers/models" + ] }, { "group": "Concepts and configuration", - "pages": ["concepts/models", "concepts/model-providers", "concepts/model-failover"] + "pages": [ + "concepts/models", + "concepts/model-providers", + "concepts/model-failover" + ] }, { "group": "Providers", @@ -1619,13 +1646,22 @@ }, { "group": "Networking and discovery", - "pages": ["network", "gateway/pairing", "gateway/discovery", "gateway/bonjour"] + "pages": [ + "network", + "gateway/pairing", + "gateway/discovery", + "gateway/bonjour" + ] } ] }, { "group": "Remote access", - "pages": ["gateway/remote", "gateway/remote-gateway-readme", "gateway/tailscale"] + "pages": [ + "gateway/remote", + "gateway/remote-gateway-readme", + "gateway/tailscale" + ] }, { "group": "Security", @@ -1653,13 +1689,23 @@ }, { "group": "Node features", - "pages": ["nodes/talk", "nodes/voicewake", "nodes/location-command"] + "pages": [ + "nodes/talk", + "nodes/voicewake", + "nodes/location-command" + ] } ] }, { "group": "Web interfaces", - "pages": ["web/index", "web/control-ui", "web/dashboard", "web/webchat", "web/tui"] + "pages": [ + "web/index", + "web/control-ui", + "web/dashboard", + "web/webchat", + "web/tui" + ] } ] }, @@ -1732,7 +1778,11 @@ }, { "group": "Configuration", - "pages": ["cli/config", "cli/configure", "cli/webhooks"] + "pages": [ + "cli/config", + "cli/configure", + "cli/webhooks" + ] }, { "group": "Plugins and skills", @@ -1746,7 +1796,11 @@ }, { "group": "Interfaces", - "pages": ["cli/attach", "cli/dashboard", "cli/tui"] + "pages": [ + "cli/attach", + "cli/dashboard", + "cli/tui" + ] }, { "group": "Utility", @@ -1835,6 +1889,7 @@ "reference/wizard", "reference/token-use", "reference/secretref-credential-surface", + "reference/openclaw-ai", "reference/prompt-caching", "reference/api-usage-costs", "reference/transcript-hygiene", @@ -1856,14 +1911,20 @@ }, { "group": "Project", - "pages": ["reference/application-modernization-plan", "reference/credits"] + "pages": [ + "reference/application-modernization-plan", + "reference/credits" + ] }, { "group": "Release and CI", "pages": [ { "group": "Release notes", - "pages": ["releases/index", "releases/2026.6.11"] + "pages": [ + "releases/index", + "releases/2026.6.11" + ] }, "maturity/scorecard", "maturity/taxonomy", @@ -1882,23 +1943,43 @@ "groups": [ { "group": "Start here", - "pages": ["help/index", "help/troubleshooting", "help/debugging"] + "pages": [ + "help/index", + "help/troubleshooting", + "help/debugging" + ] }, { "group": "FAQ", - "pages": ["help/faq", "help/faq-first-run", "help/faq-models"] + "pages": [ + "help/faq", + "help/faq-first-run", + "help/faq-models" + ] }, { "group": "Testing", - "pages": ["help/testing", "help/testing-updates-plugins", "help/testing-live"] + "pages": [ + "help/testing", + "help/testing-updates-plugins", + "help/testing-live" + ] }, { "group": "Diagnostics", - "pages": ["help/environment", "diagnostics/flags", "debug/node-issue"] + "pages": [ + "help/environment", + "diagnostics/flags", + "debug/node-issue" + ] }, { "group": "Community and meta", - "pages": ["start/lore", "start/hubs", "start/docs-directory"] + "pages": [ + "start/lore", + "start/hubs", + "start/docs-directory" + ] } ] } diff --git a/docs/docs_map.md b/docs/docs_map.md index a252f5cf20e..ecd1952ba71 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -8161,6 +8161,14 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H3: Example - H2: Related +## reference/openclaw-ai.md + +- Route: /reference/openclaw-ai +- Headings: + - H2: Quick start + - H2: Design contract + - H2: Subpath exports + ## reference/prompt-caching.md - Route: /reference/prompt-caching diff --git a/docs/gateway/security/shrinkwrap.md b/docs/gateway/security/shrinkwrap.md index 1439546c1ec..ea9a7315cc6 100644 --- a/docs/gateway/security/shrinkwrap.md +++ b/docs/gateway/security/shrinkwrap.md @@ -32,7 +32,7 @@ OpenClaw is a gateway, plugin host, model router, and agent runtime, so a defaul ## Generating and checking -The root `openclaw` npm package and OpenClaw-owned npm plugin packages (for example `@openclaw/discord`) include `npm-shrinkwrap.json` when they publish. Suitable plugin packages can also publish with explicit `bundledDependencies`, carrying their runtime dependency files in the plugin tarball instead of relying only on install-time resolution. +The root `openclaw` npm package, OpenClaw-owned npm plugin packages (for example `@openclaw/discord`), and publishable workspace packages such as [`@openclaw/ai`](/reference/openclaw-ai) include `npm-shrinkwrap.json` when they publish. Workspace dependencies are omitted from the root shrinkwrap because they publish beside the root package; each publishable workspace package pins its own transitive tree instead. Suitable plugin packages can also publish with explicit `bundledDependencies`, carrying their runtime dependency files in the plugin tarball instead of relying only on install-time resolution. ```bash # All shrinkwrap-managed packages (root + publishable plugins) diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 9898b607ac6..0ab244437b2 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -162,7 +162,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c - Run `pnpm check:architecture` before release preflight so the broader import cycle and architecture boundary checks are green outside the faster local gate. - Run `pnpm build && pnpm ui:build` before `pnpm release:check` so the expected `dist/*` release artifacts and Control UI bundle exist for the pack validation step. - Run `pnpm release:prep` after the root version bump and before tagging. It runs every deterministic release generator that commonly drifts after a version/config/API change: plugin versions, npm shrinkwraps, plugin inventory, base config schema, bundled channel config metadata, config docs baseline, plugin SDK exports, and plugin SDK API baseline. `pnpm release:check` re-runs those guards in check mode (plus a plugin SDK surface budget check) and reports every generated drift failure in one pass before running package release checks. -- Plugin version sync updates official plugin package versions and existing `openclaw.compat.pluginApi` floors to the OpenClaw release version by default. Treat that field as the plugin SDK/runtime API floor, not just a copy of the package version: for plugin-only releases that intentionally remain compatible with older OpenClaw hosts, keep the floor at the oldest supported host API and document that choice in the plugin release proof. +- Plugin version sync updates the publishable `@openclaw/ai` runtime package, official plugin package versions, and existing `openclaw.compat.pluginApi` floors to the OpenClaw release version by default. Treat that field as the plugin SDK/runtime API floor, not just a copy of the package version: for plugin-only releases that intentionally remain compatible with older OpenClaw hosts, keep the floor at the oldest supported host API and document that choice in the plugin release proof. - Run the manual `Full Release Validation` workflow before release approval to kick off all pre-release test boxes from one entrypoint. It accepts a branch, tag, or full commit SHA, dispatches manual `CI`, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full runs always include exhaustive live/E2E and Docker release-path soak; `run_release_soak=true` is retained for an explicit beta soak. Package Acceptance provides the canonical package Telegram E2E during candidate validation, avoiding a second concurrent live poller. Provide `release_package_spec` after publishing a beta to reuse the shipped npm package across release checks, Package Acceptance, and package Telegram E2E without rebuilding the release tarball. Provide `npm_telegram_package_spec` only when Telegram should use a different published package from the rest of release validation. Provide `package_acceptance_package_spec` when Package Acceptance should use a different published package from the release package spec. Provide `evidence_package_spec` when the release evidence report should prove that validation matches a published npm package without forcing Telegram E2E. diff --git a/docs/reference/openclaw-ai.md b/docs/reference/openclaw-ai.md new file mode 100644 index 00000000000..bbdd9901068 --- /dev/null +++ b/docs/reference/openclaw-ai.md @@ -0,0 +1,71 @@ +--- +summary: "The @openclaw/ai npm package: reusable model transports, isolated runtimes, and host policy ports" +title: "@openclaw/ai package" +read_when: + - You want to reuse OpenClaw's model transports in another application + - You are changing packages/ai or the AI transport host ports + - You are reviewing what the openclaw release publishes to npm besides the root package +--- + +`@openclaw/ai` is the publishable library form of OpenClaw's model execution +layer: provider-neutral message/tool/stream contracts, validation, diagnostics, +event streams, an isolated runtime registry, and lazy adapters for the eight +built-in API families (Anthropic Messages, OpenAI Completions, OpenAI +Responses, Azure OpenAI Responses, ChatGPT/Codex Responses, Google Generative +AI, Google Vertex, Mistral Conversations). + +It publishes alongside the root `openclaw` package on every release, pinned to +the same version, with its own `npm-shrinkwrap.json` so its transitive +dependency tree is locked at install time. Installing `openclaw` installs the +matching `@openclaw/ai` automatically; library consumers can depend on it +directly without any OpenClaw application code. + +## Quick start + +```js +import { createLlmRuntime } from "@openclaw/ai"; +import { registerBuiltInApiProviders } from "@openclaw/ai/providers"; + +const runtime = createLlmRuntime(); +registerBuiltInApiProviders(runtime.registry); + +const stream = runtime.streamSimple(model, { messages }, { apiKey }); +for await (const event of stream) { + if (event.type === "text_delta") process.stdout.write(event.delta); +} +const result = await stream.result(); +``` + +A runnable version lives in the repository at `examples/ai-chat`. + +## Design contract + +- **Instance-scoped by default.** Importing the package registers nothing + globally. `createApiRegistry()` / `createLlmRuntime()` return isolated + instances; `registerBuiltInApiProviders(registry)` opts one registry into the + built-in transports. Provider SDK modules load lazily on first use. +- **Host policy is injected, not bundled.** Request fetch guarding (for + example SSRF policy), secret redaction of tool-result replay text, OpenAI + strict-tool defaults, and diagnostics logging are `AiTransportHost` ports + configured with `configureAiTransportHost`. The library defaults are inert; + OpenClaw installs its real implementations in its stream facade. +- **One event-stream identity.** `@openclaw/ai/event-stream` is the canonical + `EventStream` constructor shared by OpenClaw core, agent-core, and external + consumers. +- **`internal/*` subpaths are not API.** They exist for the OpenClaw + application itself and carry no semver guarantee. +- Provider ids, credentials, model catalogs, retries, and failover remain + application concerns. OpenClaw layers those around this package; a library + consumer supplies a `Model` object and options directly. + +## Subpath exports + +| Subpath | Contents | +| ---------------- | ------------------------------------------------------------------------------ | +| `.` | Contracts, `createApiRegistry`, `createLlmRuntime`, `configureAiTransportHost` | +| `./providers` | `registerBuiltInApiProviders`, `resetApiProviders` | +| `./types` | Model/message/tool/stream types | +| `./validation` | Tool argument validation | +| `./diagnostics` | Diagnostics contracts | +| `./event-stream` | Shared `EventStream` implementation | +| `./internal/*` | OpenClaw-internal, no semver guarantee | diff --git a/examples/ai-chat/README.md b/examples/ai-chat/README.md new file mode 100644 index 00000000000..1bf019f723b --- /dev/null +++ b/examples/ai-chat/README.md @@ -0,0 +1,29 @@ +# @openclaw/ai example: minimal chat completion + +Smallest possible consumer of the published `@openclaw/ai` package: creates an +isolated runtime, registers the built-in API providers, and streams one +completion. No OpenClaw application code is involved — inside this repo the +workspace link resolves to the package's built `dist`, the same artifact npm +consumers install, so `pnpm build` must have run first. + +```sh +ANTHROPIC_API_KEY=sk-ant-... node index.mjs "Say hello" +OPENAI_API_KEY=sk-... node index.mjs --provider openai "Say hello" +# keyless, against a local Ollama server (OLLAMA_MODEL overrides the model id) +node index.mjs --provider ollama "Say hello" +``` + +Text deltas stream to stdout; stop reason and token usage go to stderr. + +Notes for library consumers: + +- `createLlmRuntime()` gives you an isolated registry; nothing is registered + globally by importing the package. +- `registerBuiltInApiProviders(runtime.registry)` opts into the eight built-in + transports (Anthropic, OpenAI Completions/Responses, Azure OpenAI, ChatGPT + Responses, Google, Vertex, Mistral). Provider SDK modules load lazily on + first use. +- Host policy (custom fetch, secret redaction, strict-tool defaults, logging) + is injectable via `configureAiTransportHost`; the defaults are inert. +- Full TypeScript types ship with the package; this example uses plain ESM so + it runs with bare `node`. diff --git a/examples/ai-chat/index.mjs b/examples/ai-chat/index.mjs new file mode 100644 index 00000000000..8bcae0b35b1 --- /dev/null +++ b/examples/ai-chat/index.mjs @@ -0,0 +1,85 @@ +// Minimal @openclaw/ai consumer: one isolated runtime, built-in providers, +// one streamed completion. Uses only the public package surface — no OpenClaw +// application code. Run with: +// ANTHROPIC_API_KEY=... node index.mjs "your prompt" +// OPENAI_API_KEY=... node index.mjs --provider openai "your prompt" +import { createLlmRuntime } from "@openclaw/ai"; +import { registerBuiltInApiProviders } from "@openclaw/ai/providers"; + +const MODELS = { + anthropic: { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text"], + cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + contextWindow: 200_000, + maxTokens: 8192, + }, + openai: { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 16_384, + }, + // Local Ollama server; no API key required. + ollama: { + id: process.env.OLLAMA_MODEL || "llama3.2:latest", + name: "Ollama", + api: "openai-completions", + provider: "ollama", + baseUrl: "http://localhost:11434/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 32_000, + maxTokens: 4096, + }, +}; + +const args = process.argv.slice(2); +const providerFlag = args.indexOf("--provider"); +const provider = providerFlag === -1 ? "anthropic" : args[providerFlag + 1]; +const prompt = + args.filter((_, i) => i !== providerFlag && i !== providerFlag + 1).join(" ") || + "Reply with one short sentence: what is @openclaw/ai?"; + +const model = MODELS[provider]; +if (!model) { + console.error(`Unknown provider "${provider}". Use one of: ${Object.keys(MODELS).join(", ")}`); + process.exit(1); +} + +const runtime = createLlmRuntime(); +registerBuiltInApiProviders(runtime.registry); + +const stream = runtime.streamSimple( + model, + { messages: [{ role: "user", content: prompt, timestamp: Date.now() }] }, + // Ollama ignores credentials but the OpenAI-compatible transport requires one. + provider === "ollama" ? { apiKey: "ollama" } : undefined, +); + +for await (const event of stream) { + if (event.type === "text_delta") { + process.stdout.write(event.delta); + } +} +const result = await stream.result(); +process.stdout.write("\n"); + +if (result.stopReason === "error" || result.stopReason === "aborted") { + console.error(`error: ${result.errorMessage ?? result.stopReason}`); + process.exit(1); +} +const { input, output } = result.usage; +console.error(`[${model.id}] stop=${result.stopReason} tokens in=${input} out=${output}`); diff --git a/examples/ai-chat/package.json b/examples/ai-chat/package.json new file mode 100644 index 00000000000..6f579efcea9 --- /dev/null +++ b/examples/ai-chat/package.json @@ -0,0 +1,13 @@ +{ + "name": "@openclaw/example-ai-chat", + "version": "0.0.0-private", + "private": true, + "description": "Minimal external-consumer example for @openclaw/ai", + "type": "module", + "scripts": { + "start": "node index.mjs" + }, + "dependencies": { + "@openclaw/ai": "workspace:*" + } +} diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index b06b52aad3c..6e116c1ad33 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -116,6 +116,27 @@ "@openclaw/whatsapp/api.js": [ "../dist/plugin-sdk/extensions/whatsapp/api.d.ts" ], + "@openclaw/ai": [ + "../dist/plugin-sdk/packages/ai/src/index.d.ts" + ], + "@openclaw/ai/diagnostics": [ + "../dist/plugin-sdk/packages/ai/src/utils/diagnostics.d.ts" + ], + "@openclaw/ai/event-stream": [ + "../dist/plugin-sdk/packages/ai/src/utils/event-stream.d.ts" + ], + "@openclaw/ai/providers": [ + "../dist/plugin-sdk/packages/ai/src/providers.d.ts" + ], + "@openclaw/ai/types": [ + "../dist/plugin-sdk/packages/ai/src/types.d.ts" + ], + "@openclaw/ai/validation": [ + "../dist/plugin-sdk/packages/ai/src/validation.d.ts" + ], + "@openclaw/ai/internal/*": [ + "../dist/plugin-sdk/packages/ai/src/internal/*.d.ts" + ], "@openclaw/llm-core": [ "../dist/plugin-sdk/packages/llm-core/src/index.d.ts" ], diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index 0caa51548cc..88b08aae229 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -102,6 +102,27 @@ "@openclaw/qa-channel/api.js": [ "../../dist/plugin-sdk/extensions/qa-channel/api.d.ts" ], + "@openclaw/ai": [ + "../../dist/plugin-sdk/packages/ai/src/index.d.ts" + ], + "@openclaw/ai/diagnostics": [ + "../../dist/plugin-sdk/packages/ai/src/utils/diagnostics.d.ts" + ], + "@openclaw/ai/event-stream": [ + "../../dist/plugin-sdk/packages/ai/src/utils/event-stream.d.ts" + ], + "@openclaw/ai/providers": [ + "../../dist/plugin-sdk/packages/ai/src/providers.d.ts" + ], + "@openclaw/ai/types": [ + "../../dist/plugin-sdk/packages/ai/src/types.d.ts" + ], + "@openclaw/ai/validation": [ + "../../dist/plugin-sdk/packages/ai/src/validation.d.ts" + ], + "@openclaw/ai/internal/*": [ + "../../dist/plugin-sdk/packages/ai/src/internal/*.d.ts" + ], "@openclaw/llm-core": [ "../../dist/plugin-sdk/packages/llm-core/src/index.d.ts" ], diff --git a/package.json b/package.json index af1bdebb88a..104366f11b1 100644 --- a/package.json +++ b/package.json @@ -1986,6 +1986,7 @@ "@mistralai/mistralai": "2.4.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "0.6.0", + "@openclaw/ai": "workspace:*", "@openclaw/fs-safe": "0.4.1", "@openclaw/proxyline": "0.3.3", "@silvia-odwyer/photon-node": "0.3.4", diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index dc181fcc95f..cf3a337ee25 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -95,7 +95,7 @@ } }, "dependencies": { - "@openclaw/llm-core": "workspace:*", + "@openclaw/ai": "workspace:*", "typebox": "1.3.3" } } diff --git a/packages/agent-core/src/agent-loop.test.ts b/packages/agent-core/src/agent-loop.test.ts index f12fe2de13a..ede6715aacb 100644 --- a/packages/agent-core/src/agent-loop.test.ts +++ b/packages/agent-core/src/agent-loop.test.ts @@ -1,4 +1,5 @@ // Agent Core tests cover agent loop behavior. +import { EventStream } from "@openclaw/ai/event-stream"; import { Type } from "typebox"; import { describe, expect, it, vi } from "vitest"; import { agentLoop, agentLoopContinue, runAgentLoop } from "./agent-loop.js"; @@ -77,6 +78,7 @@ describe("agentLoop EventStream failures", () => { undefined, failingStreamFn, ); + expect(stream).toBeInstanceOf(EventStream); const events = await collectEvents(stream); const result = await stream.result(); diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index a8609462c11..15caff4324c 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -1,6 +1,6 @@ -// Keep the runtime class on the package specifier so built agent-core shares -// constructor identity with @openclaw/llm-core; source types keep SDK d.ts bundled. -import { EventStream as LlmEventStream } from "@openclaw/llm-core"; +// Keep the runtime class on the public package specifier so OpenClaw and +// external consumers share one constructor identity. +import { EventStream as LlmEventStream } from "@openclaw/ai/event-stream"; import type { AssistantMessage, AssistantMessageEvent, diff --git a/packages/agent-core/src/llm.ts b/packages/agent-core/src/llm.ts index 00853799a32..0888e7dfadc 100644 --- a/packages/agent-core/src/llm.ts +++ b/packages/agent-core/src/llm.ts @@ -1,2 +1,2 @@ -// LLM-core compatibility barrel for agent-core consumers. -export * from "@openclaw/llm-core"; +// AI contract compatibility barrel for agent-core consumers. +export * from "@openclaw/ai"; diff --git a/packages/agent-core/src/validation.ts b/packages/agent-core/src/validation.ts index 1368906b477..45a73b99f08 100644 --- a/packages/agent-core/src/validation.ts +++ b/packages/agent-core/src/validation.ts @@ -1,2 +1,2 @@ // Tool validation facade for callers that import validation from agent-core. -export { validateToolArguments, validateToolCall } from "@openclaw/llm-core"; +export { validateToolArguments, validateToolCall } from "@openclaw/ai/validation"; diff --git a/packages/ai/LICENSE b/packages/ai/LICENSE new file mode 100644 index 00000000000..ed064819ab5 --- /dev/null +++ b/packages/ai/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenClaw Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ai/README.md b/packages/ai/README.md new file mode 100644 index 00000000000..a45c578a29e --- /dev/null +++ b/packages/ai/README.md @@ -0,0 +1,28 @@ +# `@openclaw/ai` + +Reusable model API contracts, provider adapters, and streaming primitives from +OpenClaw. The package supports isolated runtime instances; importing it does not +register providers globally. + +```ts +import { createLlmRuntime } from "@openclaw/ai"; +import { registerBuiltInApiProviders } from "@openclaw/ai/providers"; + +const runtime = createLlmRuntime(); +registerBuiltInApiProviders(runtime.registry); +``` + +Provider-neutral contracts, validation, diagnostics, and event streams are +available from the package root and focused subpaths such as +`@openclaw/ai/event-stream` and `@openclaw/ai/validation`. No second OpenClaw +runtime package is required. + +Provider ids, credentials, model catalogs, retries, and failover remain +application concerns. OpenClaw supplies those policies around this package. +Host policy (request fetch guarding, secret redaction, strict-tool defaults, +diagnostics logging) can be injected with `configureAiTransportHost`; the +defaults are inert. + +`@openclaw/ai/internal/*` subpaths exist for the OpenClaw application itself. +They carry no semver guarantee and can change or disappear in any release; do +not depend on them outside OpenClaw. diff --git a/packages/ai/npm-shrinkwrap.json b/packages/ai/npm-shrinkwrap.json new file mode 100644 index 00000000000..a3aa0119f02 --- /dev/null +++ b/packages/ai/npm-shrinkwrap.json @@ -0,0 +1,645 @@ +{ + "name": "@openclaw/ai", + "version": "2026.6.11", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openclaw/ai", + "version": "2026.6.11", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.109.1", + "@google/genai": "2.10.0", + "@mistralai/mistralai": "2.4.0", + "openai": "6.45.0", + "partial-json": "0.1.7", + "typebox": "1.3.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.109.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.109.1.tgz", + "integrity": "sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@google/genai": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.10.0.tgz", + "integrity": "sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.4.0.tgz", + "integrity": "sha512-t6hCx242MTGolB76CI+17jDtPIe/bzLsMdUTMMoMn9Qo1h02N2G5jQYHmKDGU3X//OgR2wvngTD7tO6tPp5poQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "name": "@nolyfill/domexception", + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz", + "integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==", + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.45.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", + "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", + "integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/typebox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz", + "integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 00000000000..12d4db8403e --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,89 @@ +{ + "name": "@openclaw/ai", + "version": "2026.6.11", + "description": "Reusable model provider adapters and streaming runtime from OpenClaw", + "keywords": [ + "ai", + "anthropic", + "google", + "llm", + "mistral", + "openai", + "streaming" + ], + "homepage": "https://github.com/openclaw/openclaw#readme", + "bugs": { + "url": "https://github.com/openclaw/openclaw/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/openclaw/openclaw.git", + "directory": "packages/ai" + }, + "files": [ + "dist", + "npm-shrinkwrap.json", + "LICENSE", + "README.md" + ], + "type": "module", + "main": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./providers": { + "types": "./dist/providers.d.mts", + "import": "./dist/providers.mjs", + "default": "./dist/providers.mjs" + }, + "./diagnostics": { + "types": "./dist/diagnostics.d.mts", + "import": "./dist/diagnostics.mjs", + "default": "./dist/diagnostics.mjs" + }, + "./event-stream": { + "types": "./dist/event-stream.d.mts", + "import": "./dist/event-stream.mjs", + "default": "./dist/event-stream.mjs" + }, + "./types": { + "types": "./dist/types.d.mts", + "import": "./dist/types.mjs", + "default": "./dist/types.mjs" + }, + "./validation": { + "types": "./dist/validation.d.mts", + "import": "./dist/validation.mjs", + "default": "./dist/validation.mjs" + }, + "./internal/*": { + "types": "./dist/internal/*.d.mts", + "import": "./dist/internal/*.mjs", + "default": "./dist/internal/*.mjs" + } + }, + "dependencies": { + "@anthropic-ai/sdk": "0.109.1", + "@google/genai": "2.10.0", + "@mistralai/mistralai": "2.4.0", + "openai": "6.45.0", + "partial-json": "0.1.7", + "typebox": "1.3.3" + }, + "engines": { + "node": ">=22.19.0" + }, + "publishConfig": { + "access": "public" + }, + "openclaw": { + "release": { + "publishToNpm": true + } + } +} diff --git a/packages/ai/src/api-registry.test.ts b/packages/ai/src/api-registry.test.ts new file mode 100644 index 00000000000..ba4488a1ebe --- /dev/null +++ b/packages/ai/src/api-registry.test.ts @@ -0,0 +1,76 @@ +// LLM Runtime tests cover api registry behavior. +import { describe, expect, it, vi } from "vitest"; +import { + createApiRegistry, + createAssistantMessageEventStream, + createLlmRuntime, + type Model, +} from "./index.js"; + +const TEST_SOURCE_ID = "test:llm-runtime-api-registry"; +const emptyStream = () => createAssistantMessageEventStream(); + +const model = { + id: "test-model", + name: "Test Model", + api: "test-api", + provider: "test-provider", + baseUrl: "https://example.invalid", + input: ["text"], + reasoning: false, + contextWindow: 1000, + maxTokens: 100, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +} satisfies Model; + +describe("LLM API registry", () => { + it("rejects mismatched model API calls", () => { + const registry = createApiRegistry(); + registry.registerApiProvider( + { + api: "test-api", + stream: emptyStream, + streamSimple: emptyStream, + }, + TEST_SOURCE_ID, + ); + + const provider = registry.getApiProvider("test-api"); + expect(provider).toBeDefined(); + expect(() => provider?.streamSimple({ ...model, api: "other-api" }, { messages: [] })).toThrow( + "Mismatched api: other-api expected test-api", + ); + }); + + it("isolates providers between runtime instances", () => { + const first = createLlmRuntime(); + const second = createLlmRuntime(); + const streamSimple = vi.fn(() => createAssistantMessageEventStream()); + first.registry.registerApiProvider({ api: "test-api", stream: streamSimple, streamSimple }); + + first.streamSimple(model, { messages: [] }); + + expect(streamSimple).toHaveBeenCalledOnce(); + expect(() => second.streamSimple(model, { messages: [] })).toThrow( + "No API provider registered for api: test-api", + ); + }); + + it("unregisters every provider owned by one source", () => { + const registry = createApiRegistry(); + for (const api of ["test-api", "test-api-2"] as const) { + registry.registerApiProvider( + { + api, + stream: emptyStream, + streamSimple: emptyStream, + }, + TEST_SOURCE_ID, + ); + } + + registry.unregisterApiProviders(TEST_SOURCE_ID); + + expect(registry.getApiProviders()).toEqual([]); + }); +}); diff --git a/packages/llm-runtime/src/api-registry.ts b/packages/ai/src/api-registry.ts similarity index 56% rename from packages/llm-runtime/src/api-registry.ts rename to packages/ai/src/api-registry.ts index 75d6a29d95f..c06002b53ac 100644 --- a/packages/llm-runtime/src/api-registry.ts +++ b/packages/ai/src/api-registry.ts @@ -7,7 +7,7 @@ import type { SimpleStreamOptions, StreamFunction, StreamOptions, -} from "../../llm-core/src/index.js"; +} from "@openclaw/llm-core"; /** Runtime stream adapter signature stored in the API provider registry. */ export type ApiStreamFunction = ( @@ -36,19 +36,18 @@ export interface ApiProvider< streamSimple: StreamFunction; } -interface ApiProviderInternal { +/** Type-erased provider returned by a registry after API guards are installed. */ +export interface RegisteredApiProvider { api: Api; stream: ApiStreamFunction; streamSimple: ApiStreamSimpleFunction; } -type RegisteredApiProvider = { - provider: ApiProviderInternal; +type RegisteredApiProviderEntry = { + provider: RegisteredApiProvider; sourceId?: string; }; -const apiProviderRegistry = new Map(); - function wrapStream( api: TApi, stream: StreamFunction, @@ -73,42 +72,48 @@ function wrapStreamSimple( }; } -/** Registers or replaces the provider implementation for an API id. */ -export function registerApiProvider( - provider: ApiProvider, - /** Optional source id used to unregister all providers owned by one plugin/runtime. */ - sourceId?: string, -): void { - apiProviderRegistry.set(provider.api, { - provider: { - api: provider.api, - stream: wrapStream(provider.api, provider.stream), - streamSimple: wrapStreamSimple(provider.api, provider.streamSimple), - }, - sourceId, - }); -} +/** Creates an isolated provider registry for one runtime or tenant. */ +export function createApiRegistry() { + const providers = new Map(); -/** Looks up a registered API provider by API id. */ -export function getApiProvider(api: Api): ApiProviderInternal | undefined { - return apiProviderRegistry.get(api)?.provider; -} + function registerApiProvider( + provider: ApiProvider, + /** Optional source id used to unregister all providers owned by one plugin/runtime. */ + sourceId?: string, + ): void { + providers.set(provider.api, { + provider: { + api: provider.api, + stream: wrapStream(provider.api, provider.stream), + streamSimple: wrapStreamSimple(provider.api, provider.streamSimple), + }, + sourceId, + }); + } -/** Lists all currently registered API providers. */ -export function getApiProviders(): ApiProviderInternal[] { - return Array.from(apiProviderRegistry.values(), (entry) => entry.provider); -} + function getApiProvider(api: Api): RegisteredApiProvider | undefined { + return providers.get(api)?.provider; + } -/** Removes all providers registered by a plugin/source id. */ -export function unregisterApiProviders(sourceId: string): void { - for (const [api, entry] of apiProviderRegistry.entries()) { - if (entry.sourceId === sourceId) { - apiProviderRegistry.delete(api); + function getApiProviders(): RegisteredApiProvider[] { + return Array.from(providers.values(), (entry) => entry.provider); + } + + function unregisterApiProviders(sourceId: string): void { + for (const [api, entry] of providers.entries()) { + if (entry.sourceId === sourceId) { + providers.delete(api); + } } } + + return { + registerApiProvider, + getApiProvider, + getApiProviders, + unregisterApiProviders, + clearApiProviders: () => providers.clear(), + }; } -/** Clears the registry for test teardown and runtime reset flows. */ -export function clearApiProviders(): void { - apiProviderRegistry.clear(); -} +export type ApiRegistry = ReturnType; diff --git a/src/llm/env-api-keys.ts b/packages/ai/src/env-api-keys.ts similarity index 100% rename from src/llm/env-api-keys.ts rename to packages/ai/src/env-api-keys.ts diff --git a/packages/ai/src/host.ts b/packages/ai/src/host.ts new file mode 100644 index 00000000000..a1d28f85816 --- /dev/null +++ b/packages/ai/src/host.ts @@ -0,0 +1,66 @@ +// Host policy ports for the reusable transport package. Fetch guarding, +// secret redaction, strict-tool policy, and diagnostics logging are owned by +// the embedding application (OpenClaw core installs its implementations via +// configureAiTransportHost); the library defaults below are inert so external +// consumers get safe, dependency-free behavior without wiring anything. +import type { Model } from "@openclaw/llm-core"; + +/** Strict-tool policy inputs for OpenAI-compatible routes. */ +export interface OpenAIStrictToolSettingOptions { + transport?: "stream" | "websocket"; + supportsStrictMode?: boolean; +} + +/** Narrow host ports consumed by the built-in provider adapters. */ +export interface AiTransportHost { + /** + * Builds a policy-guarded fetch for one model request. + * Returning undefined keeps the provider SDK's default fetch. + */ + buildModelFetch( + model: Model, + timeoutMs?: number, + options?: { sanitizeSse?: boolean }, + ): typeof fetch | undefined; + /** Redacts secrets inside structured tool-result payloads. */ + redactSecrets(value: T): T; + /** Redacts secret-bearing text in tool payload strings. */ + redactToolPayloadText(text: string): string; + /** + * Resolves the host strict-tool default for OpenAI-compatible routes. + * undefined lets the request omit the strict flag entirely. + */ + resolveOpenAIStrictToolSetting( + model: Pick & { compat?: unknown }, + options?: OpenAIStrictToolSettingOptions, + ): boolean | undefined; + /** + * Emits one transport diagnostic; build runs only when the host logs it and + * may return null to suppress the entry (e.g. de-duplication). + */ + logDebug( + subsystem: string, + build: () => { message: string; data?: Record } | null, + ): void; +} + +const inertAiTransportHost: AiTransportHost = { + buildModelFetch: () => undefined, + redactSecrets: (value) => value, + redactToolPayloadText: (text) => text, + resolveOpenAIStrictToolSetting: (_model, options) => + options?.supportsStrictMode ? false : undefined, + logDebug: () => {}, +}; + +let activeAiTransportHost = inertAiTransportHost; + +/** Installs host implementations for the transport policy ports. */ +export function configureAiTransportHost(host: Partial): void { + activeAiTransportHost = { ...inertAiTransportHost, ...host }; +} + +/** Returns the active transport host (inert defaults unless configured). */ +export function getAiTransportHost(): AiTransportHost { + return activeAiTransportHost; +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 00000000000..558f81dda2e --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,5 @@ +/** Reusable model API contracts, provider adapters, and streaming runtime. */ +export * from "@openclaw/llm-core"; +export * from "./api-registry.js"; +export * from "./host.js"; +export * from "./stream.js"; diff --git a/packages/ai/src/internal/anthropic.ts b/packages/ai/src/internal/anthropic.ts new file mode 100644 index 00000000000..9e3c0da1cce --- /dev/null +++ b/packages/ai/src/internal/anthropic.ts @@ -0,0 +1,7 @@ +export * from "../providers/anthropic.js"; +export * from "../providers/anthropic-auth-headers.js"; +export * from "../providers/anthropic-model-contract.js"; +export * from "../providers/anthropic-refusal.js"; +export * from "../providers/anthropic-server-fallback.js"; +export * from "../providers/anthropic-thinking-replay.js"; +export * from "../providers/anthropic-tool-projection.js"; diff --git a/packages/ai/src/internal/default-runtime.ts b/packages/ai/src/internal/default-runtime.ts new file mode 100644 index 00000000000..314791a262e --- /dev/null +++ b/packages/ai/src/internal/default-runtime.ts @@ -0,0 +1,19 @@ +// Process-default registry/runtime retained for the OpenClaw compatibility +// facade (src/llm). Deliberately not part of the public package API: external +// consumers create isolated runtimes via createLlmRuntime(); exporting these +// from the root barrel would reintroduce the mutable process-global registry. +import { createApiRegistry } from "../api-registry.js"; +import { createLlmRuntime } from "../stream.js"; + +export const defaultApiRegistry = createApiRegistry(); +export const defaultLlmRuntime = createLlmRuntime(defaultApiRegistry); + +export const { + registerApiProvider, + getApiProvider, + getApiProviders, + unregisterApiProviders, + clearApiProviders, +} = defaultApiRegistry; + +export const { stream, complete, streamSimple, completeSimple } = defaultLlmRuntime; diff --git a/packages/ai/src/internal/openai.ts b/packages/ai/src/internal/openai.ts new file mode 100644 index 00000000000..c9c8b9372e9 --- /dev/null +++ b/packages/ai/src/internal/openai.ts @@ -0,0 +1,14 @@ +export * from "../providers/agent-tools-parameter-schema.js"; +export * from "../providers/azure-deployment-map.js"; +export * from "../providers/azure-openai-responses-client-compat.js"; +export * from "../providers/clean-for-gemini.js"; +export * from "../providers/openai-completions.js"; +export * from "../providers/openai-prompt-cache.js"; +export * from "../providers/openai-reasoning-effort.js"; +export * from "../providers/openai-responses.js"; +export * from "../providers/openai-responses-stream-compat.js"; +export * from "../providers/openai-stop-reason.js"; +export * from "../providers/openai-tool-projection.js"; +export * from "../providers/openai-tool-schema.js"; +export * from "../providers/schema-keyword-strip.js"; +export * from "../providers/tool-schema-json-projection.js"; diff --git a/packages/ai/src/internal/runtime.ts b/packages/ai/src/internal/runtime.ts new file mode 100644 index 00000000000..537d2f146ff --- /dev/null +++ b/packages/ai/src/internal/runtime.ts @@ -0,0 +1,15 @@ +export * from "./default-runtime.js"; +export * from "../env-api-keys.js"; +export * from "../model-utils.js"; +export * from "../session-resources.js"; +export * from "../utils/deferred-event-buffer.js"; +export * from "../utils/hash.js"; +export * from "../utils/headers.js"; +export * from "../utils/json-parse.js"; +export * from "../utils/llm-request-activity.js"; +export * from "../utils/oauth/openai-chatgpt-jwt.js"; +export * from "../utils/overflow.js"; +export * from "../utils/reasoning-tag-text-partitioner.js"; +export * from "../utils/sanitize-unicode.js"; +export * from "../utils/stream-first-event-timeout.js"; +export * from "../utils/streaming-byte-guard.js"; diff --git a/packages/ai/src/internal/shared.ts b/packages/ai/src/internal/shared.ts new file mode 100644 index 00000000000..e4886ffa89c --- /dev/null +++ b/packages/ai/src/internal/shared.ts @@ -0,0 +1,5 @@ +export * from "../providers/simple-options.js"; +export * from "../providers/tool-result-text.js"; +export * from "../providers/transform-messages.js"; +export * from "../utils/prompt-cache-stability.js"; +export * from "../utils/system-prompt-cache-boundary.js"; diff --git a/src/llm/model-utils.ts b/packages/ai/src/model-utils.ts similarity index 100% rename from src/llm/model-utils.ts rename to packages/ai/src/model-utils.ts diff --git a/packages/ai/src/providers.ts b/packages/ai/src/providers.ts new file mode 100644 index 00000000000..4e826f48872 --- /dev/null +++ b/packages/ai/src/providers.ts @@ -0,0 +1,6 @@ +/** Lazy built-in protocol adapter registration. */ +export { + BUILT_IN_API_PROVIDER_SOURCE_ID, + registerBuiltInApiProviders, + resetApiProviders, +} from "./providers/register-builtins.js"; diff --git a/src/agents/agent-tools-parameter-schema.ts b/packages/ai/src/providers/agent-tools-parameter-schema.ts similarity index 93% rename from src/agents/agent-tools-parameter-schema.ts rename to packages/ai/src/providers/agent-tools-parameter-schema.ts index 02d0ba74428..bbf8907bcd2 100644 --- a/src/agents/agent-tools-parameter-schema.ts +++ b/packages/ai/src/providers/agent-tools-parameter-schema.ts @@ -5,20 +5,62 @@ */ import { isRecord as isSchemaRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { uniqueValues } from "@openclaw/normalization-core/string-normalization"; -import type { TSchema } from "typebox"; -import type { ModelCompatConfig } from "../config/types.models.js"; import { - resolveUnsupportedToolSchemaKeywords, - shouldOmitEmptyArrayItems, -} from "../plugins/provider-model-compat.js"; -import { stripUnsupportedSchemaKeywords } from "../shared/schema-keyword-strip.js"; -import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; + normalizeStringEntries, + uniqueValues, +} from "@openclaw/normalization-core/string-normalization"; +import type { TSchema } from "typebox"; +import { cleanSchemaForGemini } from "./clean-for-gemini.js"; +import { stripUnsupportedSchemaKeywords } from "./schema-keyword-strip.js"; + +/** + * Narrow structural view of the host's model compat config. packages/ai must stay + * config-agnostic, so only tool-schema-relevant fields are modeled here; the host's + * ModelCompatConfig remains structurally assignable. + */ +export type ToolSchemaModelCompat = { + toolSchemaProfile?: string; + unsupportedToolSchemaKeywords?: string[]; + omitEmptyArrayItems?: boolean; +}; + +/** Extracts the compat record whether callers pass a model (`{ compat }`) or the compat itself. */ +export function extractToolSchemaModelCompat( + modelOrCompat: { compat?: unknown } | ToolSchemaModelCompat | undefined, +): ToolSchemaModelCompat | undefined { + if (!modelOrCompat || typeof modelOrCompat !== "object") { + return undefined; + } + if ("compat" in modelOrCompat) { + const compat = (modelOrCompat as { compat?: unknown }).compat; + return compat && typeof compat === "object" ? (compat as ToolSchemaModelCompat) : undefined; + } + return modelOrCompat as ToolSchemaModelCompat; +} + +/** JSON Schema keywords this model/provider rejects in tool schemas. */ +export function resolveUnsupportedToolSchemaKeywords( + modelOrCompat: { compat?: unknown } | ToolSchemaModelCompat | undefined, +): ReadonlySet { + const keywords = extractToolSchemaModelCompat(modelOrCompat)?.unsupportedToolSchemaKeywords ?? []; + return new Set( + normalizeStringEntries( + keywords.filter((keyword): keyword is string => typeof keyword === "string"), + ), + ); +} + +/** Whether empty `items: {}` on array schemas must be omitted for this model/provider. */ +export function shouldOmitEmptyArrayItems( + modelOrCompat: { compat?: unknown } | ToolSchemaModelCompat | undefined, +): boolean { + return extractToolSchemaModelCompat(modelOrCompat)?.omitEmptyArrayItems === true; +} export type ToolParameterSchemaOptions = { modelProvider?: string; modelId?: string; - modelCompat?: ModelCompatConfig; + modelCompat?: ToolSchemaModelCompat; }; const MAX_TOOL_PARAMETER_SCHEMA_CACHE_ENTRIES_PER_SCHEMA = 8; diff --git a/src/shared/anthropic-auth-headers.ts b/packages/ai/src/providers/anthropic-auth-headers.ts similarity index 100% rename from src/shared/anthropic-auth-headers.ts rename to packages/ai/src/providers/anthropic-auth-headers.ts diff --git a/src/shared/anthropic-model-contract.ts b/packages/ai/src/providers/anthropic-model-contract.ts similarity index 97% rename from src/shared/anthropic-model-contract.ts rename to packages/ai/src/providers/anthropic-model-contract.ts index e00c1865a61..9e1fd8b7ecd 100644 --- a/src/shared/anthropic-model-contract.ts +++ b/packages/ai/src/providers/anthropic-model-contract.ts @@ -1,8 +1,5 @@ // Model-bound thinking cannot be exposed or replayed after a model switch. -import { - resolveClaudeFable5ModelIdentity, - resolveClaudeModelIdentity, -} from "@openclaw/llm-core"; +import { resolveClaudeFable5ModelIdentity, resolveClaudeModelIdentity } from "@openclaw/llm-core"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; export { resolveClaudeFable5ModelIdentity, diff --git a/src/shared/anthropic-refusal.ts b/packages/ai/src/providers/anthropic-refusal.ts similarity index 95% rename from src/shared/anthropic-refusal.ts rename to packages/ai/src/providers/anthropic-refusal.ts index b59dc377057..0608d9c6f45 100644 --- a/src/shared/anthropic-refusal.ts +++ b/packages/ai/src/providers/anthropic-refusal.ts @@ -1,4 +1,4 @@ -import type { AssistantMessageDiagnostic } from "../llm/types.js"; +import type { AssistantMessageDiagnostic } from "../types.js"; type AnthropicRefusalOutput = { stopReason: string; diff --git a/src/shared/anthropic-server-fallback.ts b/packages/ai/src/providers/anthropic-server-fallback.ts similarity index 61% rename from src/shared/anthropic-server-fallback.ts rename to packages/ai/src/providers/anthropic-server-fallback.ts index ee3e6029fa4..2d4c37bba27 100644 --- a/src/shared/anthropic-server-fallback.ts +++ b/packages/ai/src/providers/anthropic-server-fallback.ts @@ -1,21 +1,12 @@ -import type { AssistantMessageDiagnostic } from "../llm/types.js"; +import type { AssistantMessageDiagnostic } from "../types.js"; -/** - * Anthropic server-side refusal fallback (`server-side-fallback-2026-06-01`). - * When Claude Fable 5 safety classifiers decline a request, the API re-serves - * the same call on a permitted fallback model inside the same stream instead - * of returning `stop_reason: "refusal"`. - * https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback - */ +/** Anthropic beta that re-serves safety refusals on an allowed fallback model. */ export const ANTHROPIC_SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-06-01"; -// Anthropic contract: claude-opus-4-8 is the only entry in claude-fable-5's -// published `allowed_fallback_models`; other targets are rejected up front. +// Anthropic documents claude-opus-4-8 as the allowed fallback for claude-fable-5. export const CLAUDE_FABLE_5_FALLBACK_MODEL = "claude-opus-4-8"; -// Fallback-served turns bill at the serving model's rates (top-level usage -// covers only that attempt), so cost math must switch off the Fable table. -// Claude Opus 4.8 per-MTok pricing, same shape as the bundled Fable table. +// Fallback-served turns bill at the serving model's rates. export const CLAUDE_FABLE_5_FALLBACK_MODEL_COST = { input: 5, output: 25, @@ -56,10 +47,8 @@ export function readAnthropicFallbackBoundary(block: unknown): AnthropicFallback } /** - * Applies a mid-stream fallback boundary to the accumulated assistant output. - * Anthropic's replay contract: pre-boundary thinking/tool_use blocks must not - * be echoed on later turns (and dropped tool calls must never execute); the - * pre-boundary text is the continuation prefix the fallback model built on. + * Drops pre-fallback thinking/tool calls while preserving the text prefix that + * the serving model continued. Dropped tool calls must never execute or replay. */ export function applyAnthropicFallbackBoundary(params: { output: { @@ -73,9 +62,6 @@ export function applyAnthropicFallbackBoundary(params: { const { output, boundary } = params; const survivors = output.content.filter((block) => block.type === "text"); for (const survivor of survivors) { - // Commentary phase tags refer to dropped pre-boundary tool calls; the - // prefix is now the start of the final answer, and phase-aware display - // extraction would otherwise hide it from the visible response. delete (survivor as { textSignature?: string }).textSignature; } output.content.splice(0, output.content.length, ...survivors); diff --git a/src/llm/providers/anthropic-thinking-replay.ts b/packages/ai/src/providers/anthropic-thinking-replay.ts similarity index 100% rename from src/llm/providers/anthropic-thinking-replay.ts rename to packages/ai/src/providers/anthropic-thinking-replay.ts diff --git a/src/agents/anthropic-tool-projection.ts b/packages/ai/src/providers/anthropic-tool-projection.ts similarity index 100% rename from src/agents/anthropic-tool-projection.ts rename to packages/ai/src/providers/anthropic-tool-projection.ts diff --git a/src/llm/providers/anthropic.test.ts b/packages/ai/src/providers/anthropic.test.ts similarity index 98% rename from src/llm/providers/anthropic.test.ts rename to packages/ai/src/providers/anthropic.test.ts index c29a0f879ac..d1c69cc2e1c 100644 --- a/src/llm/providers/anthropic.test.ts +++ b/packages/ai/src/providers/anthropic.test.ts @@ -1,7 +1,8 @@ // Anthropic provider tests cover stream events, tools, and message mapping. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureAiTransportHost } from "../host.js"; import type { Context, Model, Tool } from "../types.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; const anthropicMockState = vi.hoisted(() => ({ configs: [] as unknown[], @@ -54,7 +55,14 @@ describe("Anthropic provider", () => { anthropicMockState.configs = []; }); + afterEach(() => { + configureAiTransportHost({}); + }); + it("keeps Cloudflare AI Gateway upstream provider auth on the Anthropic API key", async () => { + // Prove the Cloudflare client receives the host-built model fetch. + const hostFetch: typeof fetch = async () => new Response(null, { status: 500 }); + configureAiTransportHost({ buildModelFetch: () => hostFetch }); const model = makeAnthropicModel({ provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic/v1/messages", @@ -82,7 +90,7 @@ describe("Anthropic provider", () => { expect(config.authToken).toBeNull(); expect(config.defaultHeaders?.["x-api-key"]).toBeUndefined(); expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer gateway-token"); - expect(typeof config.fetch).toBe("function"); + expect(config.fetch).toBe(hostFetch); }); it("uses bearer auth for Microsoft Foundry Anthropic requests", async () => { diff --git a/src/llm/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts similarity index 98% rename from src/llm/providers/anthropic.ts rename to packages/ai/src/providers/anthropic.ts index 094c1341742..a468fe72dfe 100644 --- a/src/llm/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -8,42 +8,8 @@ import type { RawMessageStreamEvent, TextBlockParam, } from "@anthropic-ai/sdk/resources/messages.js"; -import { - projectAnthropicTools, - reconcileAnthropicToolChoice, - resolveOriginalAnthropicToolName, - type AnthropicProjectedToolChoice, - type AnthropicToolProjection, -} from "../../agents/anthropic-tool-projection.js"; -import { resolveProviderEndpoint } from "../../agents/provider-attribution.js"; -import { buildGuardedModelFetch } from "../../agents/provider-transport-fetch.js"; -import { - splitSystemPromptCacheBoundary, - stripSystemPromptCacheBoundary, -} from "../../agents/system-prompt-cache-boundary.js"; -import { - omitFoundryBearerCredentialHeaders, - usesFoundryBearerAuth, -} from "../../shared/anthropic-auth-headers.js"; -import { - resolveClaudeNativeThinkingLevelMap, - requiresClaudeAdaptiveThinking, - supportsClaudeAdaptiveThinking, - supportsClaudeNativeMaxEffort, - supportsClaudeNativeXhighEffort, - usesClaudeFable5MessagesContract, -} from "../../shared/anthropic-model-contract.js"; -import { applyAnthropicRefusal } from "../../shared/anthropic-refusal.js"; -import { - ANTHROPIC_SERVER_SIDE_FALLBACK_BETA, - CLAUDE_FABLE_5_FALLBACK_MODEL_COST, - applyAnthropicFallbackBoundary, - buildAnthropicServerSideFallbacks, - readAnthropicFallbackBoundary, -} from "../../shared/anthropic-server-fallback.js"; -import { createDeferredEventBuffer } from "../../shared/deferred-event-buffer.js"; -import { notifyLlmRequestActivity } from "../../shared/llm-request-activity.js"; import { getEnvApiKey } from "../env-api-keys.js"; +import { getAiTransportHost } from "../host.js"; import { calculateCost, clampThinkingLevel } from "../model-utils.js"; import type { AnthropicMessagesCompat, @@ -65,14 +31,47 @@ import type { ToolCall, ToolResultMessage, } from "../types.js"; +import { createDeferredEventBuffer } from "../utils/deferred-event-buffer.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse.js"; +import { notifyLlmRequestActivity } from "../utils/llm-request-activity.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { + splitSystemPromptCacheBoundary, + stripSystemPromptCacheBoundary, +} from "../utils/system-prompt-cache-boundary.js"; +import { + omitFoundryBearerCredentialHeaders, + usesFoundryBearerAuth, +} from "./anthropic-auth-headers.js"; +import { + resolveClaudeNativeThinkingLevelMap, + requiresClaudeAdaptiveThinking, + supportsClaudeAdaptiveThinking, + supportsClaudeNativeMaxEffort, + supportsClaudeNativeXhighEffort, + usesClaudeFable5MessagesContract, +} from "./anthropic-model-contract.js"; +import { applyAnthropicRefusal } from "./anthropic-refusal.js"; +import { + ANTHROPIC_SERVER_SIDE_FALLBACK_BETA, + CLAUDE_FABLE_5_FALLBACK_MODEL_COST, + applyAnthropicFallbackBoundary, + buildAnthropicServerSideFallbacks, + readAnthropicFallbackBoundary, +} from "./anthropic-server-fallback.js"; import { ANTHROPIC_OMITTED_REASONING_TEXT, findActiveAnthropicToolTurnAssistantIndex, } from "./anthropic-thinking-replay.js"; +import { + projectAnthropicTools, + reconcileAnthropicToolChoice, + resolveOriginalAnthropicToolName, + type AnthropicProjectedToolChoice, + type AnthropicToolProjection, +} from "./anthropic-tool-projection.js"; import { resolveCacheRetention } from "./cache-retention.js"; import { resolveCloudflareBaseUrl } from "./cloudflare.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; @@ -971,6 +970,17 @@ function isOAuthToken(apiKey: string): boolean { return apiKey.includes("sk-ant-oat"); } +function isAnthropicPublicEndpoint(baseUrl: string | undefined): boolean { + if (!baseUrl) { + return true; + } + try { + return new URL(baseUrl).hostname.toLowerCase() === "api.anthropic.com"; + } catch { + return false; + } +} + /** * Server-side refusal fallback is a first-party Claude API beta: proxies and * Bedrock/Vertex/Foundry reject the `fallbacks` param, and OAuth (Claude Code @@ -980,8 +990,7 @@ function supportsAnthropicServerSideFallback(model: Model<"anthropic-messages">) if (!usesClaudeFable5MessagesContract(model) || model.provider !== "anthropic") { return false; } - const endpointClass = resolveProviderEndpoint(model.baseUrl).endpointClass; - return endpointClass === "default" || endpointClass === "anthropic-public"; + return isAnthropicPublicEndpoint(model.baseUrl); } function createClient( @@ -1020,7 +1029,7 @@ function createClient( model.headers, optionsHeaders, ), - fetch: buildGuardedModelFetch(model), + fetch: getAiTransportHost().buildModelFetch(model), }); return { client, isOAuthToken: false, serverSideFallback: false }; diff --git a/src/llm/providers/azure-deployment-map.test.ts b/packages/ai/src/providers/azure-deployment-map.test.ts similarity index 100% rename from src/llm/providers/azure-deployment-map.test.ts rename to packages/ai/src/providers/azure-deployment-map.test.ts diff --git a/src/llm/providers/azure-deployment-map.ts b/packages/ai/src/providers/azure-deployment-map.ts similarity index 100% rename from src/llm/providers/azure-deployment-map.ts rename to packages/ai/src/providers/azure-deployment-map.ts diff --git a/src/shared/azure-openai-responses-client-compat.ts b/packages/ai/src/providers/azure-openai-responses-client-compat.ts similarity index 100% rename from src/shared/azure-openai-responses-client-compat.ts rename to packages/ai/src/providers/azure-openai-responses-client-compat.ts diff --git a/src/llm/providers/azure-openai-responses.test.ts b/packages/ai/src/providers/azure-openai-responses.test.ts similarity index 100% rename from src/llm/providers/azure-openai-responses.test.ts rename to packages/ai/src/providers/azure-openai-responses.test.ts diff --git a/src/llm/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts similarity index 96% rename from src/llm/providers/azure-openai-responses.ts rename to packages/ai/src/providers/azure-openai-responses.ts index 91e8898f6de..ecbc1f2dfbb 100644 --- a/src/llm/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -1,9 +1,8 @@ // Azure OpenAI Responses provider adapts Azure deployments to Responses API streams. import OpenAI, { AzureOpenAI } from "openai"; import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js"; -import { buildGuardedModelFetch } from "../../agents/provider-transport-fetch.js"; -import { isOpenAICompatibleAzureResponsesBaseUrl } from "../../shared/azure-openai-responses-client-compat.js"; import { getEnvApiKey } from "../env-api-keys.js"; +import { getAiTransportHost } from "../host.js"; import type { Context, Model, @@ -13,6 +12,7 @@ import type { } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { resolveAzureDeploymentNameFromMap } from "./azure-deployment-map.js"; +import { isOpenAICompatibleAzureResponsesBaseUrl } from "./azure-openai-responses-client-compat.js"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { applyCommonResponsesParams, @@ -200,7 +200,7 @@ function createClient( } const { baseUrl, apiVersion } = resolveAzureConfig(model, options); - const guardedFetch = buildGuardedModelFetch({ ...model, baseUrl }); + const guardedFetch = getAiTransportHost().buildModelFetch({ ...model, baseUrl }); if (isOpenAICompatibleAzureResponsesBaseUrl(baseUrl)) { return new OpenAI({ diff --git a/src/llm/providers/cache-retention.ts b/packages/ai/src/providers/cache-retention.ts similarity index 100% rename from src/llm/providers/cache-retention.ts rename to packages/ai/src/providers/cache-retention.ts diff --git a/src/agents/schema/clean-for-gemini.test.ts b/packages/ai/src/providers/clean-for-gemini.test.ts similarity index 100% rename from src/agents/schema/clean-for-gemini.test.ts rename to packages/ai/src/providers/clean-for-gemini.test.ts diff --git a/src/agents/schema/clean-for-gemini.ts b/packages/ai/src/providers/clean-for-gemini.ts similarity index 100% rename from src/agents/schema/clean-for-gemini.ts rename to packages/ai/src/providers/clean-for-gemini.ts diff --git a/src/llm/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts similarity index 100% rename from src/llm/providers/cloudflare.ts rename to packages/ai/src/providers/cloudflare.ts diff --git a/src/llm/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts similarity index 100% rename from src/llm/providers/github-copilot-headers.ts rename to packages/ai/src/providers/github-copilot-headers.ts diff --git a/src/llm/providers/google-shared.convert.test.ts b/packages/ai/src/providers/google-shared.convert.test.ts similarity index 100% rename from src/llm/providers/google-shared.convert.test.ts rename to packages/ai/src/providers/google-shared.convert.test.ts diff --git a/src/llm/providers/google-shared.parallel-image-repro.test.ts b/packages/ai/src/providers/google-shared.parallel-image-repro.test.ts similarity index 100% rename from src/llm/providers/google-shared.parallel-image-repro.test.ts rename to packages/ai/src/providers/google-shared.parallel-image-repro.test.ts diff --git a/src/llm/providers/google-shared.test-helpers.ts b/packages/ai/src/providers/google-shared.test-helpers.ts similarity index 100% rename from src/llm/providers/google-shared.test-helpers.ts rename to packages/ai/src/providers/google-shared.test-helpers.ts diff --git a/src/llm/providers/google-shared.test.ts b/packages/ai/src/providers/google-shared.test.ts similarity index 98% rename from src/llm/providers/google-shared.test.ts rename to packages/ai/src/providers/google-shared.test.ts index 488b86f47eb..1be3fc7d876 100644 --- a/src/llm/providers/google-shared.test.ts +++ b/packages/ai/src/providers/google-shared.test.ts @@ -1,9 +1,9 @@ // Google shared provider tests cover response conversion and finish reasons. import { FinishReason, type GenerateContentResponse } from "@google/genai"; import { describe, expect, it } from "vitest"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; import type { AssistantMessage, Model } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; import { buildGoogleGenerateContentParams, consumeGoogleGenerateContentStream, diff --git a/src/llm/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts similarity index 99% rename from src/llm/providers/google-shared.ts rename to packages/ai/src/providers/google-shared.ts index c332a6b0001..1efee30bb52 100644 --- a/src/llm/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -12,7 +12,6 @@ import { type Part, type ThinkingConfig, } from "@google/genai"; -import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; import { calculateCost, clampThinkingLevel } from "../model-utils.js"; import type { Api, @@ -32,6 +31,7 @@ import type { } from "../types.js"; import type { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js"; import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js"; import { transformMessages } from "./transform-messages.js"; diff --git a/src/llm/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts similarity index 100% rename from src/llm/providers/google-vertex.ts rename to packages/ai/src/providers/google-vertex.ts diff --git a/src/llm/providers/google.ts b/packages/ai/src/providers/google.ts similarity index 100% rename from src/llm/providers/google.ts rename to packages/ai/src/providers/google.ts diff --git a/src/llm/providers/mistral.bounded-stream.test.ts b/packages/ai/src/providers/mistral.bounded-stream.test.ts similarity index 100% rename from src/llm/providers/mistral.bounded-stream.test.ts rename to packages/ai/src/providers/mistral.bounded-stream.test.ts diff --git a/src/llm/providers/mistral.test.ts b/packages/ai/src/providers/mistral.test.ts similarity index 95% rename from src/llm/providers/mistral.test.ts rename to packages/ai/src/providers/mistral.test.ts index a204bfab22f..b0c79af405b 100644 --- a/src/llm/providers/mistral.test.ts +++ b/packages/ai/src/providers/mistral.test.ts @@ -1,7 +1,8 @@ // Mistral provider tests cover request mapping and stream conversion. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureAiTransportHost } from "../host.js"; import type { Context, Model } from "../types.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; const mistralMockState = vi.hoisted(() => ({ payloads: [] as unknown[], @@ -72,6 +73,10 @@ describe("Mistral provider", () => { mistralMockState.payloads = []; }); + afterEach(() => { + configureAiTransportHost({}); + }); + it("forwards simple stop sequences to Mistral stop", async () => { const stream = streamSimpleMistral(makeMistralModel(), context, { apiKey: "sk-mistral-provider", @@ -247,6 +252,10 @@ describe("Mistral provider", () => { }); it("serializes structured non-image blocks in tool results as JSON text", async () => { + // Prove the host redaction port is applied to structured tool-result text. + configureAiTransportHost({ + redactToolPayloadText: (text) => text.replaceAll('"value"', '"***"'), + }); const testContext = { messages: [ { diff --git a/src/llm/providers/mistral.ts b/packages/ai/src/providers/mistral.ts similarity index 99% rename from src/llm/providers/mistral.ts rename to packages/ai/src/providers/mistral.ts index 294e1d24192..ee8516f1baa 100644 --- a/src/llm/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -7,8 +7,6 @@ import type { ContentChunk, FunctionTool, } from "@mistralai/mistralai/models/components"; -import { createSseByteGuard } from "../../agents/streaming-byte-guard.js"; -import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; import { getEnvApiKey } from "../env-api-keys.js"; import { calculateCost, clampThinkingLevel } from "../model-utils.js"; import type { @@ -29,6 +27,8 @@ import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { shortHash } from "../utils/hash.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { createSseByteGuard } from "../utils/streaming-byte-guard.js"; +import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js"; import { buildBaseOptions } from "./simple-options.js"; import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js"; import { transformMessages } from "./transform-messages.js"; diff --git a/src/llm/providers/openai-chatgpt-responses.test.ts b/packages/ai/src/providers/openai-chatgpt-responses.test.ts similarity index 99% rename from src/llm/providers/openai-chatgpt-responses.test.ts rename to packages/ai/src/providers/openai-chatgpt-responses.test.ts index d5c327897e6..dc85a2bddcc 100644 --- a/src/llm/providers/openai-chatgpt-responses.test.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.test.ts @@ -3,8 +3,8 @@ import { zstdDecompressSync } from "node:zlib"; // ChatGPT Responses provider tests cover stream handling and timeout behavior. import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; import type { Context, Model } from "../types.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; import { closeOpenAICodexWebSocketSessions, extractOpenAICodexAccountId, diff --git a/src/llm/providers/openai-chatgpt-responses.ts b/packages/ai/src/providers/openai-chatgpt-responses.ts similarity index 99% rename from src/llm/providers/openai-chatgpt-responses.ts rename to packages/ai/src/providers/openai-chatgpt-responses.ts index 98cc4317f54..9d32e9a532d 100644 --- a/src/llm/providers/openai-chatgpt-responses.ts +++ b/packages/ai/src/providers/openai-chatgpt-responses.ts @@ -30,13 +30,6 @@ import { resolveTimerTimeoutMs, clampTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; -import { - createFirstStreamEventAbortController, - getFirstStreamEventTimeoutHandler, - getFirstStreamEventTimeoutMs, -} from "../../agents/stream-first-event-timeout.js"; -import { createSseByteGuard } from "../../agents/streaming-byte-guard.js"; -import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; import { getEnvApiKey } from "../env-api-keys.js"; import { registerSessionResourceCleanup } from "../session-resources.js"; import type { @@ -57,6 +50,13 @@ import { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { resolveOpenAICodexAccountId } from "../utils/oauth/openai-chatgpt-jwt.js"; +import { + createFirstStreamEventAbortController, + getFirstStreamEventTimeoutHandler, + getFirstStreamEventTimeoutMs, +} from "../utils/stream-first-event-timeout.js"; +import { createSseByteGuard } from "../utils/streaming-byte-guard.js"; +import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { convertResponsesMessages, diff --git a/src/llm/providers/openai-completions.test.ts b/packages/ai/src/providers/openai-completions.test.ts similarity index 97% rename from src/llm/providers/openai-completions.test.ts rename to packages/ai/src/providers/openai-completions.test.ts index 620a47b0f27..5f25403bc46 100644 --- a/src/llm/providers/openai-completions.test.ts +++ b/packages/ai/src/providers/openai-completions.test.ts @@ -1,8 +1,9 @@ // OpenAI completions tests cover chat completion stream adaptation. import type { ChatCompletionChunk } from "openai/resources/chat/completions.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; +import { configureAiTransportHost } from "../host.js"; import type { Context, Model, SimpleStreamOptions } from "../types.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; type DeepPartial = { [P in keyof T]?: DeepPartial }; type OpenAICompatibleDelta = DeepPartial & { @@ -160,24 +161,28 @@ function createNeverYieldingStream(): AsyncIterable { - it("configures the OpenAI SDK client with guarded fetch", async () => { + it("configures the OpenAI SDK client with the host-built model fetch", async () => { mockOpenAIOptionsRef.options = []; mockChunksRef.chunks = [makeTextChunk("ok"), makeFinishChunk("stop")]; + const hostFetch: typeof fetch = async () => new Response(null, { status: 500 }); + configureAiTransportHost({ buildModelFetch: () => hostFetch }); - const stream = streamOpenAICompletions(model, context, { - apiKey: "sk-test", - }); - const result = await stream.result(); + try { + const stream = streamOpenAICompletions(model, context, { + apiKey: "sk-test", + }); + const result = await stream.result(); - expect(result.stopReason).toBe("stop"); - expect(mockOpenAIOptionsRef.options).toHaveLength(1); - expect(mockOpenAIOptionsRef.options[0]).toMatchObject({ - baseURL: "https://api.openai.com/v1", - dangerouslyAllowBrowser: true, - }); - expect((mockOpenAIOptionsRef.options[0] as { fetch?: unknown }).fetch).toEqual( - expect.any(Function), - ); + expect(result.stopReason).toBe("stop"); + expect(mockOpenAIOptionsRef.options).toHaveLength(1); + expect(mockOpenAIOptionsRef.options[0]).toMatchObject({ + baseURL: "https://api.openai.com/v1", + dangerouslyAllowBrowser: true, + }); + expect((mockOpenAIOptionsRef.options[0] as { fetch?: unknown }).fetch).toBe(hostFetch); + } finally { + configureAiTransportHost({}); + } }); it("fails when streaming headers arrive but no first SSE event follows", async () => { diff --git a/src/llm/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts similarity index 99% rename from src/llm/providers/openai-completions.ts rename to packages/ai/src/providers/openai-completions.ts index 65ef2abea4f..4f19ba0236e 100644 --- a/src/llm/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -11,25 +11,8 @@ import type { ChatCompletionSystemMessageParam, ChatCompletionToolMessageParam, } from "openai/resources/chat/completions.js"; -import { - projectOpenAITools, - reconcileOpenAICompletionsToolChoice, - type OpenAICompletionsToolChoice, - type OpenAIToolProjection, -} from "../../agents/openai-tool-projection.js"; -import { buildGuardedModelFetch } from "../../agents/provider-transport-fetch.js"; -import { - createFirstStreamEventAbortController, - getFirstStreamEventTimeoutHandler, - getFirstStreamEventTimeoutMs, - withFirstStreamEventTimeout, -} from "../../agents/stream-first-event-timeout.js"; -import { - splitSystemPromptCacheBoundary, - stripSystemPromptCacheBoundary, -} from "../../agents/system-prompt-cache-boundary.js"; -import { createReasoningTagTextPartitioner } from "../../shared/text/reasoning-tag-text-partitioner.js"; import { getEnvApiKey } from "../env-api-keys.js"; +import { getAiTransportHost } from "../host.js"; import { calculateCost, clampThinkingLevel } from "../model-utils.js"; import type { AssistantMessage, @@ -51,12 +34,29 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { headersToRecord } from "../utils/headers.js"; import { parseStreamingJson } from "../utils/json-parse.js"; +import { createReasoningTagTextPartitioner } from "../utils/reasoning-tag-text-partitioner.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { + createFirstStreamEventAbortController, + getFirstStreamEventTimeoutHandler, + getFirstStreamEventTimeoutMs, + withFirstStreamEventTimeout, +} from "../utils/stream-first-event-timeout.js"; +import { + splitSystemPromptCacheBoundary, + stripSystemPromptCacheBoundary, +} from "../utils/system-prompt-cache-boundary.js"; import { resolveCacheRetention } from "./cache-retention.js"; import { isCloudflareProvider, resolveCloudflareBaseUrl } from "./cloudflare.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.js"; import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js"; import { mapOpenAIStopReason } from "./openai-stop-reason.js"; +import { + projectOpenAITools, + reconcileOpenAICompletionsToolChoice, + type OpenAICompletionsToolChoice, + type OpenAIToolProjection, +} from "./openai-tool-projection.js"; import { buildBaseOptions } from "./simple-options.js"; import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js"; import { transformMessages } from "./transform-messages.js"; @@ -638,7 +638,7 @@ function createClient( baseURL: isCloudflareProvider(model.provider) ? resolveCloudflareBaseUrl(model) : model.baseUrl, dangerouslyAllowBrowser: true, defaultHeaders, - fetch: buildGuardedModelFetch(model), + fetch: getAiTransportHost().buildModelFetch(model), }); } diff --git a/src/llm/providers/openai-prompt-cache.ts b/packages/ai/src/providers/openai-prompt-cache.ts similarity index 100% rename from src/llm/providers/openai-prompt-cache.ts rename to packages/ai/src/providers/openai-prompt-cache.ts diff --git a/src/agents/openai-reasoning-effort.test.ts b/packages/ai/src/providers/openai-reasoning-effort.test.ts similarity index 100% rename from src/agents/openai-reasoning-effort.test.ts rename to packages/ai/src/providers/openai-reasoning-effort.test.ts diff --git a/src/agents/openai-reasoning-effort.ts b/packages/ai/src/providers/openai-reasoning-effort.ts similarity index 100% rename from src/agents/openai-reasoning-effort.ts rename to packages/ai/src/providers/openai-reasoning-effort.ts diff --git a/src/llm/providers/openai-responses-shared.test.ts b/packages/ai/src/providers/openai-responses-shared.test.ts similarity index 98% rename from src/llm/providers/openai-responses-shared.test.ts rename to packages/ai/src/providers/openai-responses-shared.test.ts index ac0ad05570d..af9e8c42d25 100644 --- a/src/llm/providers/openai-responses-shared.test.ts +++ b/packages/ai/src/providers/openai-responses-shared.test.ts @@ -3,10 +3,11 @@ import type { ResponseStreamEvent, Tool as OpenAIResponsesTool, } from "openai/resources/responses/responses.js"; -import { describe, expect, it, vi } from "vitest"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-boundary.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { configureAiTransportHost } from "../host.js"; import type { AssistantMessage, AssistantMessageEvent, Context, Model, Tool } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-boundary.js"; import { applyCommonResponsesParams, createResponsesAssistantOutput, @@ -117,6 +118,23 @@ async function* responseEvents(events: Array>) { } describe("convertResponsesTools", () => { + beforeEach(() => { + // Mimic the OpenClaw host strict-tool policy: native OpenAI routes force + // strict=true, proxy-like routes leave the flag unset. + configureAiTransportHost({ + resolveOpenAIStrictToolSetting: (model, options) => { + if (model.provider === "openai" && model.baseUrl === "https://api.openai.com/v1") { + return true; + } + return options?.supportsStrictMode ? false : undefined; + }, + }); + }); + + afterEach(() => { + configureAiTransportHost({}); + }); + it("enables native strict OpenAI Responses tools and normalizes schemas", () => { const tools = [ { diff --git a/src/llm/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts similarity index 99% rename from src/llm/providers/openai-responses-shared.ts rename to packages/ai/src/providers/openai-responses-shared.ts index 793cddd3d57..9f3922fc65f 100644 --- a/src/llm/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -13,27 +13,6 @@ import type { ResponseReasoningItem, ResponseStreamEvent, } from "openai/resources/responses/responses.js"; -import { - resolveOpenAIReasoningEffortForModel, - supportsOpenAIReasoningEffort, -} from "../../agents/openai-reasoning-effort.js"; -import { - createFirstStreamEventAbortController, - getFirstStreamEventTimeoutHandler, - getFirstStreamEventTimeoutMs, - type FirstStreamEventInternalOptions, - withFirstStreamEventTimeout, -} from "../../agents/stream-first-event-timeout.js"; -import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js"; -import { - AZURE_RESPONSES_TEXT_CONTENT_PART_TYPE, - OPENAI_RESPONSES_OUTPUT_TEXT_CONTENT_PART_TYPE, - type AzureResponsesTextContentPart, - type AzureResponsesTextDeltaEvent, - isAzureResponsesTextDeltaEvent, - isResponsesTextContentPartType, - resolveResponsesMessageSnapshotCollapse, -} from "../../shared/openai-responses-stream-compat.js"; import { calculateCost, clampThinkingLevel } from "../model-utils.js"; import type { Api, @@ -55,6 +34,27 @@ import { shortHash } from "../utils/hash.js"; import { headersToRecord } from "../utils/headers.js"; import { parseStreamingJson } from "../utils/json-parse.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { + createFirstStreamEventAbortController, + getFirstStreamEventTimeoutHandler, + getFirstStreamEventTimeoutMs, + type FirstStreamEventInternalOptions, + withFirstStreamEventTimeout, +} from "../utils/stream-first-event-timeout.js"; +import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js"; +import { + resolveOpenAIReasoningEffortForModel, + supportsOpenAIReasoningEffort, +} from "./openai-reasoning-effort.js"; +import { + AZURE_RESPONSES_TEXT_CONTENT_PART_TYPE, + OPENAI_RESPONSES_OUTPUT_TEXT_CONTENT_PART_TYPE, + type AzureResponsesTextContentPart, + type AzureResponsesTextDeltaEvent, + isAzureResponsesTextDeltaEvent, + isResponsesTextContentPartType, + resolveResponsesMessageSnapshotCollapse, +} from "./openai-responses-stream-compat.js"; import { convertResponsesToolPayload, convertResponsesTools } from "./openai-responses-tools.js"; import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js"; import { transformMessages } from "./transform-messages.js"; diff --git a/src/shared/openai-responses-stream-compat.ts b/packages/ai/src/providers/openai-responses-stream-compat.ts similarity index 100% rename from src/shared/openai-responses-stream-compat.ts rename to packages/ai/src/providers/openai-responses-stream-compat.ts diff --git a/src/llm/providers/openai-responses-tools.ts b/packages/ai/src/providers/openai-responses-tools.ts similarity index 86% rename from src/llm/providers/openai-responses-tools.ts rename to packages/ai/src/providers/openai-responses-tools.ts index 04fae8a8802..7bad12ab383 100644 --- a/src/llm/providers/openai-responses-tools.ts +++ b/packages/ai/src/providers/openai-responses-tools.ts @@ -1,18 +1,14 @@ // OpenAI Responses tool helpers convert runtime tools to Responses API schemas. import { createHash } from "node:crypto"; import type { Tool as OpenAITool } from "openai/resources/responses/responses.js"; -import { resolveOpenAIStrictToolSetting } from "../../agents/openai-strict-tool-setting.js"; -import { - projectOpenAITools, - type OpenAIToolProjection, -} from "../../agents/openai-tool-projection.js"; +import { getAiTransportHost } from "../host.js"; +import type { Model, Tool } from "../types.js"; +import { projectOpenAITools, type OpenAIToolProjection } from "./openai-tool-projection.js"; import { findOpenAIStrictToolProjectionDiagnostics, normalizeOpenAIStrictToolParameters, resolveOpenAIProjectedToolsStrictToolFlag, -} from "../../agents/openai-tool-schema.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; -import type { Model, Tool } from "../types.js"; +} from "./openai-tool-schema.js"; /** Options for converting internal tool schemas to OpenAI Responses function tools. */ export interface ConvertResponsesToolsOptions { @@ -36,7 +32,7 @@ export type ConvertedResponsesTools = { }; // Converts OpenClaw tool schemas to OpenAI Responses tools, including strict-mode compatibility. -const log = createSubsystemLogger("llm/openai-responses"); +const LOG_SUBSYSTEM = "llm/openai-responses"; const MAX_STRICT_TOOL_DOWNGRADE_DIAGNOSTIC_KEYS = 64; const loggedStrictToolDowngradeDiagnosticKeys = new Set(); @@ -83,7 +79,7 @@ function resolveResponsesStrictToolSetting( return options.strict; } if (options?.model) { - return resolveOpenAIStrictToolSetting(options.model, { + return getAiTransportHost().resolveOpenAIStrictToolSetting(options.model, { transport: "stream", supportsStrictMode: options.supportsStrictMode, }); @@ -97,25 +93,29 @@ function resolveResponsesStrictToolFlag( model: Model | undefined, ): boolean | undefined { const strict = resolveOpenAIProjectedToolsStrictToolFlag(projection, strictSetting); - if (strictSetting === true && strict === false && model && log.isEnabled("debug", "any")) { - const diagnostics = findOpenAIStrictToolProjectionDiagnostics(projection); - if (shouldLogStrictToolDowngradeDiagnostic(diagnostics, model)) { + if (strictSetting === true && strict === false && model) { + getAiTransportHost().logDebug(LOG_SUBSYSTEM, () => { + const diagnostics = findOpenAIStrictToolProjectionDiagnostics(projection); + if (!shouldLogStrictToolDowngradeDiagnostic(diagnostics, model)) { + return null; + } const sample = diagnostics.slice(0, 5).map((entry) => ({ tool: entry.toolName ?? `tool[${entry.toolIndex}]`, violations: entry.violations.slice(0, 8), })); - log.debug( - `OpenAI responses tool schema strict mode downgraded to strict=false for ` + + return { + message: + `OpenAI responses tool schema strict mode downgraded to strict=false for ` + `${model.provider ?? "unknown"}/${model.id ?? "unknown"} because ` + `${diagnostics.length} tool schema(s) are not strict-compatible`, - { + data: { provider: model.provider, model: model.id, incompatibleToolCount: diagnostics.length, sample, }, - ); - } + }; + }); } return strict; } diff --git a/src/llm/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts similarity index 100% rename from src/llm/providers/openai-responses.ts rename to packages/ai/src/providers/openai-responses.ts diff --git a/src/llm/providers/openai-stop-reason.test.ts b/packages/ai/src/providers/openai-stop-reason.test.ts similarity index 100% rename from src/llm/providers/openai-stop-reason.test.ts rename to packages/ai/src/providers/openai-stop-reason.test.ts diff --git a/src/llm/providers/openai-stop-reason.ts b/packages/ai/src/providers/openai-stop-reason.ts similarity index 100% rename from src/llm/providers/openai-stop-reason.ts rename to packages/ai/src/providers/openai-stop-reason.ts diff --git a/src/agents/openai-tool-projection.test.ts b/packages/ai/src/providers/openai-tool-projection.test.ts similarity index 100% rename from src/agents/openai-tool-projection.test.ts rename to packages/ai/src/providers/openai-tool-projection.test.ts diff --git a/src/agents/openai-tool-projection.ts b/packages/ai/src/providers/openai-tool-projection.ts similarity index 100% rename from src/agents/openai-tool-projection.ts rename to packages/ai/src/providers/openai-tool-projection.ts diff --git a/src/agents/openai-tool-schema.test.ts b/packages/ai/src/providers/openai-tool-schema.test.ts similarity index 100% rename from src/agents/openai-tool-schema.test.ts rename to packages/ai/src/providers/openai-tool-schema.test.ts diff --git a/src/agents/openai-tool-schema.ts b/packages/ai/src/providers/openai-tool-schema.ts similarity index 97% rename from src/agents/openai-tool-schema.ts rename to packages/ai/src/providers/openai-tool-schema.ts index 69ea173ae66..e9679186d54 100644 --- a/src/agents/openai-tool-schema.ts +++ b/packages/ai/src/providers/openai-tool-schema.ts @@ -3,9 +3,11 @@ * * Caches normalized object inputs by provider compatibility so repeated inventory builds preserve identity. */ -import type { ModelCompatConfig } from "../config/types.models.js"; -import { shouldOmitEmptyArrayItems } from "../plugins/provider-model-compat.js"; -import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js"; +import { + normalizeToolParameterSchema, + shouldOmitEmptyArrayItems, + type ToolSchemaModelCompat, +} from "./agent-tools-parameter-schema.js"; import type { OpenAIToolProjection } from "./openai-tool-projection.js"; /** @@ -24,7 +26,7 @@ let strictOpenAISchemaCache = new WeakMap = { + stream: StreamFunction; + streamSimple: StreamFunction; +}; + +type RegisterBuiltIn = (registry: ApiRegistry) => void; + +/** Source id used for built-in API provider registrations. */ +export const BUILT_IN_API_PROVIDER_SOURCE_ID = "core:built-in"; + +function forwardStream( + target: AssistantMessageEventStream, + source: AsyncIterable, +): void { + void (async () => { + for await (const event of source) { + target.push(event); + } + target.end(); + })(); +} + +function createLazyLoadErrorMessage( + model: Model, + error: unknown, +): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +// Provider modules load on first use, while callers still receive a stream synchronously. +function createLazyStream( + load: () => Promise, + select: (streams: TStreams) => StreamFunction, +): StreamFunction { + return (model, context, options) => { + const outer = new AssistantMessageEventStream(); + load() + .then((streams) => forwardStream(outer, select(streams)(model, context, options))) + .catch((error: unknown) => { + const message = createLazyLoadErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + return outer; + }; +} + +function createLazyRegistration( + api: TApi, + importModule: () => Promise, + select: (module: TModule) => ProviderStreams, +): RegisterBuiltIn { + let streamsPromise: Promise> | undefined; + const load = () => (streamsPromise ??= importModule().then(select)); + const stream = createLazyStream(load, (streams) => streams.stream); + const streamSimple = createLazyStream>( + load, + (streams) => streams.streamSimple, + ); + return (registry) => { + registry.registerApiProvider({ api, stream, streamSimple }, BUILT_IN_API_PROVIDER_SOURCE_ID); + }; +} + +const registerBuiltIns: RegisterBuiltIn[] = [ + createLazyRegistration( + "anthropic-messages", + () => import("./anthropic.js"), + (module) => ({ stream: module.streamAnthropic, streamSimple: module.streamSimpleAnthropic }), + ), + createLazyRegistration( + "openai-completions", + () => import("./openai-completions.js"), + (module) => ({ + stream: module.streamOpenAICompletions, + streamSimple: module.streamSimpleOpenAICompletions, + }), + ), + createLazyRegistration( + "mistral-conversations", + () => import("./mistral.js"), + (module) => ({ stream: module.streamMistral, streamSimple: module.streamSimpleMistral }), + ), + createLazyRegistration( + "openai-responses", + () => import("./openai-responses.js"), + (module) => ({ + stream: module.streamOpenAIResponses, + streamSimple: module.streamSimpleOpenAIResponses, + }), + ), + createLazyRegistration( + "azure-openai-responses", + () => import("./azure-openai-responses.js"), + (module) => ({ + stream: module.streamAzureOpenAIResponses, + streamSimple: module.streamSimpleAzureOpenAIResponses, + }), + ), + createLazyRegistration( + "openai-chatgpt-responses", + () => import("./openai-chatgpt-responses.js"), + (module) => ({ + stream: module.streamOpenAICodexResponses, + streamSimple: module.streamSimpleOpenAICodexResponses, + }), + ), + createLazyRegistration( + "google-generative-ai", + () => import("./google.js"), + (module) => ({ stream: module.streamGoogle, streamSimple: module.streamSimpleGoogle }), + ), + createLazyRegistration( + "google-vertex", + () => import("./google-vertex.js"), + (module) => ({ + stream: module.streamGoogleVertex, + streamSimple: module.streamSimpleGoogleVertex, + }), + ), +]; + +/** Registers every built-in API provider in one runtime registry. */ +export function registerBuiltInApiProviders(registry: ApiRegistry): void { + for (const register of registerBuiltIns) { + register(registry); + } +} + +/** Restores the built-in provider registry state for tests. */ +export function resetApiProviders(registry: ApiRegistry): void { + registry.unregisterApiProviders(BUILT_IN_API_PROVIDER_SOURCE_ID); + registerBuiltInApiProviders(registry); +} diff --git a/src/shared/schema-keyword-strip.ts b/packages/ai/src/providers/schema-keyword-strip.ts similarity index 100% rename from src/shared/schema-keyword-strip.ts rename to packages/ai/src/providers/schema-keyword-strip.ts diff --git a/src/llm/providers/simple-options.ts b/packages/ai/src/providers/simple-options.ts similarity index 100% rename from src/llm/providers/simple-options.ts rename to packages/ai/src/providers/simple-options.ts diff --git a/src/llm/providers/tool-result-text.test.ts b/packages/ai/src/providers/tool-result-text.test.ts similarity index 55% rename from src/llm/providers/tool-result-text.test.ts rename to packages/ai/src/providers/tool-result-text.test.ts index 31e0beb178c..c271d04f921 100644 --- a/src/llm/providers/tool-result-text.test.ts +++ b/packages/ai/src/providers/tool-result-text.test.ts @@ -2,73 +2,6 @@ import { describe, expect, it } from "vitest"; import { describeToolResultMediaPlaceholder, extractToolResultText } from "./tool-result-text.js"; describe("extractToolResultText", () => { - it("redacts structured secret fields with the shared tool-payload contract", () => { - const text = extractToolResultText([ - { - type: "json", - apiToken: "api-token-value-1234567890", - privateKey: "private-key-value-1234567890", - private_key: "private-key-snake-1234567890", - key: "generic-key-value-1234567890", - keyMaterial: "key-material-value-1234567890", - bearerToken: "bearer-token-value-1234567890", - bearer_token: "bearer-token-snake-value-1234567890", - jwt: "jwt-value-1234567890", - session: "session-value-1234567890", - code: "code-value-1234567890", - error: { code: "ERR_VISIBLE_PROVIDER_CODE" }, - oauth: { code: "OPAQUEPROVIDERCODE1234567890" }, - providerError: { error: { code: "ERR_VISIBLE_PROVIDER_NESTED_CODE" } }, - signature: "signature-value-1234567890", - cookie: "cookie-value-1234567890", - "set-cookie": "set-cookie-value-1234567890", - paymentCredential: "payment-credential-value-1234567890", - cardNumber: 4111111111111111, - cvc: 123, - text: '{"apiToken":"api-token-in-text-1234567890","code":"oauth-code-in-text-1234567890","safe":"ok"}', - credential: "live-credential-value", - appSecret: "app-secret-value", - rawSecret: "raw-secret-value", - nested: { - token: "nested-token-value", - visible: "safe-value", - }, - }, - ]); - - expect(text).toContain('"credential":"'); - expect(text).toContain('"appSecret":"'); - expect(text).toContain('"rawSecret":"'); - expect(text).toContain('"token":"'); - expect(text).toContain('"visible":"safe-value"'); - expect(text).toContain('"code":"ERR_VISIBLE_PROVIDER_CODE"'); - expect(text).toContain('"code":"ERR_VISIBLE_PROVIDER_NESTED_CODE"'); - expect(text).not.toContain("api-token-value-1234567890"); - expect(text).not.toContain("private-key-value-1234567890"); - expect(text).not.toContain("private-key-snake-1234567890"); - expect(text).not.toContain("generic-key-value-1234567890"); - expect(text).not.toContain("key-material-value-1234567890"); - expect(text).not.toContain("bearer-token-value-1234567890"); - expect(text).not.toContain("bearer-token-snake-value-1234567890"); - expect(text).not.toContain("jwt-value-1234567890"); - expect(text).not.toContain("session-value-1234567890"); - expect(text).not.toContain("code-value-1234567890"); - expect(text).not.toContain("OPAQUEPROVIDERCODE1234567890"); - expect(text).not.toContain("signature-value-1234567890"); - expect(text).not.toContain("cookie-value-1234567890"); - expect(text).not.toContain("set-cookie-value-1234567890"); - expect(text).not.toContain("payment-credential-value-1234567890"); - expect(text).not.toContain("4111111111111111"); - expect(text).not.toContain('"cvc":123'); - expect(text).not.toContain("api-token-in-text-1234567890"); - expect(text).not.toContain("oauth-code-in-text-1234567890"); - expect(text).toContain('\\"safe\\":\\"ok\\"'); - expect(text).not.toContain("live-credential-value"); - expect(text).not.toContain("app-secret-value"); - expect(text).not.toContain("raw-secret-value"); - expect(text).not.toContain("nested-token-value"); - }); - it("keeps media-only blocks out of provider replay text", () => { const text = extractToolResultText([ { type: "text", text: "summary" }, diff --git a/src/llm/providers/tool-result-text.ts b/packages/ai/src/providers/tool-result-text.ts similarity index 93% rename from src/llm/providers/tool-result-text.ts rename to packages/ai/src/providers/tool-result-text.ts index 252c474bffc..6e48c787aae 100644 --- a/src/llm/providers/tool-result-text.ts +++ b/packages/ai/src/providers/tool-result-text.ts @@ -1,6 +1,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; -import { redactSecrets, redactToolPayloadText } from "../../logging/redact.js"; -import { truncateUtf16Safe } from "../../shared/utf16-slice.js"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { getAiTransportHost } from "../host.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; const PROVIDER_TOOL_RESULT_MAX_CHARS = 8000; @@ -55,13 +55,14 @@ function redactInlineDataUris(value: string): string { } function redactStructuredTextValue(value: string): string { - const redacted = redactToolPayloadText(value); + const host = getAiTransportHost(); + const redacted = host.redactToolPayloadText(value); const trimmed = redacted.trim(); if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { return redacted; } try { - const redactedWrapper = redactSecrets({ structuredTextValue: JSON.parse(redacted) }); + const redactedWrapper = host.redactSecrets({ structuredTextValue: JSON.parse(redacted) }); return JSON.stringify(redactedWrapper.structuredTextValue); } catch { return redacted; @@ -71,7 +72,7 @@ function redactStructuredTextValue(value: string): string { function stringifyStructuredBlock(block: Record): string | undefined { const seen = new WeakSet(); try { - const redactedWrapper = redactSecrets({ structuredToolResult: block }); + const redactedWrapper = getAiTransportHost().redactSecrets({ structuredToolResult: block }); const redactedBlock = redactedWrapper.structuredToolResult; const serialized = JSON.stringify( redactedBlock, diff --git a/src/agents/tool-schema-json-projection.ts b/packages/ai/src/providers/tool-schema-json-projection.ts similarity index 100% rename from src/agents/tool-schema-json-projection.ts rename to packages/ai/src/providers/tool-schema-json-projection.ts diff --git a/src/llm/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts similarity index 98% rename from src/llm/providers/transform-messages.ts rename to packages/ai/src/providers/transform-messages.ts index 130fd6c12c9..8f9d32bf69d 100644 --- a/src/llm/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -1,4 +1,3 @@ -import { resolveModelBoundThinkingReplayMode } from "../../shared/anthropic-model-contract.js"; // Provider message transform helpers convert runtime messages to provider payloads. import type { Api, @@ -10,6 +9,7 @@ import type { ToolCall, ToolResultMessage, } from "../types.js"; +import { resolveModelBoundThinkingReplayMode } from "./anthropic-model-contract.js"; const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)"; const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)"; diff --git a/src/llm/session-resources.ts b/packages/ai/src/session-resources.ts similarity index 100% rename from src/llm/session-resources.ts rename to packages/ai/src/session-resources.ts diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts new file mode 100644 index 00000000000..f0ee5223d4e --- /dev/null +++ b/packages/ai/src/stream.ts @@ -0,0 +1,59 @@ +// LLM Runtime module implements stream behavior. +import type { + Api, + AssistantMessage, + AssistantMessageEventStreamContract, + Context, + Model, + ProviderStreamOptions, + SimpleStreamOptions, + StreamOptions, +} from "@openclaw/llm-core"; +import { createApiRegistry, type ApiRegistry } from "./api-registry.js"; + +/** Creates an isolated LLM runtime backed by the supplied provider registry. */ +export function createLlmRuntime(registry: ApiRegistry = createApiRegistry()) { + function resolveApiProvider(api: Api) { + const provider = registry.getApiProvider(api); + if (!provider) { + throw new Error(`No API provider registered for api: ${api}`); + } + return provider; + } + + function stream( + model: Model, + context: Context, + options?: ProviderStreamOptions, + ): AssistantMessageEventStreamContract { + return resolveApiProvider(model.api).stream(model, context, options as StreamOptions); + } + + async function complete( + model: Model, + context: Context, + options?: ProviderStreamOptions, + ): Promise { + return stream(model, context, options).result(); + } + + function streamSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, + ): AssistantMessageEventStreamContract { + return resolveApiProvider(model.api).streamSimple(model, context, options); + } + + async function completeSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, + ): Promise { + return streamSimple(model, context, options).result(); + } + + return { registry, stream, complete, streamSimple, completeSimple }; +} + +export type LlmRuntime = ReturnType; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts new file mode 100644 index 00000000000..02571a191e0 --- /dev/null +++ b/packages/ai/src/types.ts @@ -0,0 +1,2 @@ +/** Shared model, message, tool, and streaming contracts. */ +export * from "@openclaw/llm-core"; diff --git a/src/shared/deferred-event-buffer.ts b/packages/ai/src/utils/deferred-event-buffer.ts similarity index 100% rename from src/shared/deferred-event-buffer.ts rename to packages/ai/src/utils/deferred-event-buffer.ts diff --git a/packages/ai/src/utils/diagnostics.ts b/packages/ai/src/utils/diagnostics.ts new file mode 100644 index 00000000000..d5224eefb3d --- /dev/null +++ b/packages/ai/src/utils/diagnostics.ts @@ -0,0 +1,2 @@ +/** Shared provider diagnostics. */ +export * from "@openclaw/llm-core/diagnostics"; diff --git a/packages/ai/src/utils/event-stream.ts b/packages/ai/src/utils/event-stream.ts new file mode 100644 index 00000000000..7a206d45ffa --- /dev/null +++ b/packages/ai/src/utils/event-stream.ts @@ -0,0 +1,2 @@ +/** Assistant message event stream implementation. */ +export * from "@openclaw/llm-core/event-stream"; diff --git a/src/llm/utils/hash.ts b/packages/ai/src/utils/hash.ts similarity index 100% rename from src/llm/utils/hash.ts rename to packages/ai/src/utils/hash.ts diff --git a/src/llm/utils/headers.ts b/packages/ai/src/utils/headers.ts similarity index 100% rename from src/llm/utils/headers.ts rename to packages/ai/src/utils/headers.ts diff --git a/src/llm/utils/json-parse.ts b/packages/ai/src/utils/json-parse.ts similarity index 100% rename from src/llm/utils/json-parse.ts rename to packages/ai/src/utils/json-parse.ts diff --git a/src/shared/llm-request-activity.ts b/packages/ai/src/utils/llm-request-activity.ts similarity index 100% rename from src/shared/llm-request-activity.ts rename to packages/ai/src/utils/llm-request-activity.ts diff --git a/src/llm/utils/oauth/openai-chatgpt-jwt.ts b/packages/ai/src/utils/oauth/openai-chatgpt-jwt.ts similarity index 100% rename from src/llm/utils/oauth/openai-chatgpt-jwt.ts rename to packages/ai/src/utils/oauth/openai-chatgpt-jwt.ts diff --git a/src/llm/utils/overflow.test.ts b/packages/ai/src/utils/overflow.test.ts similarity index 100% rename from src/llm/utils/overflow.test.ts rename to packages/ai/src/utils/overflow.test.ts diff --git a/src/llm/utils/overflow.ts b/packages/ai/src/utils/overflow.ts similarity index 100% rename from src/llm/utils/overflow.ts rename to packages/ai/src/utils/overflow.ts diff --git a/src/agents/prompt-cache-stability.ts b/packages/ai/src/utils/prompt-cache-stability.ts similarity index 100% rename from src/agents/prompt-cache-stability.ts rename to packages/ai/src/utils/prompt-cache-stability.ts diff --git a/src/shared/text/reasoning-tag-text-partitioner.test.ts b/packages/ai/src/utils/reasoning-tag-text-partitioner.test.ts similarity index 100% rename from src/shared/text/reasoning-tag-text-partitioner.test.ts rename to packages/ai/src/utils/reasoning-tag-text-partitioner.test.ts diff --git a/src/shared/text/reasoning-tag-text-partitioner.ts b/packages/ai/src/utils/reasoning-tag-text-partitioner.ts similarity index 98% rename from src/shared/text/reasoning-tag-text-partitioner.ts rename to packages/ai/src/utils/reasoning-tag-text-partitioner.ts index 93087d03db0..85533e357cf 100644 --- a/src/shared/text/reasoning-tag-text-partitioner.ts +++ b/packages/ai/src/utils/reasoning-tag-text-partitioner.ts @@ -3,8 +3,8 @@ import { buildCodeSpanIndex, createInlineCodeState, type InlineCodeState, -} from "../../../packages/markdown-core/src/code-spans.js"; -import type { FenceScanState } from "../../../packages/markdown-core/src/fences.js"; +} from "@openclaw/markdown-core/code-spans"; +import type { FenceScanState } from "@openclaw/markdown-core/fences"; export type ReasoningTagTextDelta = | { kind: "text"; text: string } diff --git a/src/llm/utils/sanitize-unicode.ts b/packages/ai/src/utils/sanitize-unicode.ts similarity index 100% rename from src/llm/utils/sanitize-unicode.ts rename to packages/ai/src/utils/sanitize-unicode.ts diff --git a/src/agents/stream-first-event-timeout.test.ts b/packages/ai/src/utils/stream-first-event-timeout.test.ts similarity index 100% rename from src/agents/stream-first-event-timeout.test.ts rename to packages/ai/src/utils/stream-first-event-timeout.test.ts diff --git a/src/agents/stream-first-event-timeout.ts b/packages/ai/src/utils/stream-first-event-timeout.ts similarity index 100% rename from src/agents/stream-first-event-timeout.ts rename to packages/ai/src/utils/stream-first-event-timeout.ts diff --git a/src/agents/streaming-byte-guard.ts b/packages/ai/src/utils/streaming-byte-guard.ts similarity index 100% rename from src/agents/streaming-byte-guard.ts rename to packages/ai/src/utils/streaming-byte-guard.ts diff --git a/src/agents/system-prompt-cache-boundary.test.ts b/packages/ai/src/utils/system-prompt-cache-boundary.test.ts similarity index 100% rename from src/agents/system-prompt-cache-boundary.test.ts rename to packages/ai/src/utils/system-prompt-cache-boundary.test.ts diff --git a/src/agents/system-prompt-cache-boundary.ts b/packages/ai/src/utils/system-prompt-cache-boundary.ts similarity index 100% rename from src/agents/system-prompt-cache-boundary.ts rename to packages/ai/src/utils/system-prompt-cache-boundary.ts diff --git a/packages/ai/src/validation.ts b/packages/ai/src/validation.ts new file mode 100644 index 00000000000..f856ff77311 --- /dev/null +++ b/packages/ai/src/validation.ts @@ -0,0 +1,2 @@ +/** Tool argument validation for TypeBox and JSON Schema declarations. */ +export * from "@openclaw/llm-core/validation"; diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000000..5d606da6b87 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "outDir": "dist" + }, + "include": ["src/**/*", "../../src/agents/**/*", "../../src/shared/**/*"] +} diff --git a/packages/llm-runtime/package.json b/packages/llm-runtime/package.json deleted file mode 100644 index 4999aba9618..00000000000 --- a/packages/llm-runtime/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@openclaw/llm-runtime", - "version": "0.0.0-private", - "private": true, - "files": [ - "dist" - ], - "type": "module", - "main": "./dist/index.mjs", - "types": "./dist/index.d.mts", - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" - }, - "./api-registry": { - "types": "./dist/api-registry.d.mts", - "import": "./dist/api-registry.mjs", - "default": "./dist/api-registry.mjs" - }, - "./stream": { - "types": "./dist/stream.d.mts", - "import": "./dist/stream.mjs", - "default": "./dist/stream.mjs" - } - }, - "dependencies": { - "@openclaw/llm-core": "workspace:*" - } -} diff --git a/packages/llm-runtime/src/api-registry.test.ts b/packages/llm-runtime/src/api-registry.test.ts deleted file mode 100644 index a7ec9e8e9f3..00000000000 --- a/packages/llm-runtime/src/api-registry.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// LLM Runtime tests cover api registry behavior. -import { createAssistantMessageEventStream, type Model } from "@openclaw/llm-core"; -import { afterEach, describe, expect, it } from "vitest"; -import { getApiProvider, registerApiProvider, unregisterApiProviders } from "./api-registry.js"; - -const TEST_SOURCE_ID = "test:llm-runtime-api-registry"; - -const model = { - id: "test-model", - name: "Test Model", - api: "test-api", - provider: "test-provider", - baseUrl: "https://example.invalid", - input: ["text"], - reasoning: false, - contextWindow: 1000, - maxTokens: 100, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, -} satisfies Model; - -describe("LLM API registry", () => { - afterEach(() => { - unregisterApiProviders(TEST_SOURCE_ID); - }); - - it("rejects mismatched model API calls", () => { - registerApiProvider( - { - api: "test-api", - stream: () => createAssistantMessageEventStream(), - streamSimple: () => createAssistantMessageEventStream(), - }, - TEST_SOURCE_ID, - ); - - const provider = getApiProvider("test-api"); - expect(provider).toBeDefined(); - expect(() => provider?.streamSimple({ ...model, api: "other-api" }, { messages: [] })).toThrow( - "Mismatched api: other-api expected test-api", - ); - }); -}); diff --git a/packages/llm-runtime/src/index.ts b/packages/llm-runtime/src/index.ts deleted file mode 100644 index c4f02f07819..00000000000 --- a/packages/llm-runtime/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Public LLM runtime package surface for provider registry and stream helpers. -export { - clearApiProviders, - getApiProvider, - getApiProviders, - registerApiProvider, - unregisterApiProviders, - type ApiProvider, -} from "./api-registry.js"; -export { complete, completeSimple, stream, streamSimple } from "./stream.js"; diff --git a/packages/llm-runtime/src/stream.ts b/packages/llm-runtime/src/stream.ts deleted file mode 100644 index 604eae2bcbf..00000000000 --- a/packages/llm-runtime/src/stream.ts +++ /dev/null @@ -1,60 +0,0 @@ -// LLM Runtime module implements stream behavior. -import type { - Api, - AssistantMessage, - AssistantMessageEventStreamContract, - Context, - Model, - ProviderStreamOptions, - SimpleStreamOptions, - StreamOptions, -} from "../../llm-core/src/index.js"; -import { getApiProvider } from "./api-registry.js"; - -function resolveApiProvider(api: Api) { - const provider = getApiProvider(api); - if (!provider) { - throw new Error(`No API provider registered for api: ${api}`); - } - return provider; -} - -/** Streams a provider turn through the registered implementation for the model API. */ -export function stream( - model: Model, - context: Context, - options?: ProviderStreamOptions, -): AssistantMessageEventStreamContract { - const provider = resolveApiProvider(model.api); - return provider.stream(model, context, options as StreamOptions); -} - -/** Runs a provider turn and resolves the final assistant message result. */ -export async function complete( - model: Model, - context: Context, - options?: ProviderStreamOptions, -): Promise { - const s = stream(model, context, options); - return s.result(); -} - -/** Streams a simple provider turn through the registered implementation for the model API. */ -export function streamSimple( - model: Model, - context: Context, - options?: SimpleStreamOptions, -): AssistantMessageEventStreamContract { - const provider = resolveApiProvider(model.api); - return provider.streamSimple(model, context, options); -} - -/** Runs a simple provider turn and resolves the final assistant message result. */ -export async function completeSimple( - model: Model, - context: Context, - options?: SimpleStreamOptions, -): Promise { - const s = streamSimple(model, context, options); - return s.result(); -} diff --git a/packages/llm-runtime/tsconfig.json b/packages/llm-runtime/tsconfig.json deleted file mode 100644 index 60c7df18111..00000000000 --- a/packages/llm-runtime/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist" - }, - "include": ["src/**/*"] -} diff --git a/packages/normalization-core/package.json b/packages/normalization-core/package.json index 34cac4f0974..d9c50664759 100644 --- a/packages/normalization-core/package.json +++ b/packages/normalization-core/package.json @@ -43,9 +43,14 @@ "types": "./dist/string-normalization.d.mts", "import": "./dist/string-normalization.mjs", "default": "./dist/string-normalization.mjs" + }, + "./utf16-slice": { + "types": "./dist/utf16-slice.d.mts", + "import": "./dist/utf16-slice.mjs", + "default": "./dist/utf16-slice.mjs" } }, "scripts": { - "build": "tsdown src/index.ts src/boolean-coercion.ts src/error-coercion.ts src/number-coercion.ts src/record-coerce.ts src/string-coerce.ts src/string-normalization.ts --no-config --platform node --format esm --dts --out-dir dist --clean" + "build": "tsdown src/index.ts src/boolean-coercion.ts src/error-coercion.ts src/number-coercion.ts src/record-coerce.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean" } } diff --git a/packages/normalization-core/src/index.ts b/packages/normalization-core/src/index.ts index 4445cc6e654..9bc9d57d82e 100644 --- a/packages/normalization-core/src/index.ts +++ b/packages/normalization-core/src/index.ts @@ -7,3 +7,4 @@ export * from "./number-coercion.js"; export * from "./record-coerce.js"; export * from "./string-coerce.js"; export * from "./string-normalization.js"; +export * from "./utf16-slice.js"; diff --git a/src/shared/utf16-slice.test.ts b/packages/normalization-core/src/utf16-slice.test.ts similarity index 100% rename from src/shared/utf16-slice.test.ts rename to packages/normalization-core/src/utf16-slice.test.ts diff --git a/src/shared/utf16-slice.ts b/packages/normalization-core/src/utf16-slice.ts similarity index 100% rename from src/shared/utf16-slice.ts rename to packages/normalization-core/src/utf16-slice.ts diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json index 669ca6ae5c4..e5d9fa8122a 100644 --- a/packages/plugin-sdk/tsconfig.json +++ b/packages/plugin-sdk/tsconfig.json @@ -13,6 +13,7 @@ "tsBuildInfoFile": "dist/.tsbuildinfo" }, "include": [ + "../../packages/ai/src/**/*.ts", "../../packages/markdown-core/src/**/*.ts", "../../packages/media-core/src/**/*.ts", "../../packages/media-generation-core/src/**/*.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bc2a5ad89e..4350dff5c36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,9 @@ importers: '@mozilla/readability': specifier: 0.6.0 version: 0.6.0 + '@openclaw/ai': + specifier: workspace:* + version: link:packages/ai '@openclaw/fs-safe': specifier: 0.4.1 version: 0.4.1(patch_hash=e49004277fcb3e714125baf15a996682c7310e8e9269c865cc5bcb9fef46a57c) @@ -307,6 +310,12 @@ importers: specifier: 0.1.9 version: 0.1.9 + examples/ai-chat: + dependencies: + '@openclaw/ai': + specifier: workspace:* + version: link:../../packages/ai + extensions/acpx: dependencies: '@agentclientprotocol/claude-agent-acp': @@ -1828,9 +1837,30 @@ importers: packages/agent-core: dependencies: - '@openclaw/llm-core': + '@openclaw/ai': specifier: workspace:* - version: link:../llm-core + version: link:../ai + typebox: + specifier: 1.3.3 + version: 1.3.3 + + packages/ai: + dependencies: + '@anthropic-ai/sdk': + specifier: 0.109.1 + version: 0.109.1(zod@4.4.3) + '@google/genai': + specifier: 2.10.0 + version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@mistralai/mistralai': + specifier: 2.4.0 + version: 2.4.0(@opentelemetry/api@1.9.1) + openai: + specifier: 6.45.0 + version: 6.45.0(@aws-sdk/credential-provider-node@3.972.61)(@smithy/hash-node@4.4.5)(@smithy/signature-v4@5.6.1)(ws@8.21.0)(zod@4.4.3) + partial-json: + specifier: 0.1.7 + version: 0.1.7 typebox: specifier: 1.3.3 version: 1.3.3 @@ -1859,12 +1889,6 @@ importers: specifier: 1.3.3 version: 1.3.3 - packages/llm-runtime: - dependencies: - '@openclaw/llm-core': - specifier: workspace:* - version: link:../llm-core - packages/markdown-core: dependencies: markdown-it: @@ -3149,6 +3173,11 @@ packages: '@openclaw/libterminal@0.3.1': resolution: {integrity: sha512-nKEreLY6sdRvn+9UEcZhFpLYg3mJA4tmqNLZiYSMfislU11skiPWJfT7fn6ERsgAJsVMOSxeiVjBb5AX7dLKdQ==} engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + node-pty: '>=1.0.0' + peerDependenciesMeta: + node-pty: + optional: true '@openclaw/proxyline@0.3.3': resolution: {integrity: sha512-sftHnW69NHQqLjCxBTvQ8f/eQl+peZ5pHCBQtuTWBbeuYRHZ0/GXVTmw/O/YKsShMbqPWhJB0UYtPPdvCUSS8w==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b04519a13a2..05d3a199ce8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - ui - packages/* - extensions/* + - examples/* minimumReleaseAge: 2880 diff --git a/scripts/check-duplicates.mjs b/scripts/check-duplicates.mjs index 95d255ea496..f03bfcec4bc 100644 --- a/scripts/check-duplicates.mjs +++ b/scripts/check-duplicates.mjs @@ -10,6 +10,7 @@ const jscpdBin = path.join(repoRoot, "node_modules", "jscpd", "bin", "jscpd"); const targets = [ "src", "extensions", + "examples", "scripts", "packages", "ui", @@ -21,6 +22,7 @@ const targets = [ "skills", "openclaw.mjs", "config/knip.config.ts", + "tsdown.ai.config.ts", "tsdown.config.ts", "vitest.config.ts", ]; diff --git a/scripts/generate-npm-shrinkwrap.mjs b/scripts/generate-npm-shrinkwrap.mjs index 5cb88dbf119..45ea3f1560f 100644 --- a/scripts/generate-npm-shrinkwrap.mjs +++ b/scripts/generate-npm-shrinkwrap.mjs @@ -382,6 +382,17 @@ function readShrinkwrapOverrides() { function packageJsonForShrinkwrap(packageJson, shrinkwrapOverrides) { const normalized = { ...packageJson }; delete normalized.devDependencies; + for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) { + const dependencies = normalized[field]; + if (!dependencies || typeof dependencies !== "object" || Array.isArray(dependencies)) { + continue; + } + normalized[field] = Object.fromEntries( + Object.entries(dependencies).filter( + ([, spec]) => typeof spec !== "string" || !spec.startsWith("workspace:"), + ), + ); + } normalized.overrides = mergeOverrides(packageJson.overrides, shrinkwrapOverrides, {}); return normalized; } @@ -1065,10 +1076,14 @@ function shrinkwrapPathForPackage(packageDir) { } function listPublishablePluginPackageDirs() { - const extensionsDir = path.join(ROOT_DIR, "extensions"); - return readdirSync(extensionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.posix.join("extensions", entry.name)) + // Published workspace packages (packages/*) ship npm-shrinkwrap.json just + // like publishable plugins so their transitive dependency tree stays pinned. + return ["extensions", "packages"] + .flatMap((parentDir) => + readdirSync(path.join(ROOT_DIR, parentDir), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.posix.join(parentDir, entry.name)), + ) .filter((packageDir) => { const packageJsonPath = path.join(ROOT_DIR, packageDir, "package.json"); if (!existsSync(packageJsonPath)) { @@ -1098,11 +1113,11 @@ function shrinkwrapPackageDirsForChangedPaths(changedPaths) { packageDirs.add(ROOT_DIR); continue; } - const extensionMatch = changedPath.match( - /^(extensions\/[^/]+)\/(?:package\.json|npm-shrinkwrap\.json)$/u, + const workspacePackageMatch = changedPath.match( + /^((?:extensions|packages)\/[^/]+)\/(?:package\.json|npm-shrinkwrap\.json)$/u, ); - if (extensionMatch && publishablePluginPackageDirs.has(extensionMatch[1])) { - packageDirs.add(path.resolve(ROOT_DIR, extensionMatch[1])); + if (workspacePackageMatch && publishablePluginPackageDirs.has(workspacePackageMatch[1])) { + packageDirs.add(path.resolve(ROOT_DIR, workspacePackageMatch[1])); continue; } if (changedPath === "pnpm-lock.yaml") { diff --git a/scripts/lib/extension-package-boundary.ts b/scripts/lib/extension-package-boundary.ts index 40a854522b9..b3d5e893fe3 100644 --- a/scripts/lib/extension-package-boundary.ts +++ b/scripts/lib/extension-package-boundary.ts @@ -85,6 +85,13 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = { "@openclaw/discord/api.js": ["../dist/plugin-sdk/extensions/discord/api.d.ts"], "@openclaw/slack/api.js": ["../dist/plugin-sdk/extensions/slack/api.d.ts"], "@openclaw/whatsapp/api.js": ["../dist/plugin-sdk/extensions/whatsapp/api.d.ts"], + "@openclaw/ai": ["../dist/plugin-sdk/packages/ai/src/index.d.ts"], + "@openclaw/ai/diagnostics": ["../dist/plugin-sdk/packages/ai/src/utils/diagnostics.d.ts"], + "@openclaw/ai/event-stream": ["../dist/plugin-sdk/packages/ai/src/utils/event-stream.d.ts"], + "@openclaw/ai/providers": ["../dist/plugin-sdk/packages/ai/src/providers.d.ts"], + "@openclaw/ai/types": ["../dist/plugin-sdk/packages/ai/src/types.d.ts"], + "@openclaw/ai/validation": ["../dist/plugin-sdk/packages/ai/src/validation.d.ts"], + "@openclaw/ai/internal/*": ["../dist/plugin-sdk/packages/ai/src/internal/*.d.ts"], "@openclaw/llm-core": ["../dist/plugin-sdk/packages/llm-core/src/index.d.ts"], "@openclaw/llm-core/diagnostics": [ "../dist/plugin-sdk/packages/llm-core/src/utils/diagnostics.d.ts", diff --git a/scripts/lib/tsdown-output-roots.mjs b/scripts/lib/tsdown-output-roots.mjs index 8c981bb7b63..61be55152ec 100644 --- a/scripts/lib/tsdown-output-roots.mjs +++ b/scripts/lib/tsdown-output-roots.mjs @@ -1,10 +1,10 @@ // Lists package dist roots produced by tsdown builds. const TSDOWN_PACKAGE_NAMES = [ "agent-core", + "ai", "gateway-client", "gateway-protocol", "llm-core", - "llm-runtime", "markdown-core", "media-core", "media-generation-core", diff --git a/scripts/openclaw-npm-prepublish-verify.ts b/scripts/openclaw-npm-prepublish-verify.ts index 173dc82931f..39a7799fe8a 100644 --- a/scripts/openclaw-npm-prepublish-verify.ts +++ b/scripts/openclaw-npm-prepublish-verify.ts @@ -22,17 +22,19 @@ type InstalledPackageJson = { export type OpenClawNpmPrepublishVerifyArgs = | { expectedVersion?: string; + dependencyTarballPaths: string[]; help: false; tarballPath: string; } | { expectedVersion?: undefined; + dependencyTarballPaths: []; help: true; tarballPath: ""; }; export function openClawNpmPrepublishVerifyUsage(): string { - return "Usage: node --import tsx scripts/openclaw-npm-prepublish-verify.ts [expected-version]"; + return "Usage: node --import tsx scripts/openclaw-npm-prepublish-verify.ts [expected-version] [dependency.tgz ...]"; } export function parseOpenClawNpmPrepublishVerifyArgs( @@ -41,7 +43,7 @@ export function parseOpenClawNpmPrepublishVerifyArgs( const args = argv[0] === "--" ? argv.slice(1) : argv; const tarballPath = args[0]?.trim() ?? ""; if (tarballPath === "--help" || tarballPath === "-h") { - return { help: true, tarballPath: "" }; + return { dependencyTarballPaths: [], help: true, tarballPath: "" }; } if (!tarballPath) { throw new Error(openClawNpmPrepublishVerifyUsage()); @@ -54,14 +56,17 @@ export function parseOpenClawNpmPrepublishVerifyArgs( if (expectedVersion?.startsWith("-")) { throw new Error(`Unknown openclaw npm prepublish verifier option: ${expectedVersion}`); } - const extraArg = args[2]?.trim(); - if (extraArg) { - throw new Error(`Unexpected openclaw npm prepublish verifier argument: ${extraArg}`); + const dependencyTarballPaths = args.slice(2).map((value) => value.trim()); + const invalidDependency = dependencyTarballPaths.find( + (value) => value.length === 0 || value.startsWith("-"), + ); + if (invalidDependency !== undefined) { + throw new Error(`Invalid dependency tarball path: ${invalidDependency || ""}`); } return expectedVersion - ? { expectedVersion, help: false, tarballPath } - : { help: false, tarballPath }; + ? { dependencyTarballPaths, expectedVersion, help: false, tarballPath } + : { dependencyTarballPaths, help: false, tarballPath }; } function npmExec(args: string[], cwd: string): string { @@ -91,6 +96,7 @@ function main(argv = process.argv.slice(2)): void { "-g", "--prefix", prefixDir, + ...args.dependencyTarballPaths.map((dependency) => realpathSync(dependency)), realpathSync(args.tarballPath), "--no-fund", "--no-audit", diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index aec75a6c440..d7391cbc714 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -6,6 +6,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import * as tar from "tar"; import { preparePackageChangelog, restorePackageChangelog } from "./package-changelog.mjs"; const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -18,6 +19,8 @@ const PROCESS_GROUP_EXIT_POLL_MS = 25; const POST_FORCE_KILL_WAIT_MS = 1_000; const DEFAULT_CAPTURED_STDOUT_MAX_BYTES = 1024 * 1024; const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; +const AI_RUNTIME_PACKAGE = "@openclaw/ai"; +const AI_RUNTIME_BACKUP_DIR = ".openclaw-ai-package-backup"; const ACTIVE_CHILD_KILLERS = new Set(); const SIGNAL_EXIT_CODES = { SIGHUP: 129, @@ -421,15 +424,161 @@ async function cleanPackedOpenClawTarballs(outputDir) { ); } +function isPackedAiRuntimeTarball(filename) { + return /^openclaw-ai-[A-Za-z0-9._-]+\.tgz$/u.test(filename); +} + +export async function prepareBundledAiRuntimePackage( + sourceDir, + outputDir, + runCaptureImpl = runCapture, + options = {}, +) { + const packageJsonPath = path.join(sourceDir, "package.json"); + const aiRuntimePath = path.join(sourceDir, "node_modules", "@openclaw", "ai"); + const aiRuntimeBackupPath = path.join( + sourceDir, + "node_modules", + "@openclaw", + AI_RUNTIME_BACKUP_DIR, + ); + const extractAiRuntime = + options.extractAiRuntime ?? + ((tarballPath, destination) => + Promise.resolve(tar.x({ cwd: destination, file: tarballPath, strip: 1 }))); + const originalPackageJson = await fs.readFile(packageJsonPath, "utf8"); + let packageJson; + try { + packageJson = JSON.parse(originalPackageJson); + } catch (error) { + throw new Error(`failed to parse ${packageJsonPath}`, { cause: error }); + } + if (typeof packageJson.dependencies?.[AI_RUNTIME_PACKAGE] !== "string") { + throw new Error("root package.json must declare @openclaw/ai as a dependency"); + } + + try { + await fs.access(aiRuntimeBackupPath); + throw new Error(`refusing to overwrite existing ${aiRuntimeBackupPath}`); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + let packedAiTarballs = []; + let packageJsonChanged = false; + let originalAiRuntimeMoved = false; + let stagedAiRuntimeCreated = false; + const cleanup = async () => { + let cleanupError; + const attempt = async (action) => { + try { + await action(); + } catch (error) { + cleanupError ??= error; + } + }; + if (packageJsonChanged) { + await attempt(async () => await fs.writeFile(packageJsonPath, originalPackageJson)); + } + if (stagedAiRuntimeCreated) { + await attempt(async () => await fs.rm(aiRuntimePath, { force: true, recursive: true })); + } + if (originalAiRuntimeMoved) { + await attempt(async () => await fs.rename(aiRuntimeBackupPath, aiRuntimePath)); + } + await attempt(async () => { + await Promise.all(packedAiTarballs.map((filename) => fs.rm(filename, { force: true }))); + }); + packageJsonChanged = false; + stagedAiRuntimeCreated = false; + originalAiRuntimeMoved = false; + packedAiTarballs = []; + if (cleanupError) { + throw cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); + } + }; + + try { + await runCaptureImpl( + "pnpm", + ["--dir", "packages/ai", "pack", "--silent", "--pack-destination", outputDir], + sourceDir, + { + deferForwardedSignalExit: true, + timeoutMs: resolveTimeoutMs( + "OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS", + DEFAULT_PACKAGE_PACK_TIMEOUT_MS, + ), + }, + ); + packedAiTarballs = (await fs.readdir(outputDir)) + .filter(isPackedAiRuntimeTarball) + .map((filename) => path.join(outputDir, filename)); + if (packedAiTarballs.length !== 1) { + throw new Error( + `expected one packed @openclaw/ai tarball in ${outputDir}, found ${packedAiTarballs.length}`, + ); + } + + try { + await fs.lstat(aiRuntimePath); + await fs.rename(aiRuntimePath, aiRuntimeBackupPath); + originalAiRuntimeMoved = true; + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + await fs.mkdir(aiRuntimePath, { recursive: true }); + stagedAiRuntimeCreated = true; + await extractAiRuntime(packedAiTarballs[0], aiRuntimePath); + + const stagedPackageJsonPath = path.join(aiRuntimePath, "package.json"); + const stagedPackageJson = JSON.parse(await fs.readFile(stagedPackageJsonPath, "utf8")); + if (typeof stagedPackageJson.version !== "string" || !stagedPackageJson.version) { + throw new Error("packed @openclaw/ai package must declare a version"); + } + for (const [name, version] of Object.entries(stagedPackageJson.dependencies ?? {})) { + if (packageJson.dependencies[name] !== version) { + throw new Error( + `root package.json must declare ${name}@${version} to bundle @openclaw/ai without duplicate dependencies`, + ); + } + } + // Root owns these exact dependencies. Removing them from the staged copy keeps npm from + // recursively bundling duplicate packages alongside the one private workspace runtime. + delete stagedPackageJson.dependencies; + await fs.writeFile(stagedPackageJsonPath, `${JSON.stringify(stagedPackageJson, null, 2)}\n`); + + packageJson.dependencies[AI_RUNTIME_PACKAGE] = stagedPackageJson.version; + const bundleDependencies = packageJson.bundleDependencies ?? []; + if (!Array.isArray(bundleDependencies)) { + throw new Error("root package.json bundleDependencies must be an array when present"); + } + packageJson.bundleDependencies = [...new Set([...bundleDependencies, AI_RUNTIME_PACKAGE])]; + packageJsonChanged = true; + await fs.writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + return cleanup; + } catch (error) { + await cleanup(); + throw error; + } +} + export async function packOpenClawPackageForDocker(sourceDir, outputDir, options = {}) { const runCaptureImpl = options.runCaptureImpl ?? runCapture; const prepareChangelog = options.prepareChangelog ?? preparePackageChangelog; const restoreChangelog = options.restoreChangelog ?? restorePackageChangelog; + const prepareBundledAiRuntime = options.prepareBundledAiRuntime ?? prepareBundledAiRuntimePackage; console.error("==> Packing OpenClaw package"); await prepareChangelog(sourceDir); let packOutput; + let cleanupBundledAiRuntime = async () => {}; try { await cleanPackedOpenClawTarballs(outputDir); + cleanupBundledAiRuntime = await prepareBundledAiRuntime(sourceDir, outputDir, runCaptureImpl); packOutput = await runCaptureImpl( "npm", ["pack", "--silent", "--ignore-scripts", "--pack-destination", outputDir], @@ -443,7 +592,11 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options }, ); } finally { - await restoreChangelog(sourceDir); + try { + await cleanupBundledAiRuntime(); + } finally { + await restoreChangelog(sourceDir); + } } return await newestOpenClawTarball(outputDir, packOutput); } diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 19df18ec577..df01301778b 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -203,7 +203,7 @@ try { budgets = { publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 324), publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10429), - publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5240), + publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5206), publicDeprecatedExports: readBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS", 3261, @@ -242,23 +242,18 @@ function hasDeprecatedTag(symbol) { return symbol.getJsDocTags().some((tag) => tag.name === "deprecated"); } -const generatedLlmCoreValidatorExports = new Set(["validateToolArguments", "validateToolCall"]); - -function isGeneratedLlmCoreValidatorDeclaration(exportName, declaration) { - if (!generatedLlmCoreValidatorExports.has(exportName)) { - return false; - } +function isGeneratedPackageDeclaration(declaration) { const relative = path.relative(repoRoot, declaration.getSourceFile().fileName); const relativePath = relative.split(path.sep).join(path.posix.sep); - // Build artifacts can make agent-core's package-name validator reexports look - // newly callable. Keep this source report independent of generated dist state. - return relativePath.includes("llm-core/dist/validation.d."); + // Package builds can make workspace package reexports look newly callable. + // Source-surface counts must stay independent of generated dist state. + return /^packages\/[^/]+\/dist\//u.test(relativePath); } function isCallableExport(checker, symbol, sourceFile) { const target = unwrapAlias(checker, symbol); const declaration = target.valueDeclaration ?? target.declarations?.[0] ?? sourceFile; - if (isGeneratedLlmCoreValidatorDeclaration(symbol.getName(), declaration)) { + if (isGeneratedPackageDeclaration(declaration)) { return false; } const type = checker.getTypeOfSymbolAtLocation(target, declaration); diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 8bf7727af2e..36cb7db20de 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -52,11 +52,38 @@ function listPackageDtsOutputsFromExports({ packageDir, outputPrefix }) { .toSorted((a, b) => a.localeCompare(b)); } +function listSourceDtsOutputs({ sourceDir, outputPrefix }) { + const outputs = []; + + function visit(relativeDir) { + const absoluteDir = path.join(repoRoot, sourceDir, relativeDir); + for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) { + const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + visit(relativePath); + continue; + } + if ( + !entry.name.endsWith(".ts") || + entry.name.endsWith(".test.ts") || + entry.name.endsWith(".test-helpers.ts") + ) { + continue; + } + outputs.push(`${outputPrefix}/${relativePath.replace(/\.ts$/u, ".d.ts")}`); + } + } + + visit(""); + return outputs.toSorted((a, b) => a.localeCompare(b)); +} + const PLUGIN_SDK_TYPE_INPUTS = [ "tsconfig.json", "src/plugin-sdk", "src/plugins/types.ts", "src/auto-reply", + "packages/ai/src", "packages/llm-core/src", "packages/markdown-core/src", "packages/media-core/src", @@ -77,7 +104,12 @@ const ACP_CORE_REQUIRED_DTS_OUTPUTS = listPackageDtsOutputsFromExports({ packageDir: "acp-core", outputPrefix: "dist/plugin-sdk/packages/acp-core/src", }); +const AI_REQUIRED_DTS_OUTPUTS = listSourceDtsOutputs({ + sourceDir: "packages/ai/src", + outputPrefix: "dist/plugin-sdk/packages/ai/src", +}); const ROOT_DTS_REQUIRED_OUTPUTS = [ + ...AI_REQUIRED_DTS_OUTPUTS, "dist/plugin-sdk/packages/memory-host-sdk/src/engine-embeddings.d.ts", "dist/plugin-sdk/packages/memory-host-sdk/src/secret.d.ts", "dist/plugin-sdk/packages/memory-host-sdk/src/status.d.ts", @@ -146,7 +178,12 @@ const ACP_CORE_REQUIRED_PACKAGE_DTS_OUTPUTS = listPackageDtsOutputsFromExports({ packageDir: "acp-core", outputPrefix: "packages/plugin-sdk/dist/packages/acp-core/src", }); +const AI_REQUIRED_PACKAGE_DTS_OUTPUTS = listSourceDtsOutputs({ + sourceDir: "packages/ai/src", + outputPrefix: "packages/plugin-sdk/dist/packages/ai/src", +}); const PACKAGE_DTS_REQUIRED_OUTPUTS = [ + ...AI_REQUIRED_PACKAGE_DTS_OUTPUTS, "packages/plugin-sdk/dist/packages/markdown-core/src/code-spans.d.ts", "packages/plugin-sdk/dist/packages/markdown-core/src/fences.d.ts", "packages/plugin-sdk/dist/packages/markdown-core/src/frontmatter.d.ts", diff --git a/scripts/sync-plugin-versions.ts b/scripts/sync-plugin-versions.ts index b0cf51e1d52..f873a05f8c8 100644 --- a/scripts/sync-plugin-versions.ts +++ b/scripts/sync-plugin-versions.ts @@ -25,6 +25,7 @@ type SyncPluginVersionsOptions = { }; const OPENCLAW_VERSION_RANGE_RE = /^>=\d{4}\.\d{1,2}\.\d{1,2}(?:[-.][^"\s]+)?$/u; +const VERSION_ALIGNED_PACKAGE_DIRS = ["packages/ai"] as const; function syncOpenClawDependencyRange( deps: Record | undefined, @@ -117,6 +118,22 @@ export function syncPluginVersions( const changelogged: string[] = []; const skipped: string[] = []; + for (const packageDir of VERSION_ALIGNED_PACKAGE_DIRS) { + const packagePath = join(rootDir, packageDir, "package.json"); + if (!existsSync(packagePath)) { + continue; + } + const pkg = JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson; + if (!pkg.name || pkg.version === targetVersion) { + continue; + } + pkg.version = targetVersion; + if (write) { + writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); + } + updated.push(pkg.name); + } + for (const dir of dirs) { const packagePath = join(extensionsDir, dir.name, "package.json"); let pkg: PackageJson; diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 60a3bcc1b7e..f1b58aa6887 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -596,6 +596,18 @@ export function resolveTsdownBuildInvocation(params = {}) { }; } +/** Builds AI package declarations first, then consumes them from the main graph. */ +export function resolveTsdownBuildInvocations(params = {}) { + const forwardedArgs = params.args ?? []; + return [ + resolveTsdownBuildInvocation({ + ...params, + args: ["--config", "tsdown.ai.config.ts", ...forwardedArgs], + }), + resolveTsdownBuildInvocation(params), + ]; +} + function signalWindowsProcessTree(pid, signal, runTaskkill = spawnSync) { const args = ["/PID", String(pid), "/T"]; if (signal === "SIGKILL") { @@ -844,8 +856,14 @@ if (isMainModule()) { pruneUntrackedGeneratedSourceDeclarations(); pruneStaleRuntimeSymlinks(); cleanTsdownOutputRoots(); - const invocation = resolveTsdownBuildInvocation({ args: args.forwardedArgs }); - const result = await runTsdownBuildInvocation(invocation); + const invocations = resolveTsdownBuildInvocations({ args: args.forwardedArgs }); + let result; + for (const invocation of invocations) { + result = await runTsdownBuildInvocation(invocation); + if (result.status !== 0 || result.hasIneffectiveDynamicImport || result.fatalUnresolvedImport) { + break; + } + } if (result.status === 0 && result.hasIneffectiveDynamicImport) { console.error( diff --git a/src/agents/agent-bundle-mcp-materialize.ts b/src/agents/agent-bundle-mcp-materialize.ts index f1190260630..403d99cd0e1 100644 --- a/src/agents/agent-bundle-mcp-materialize.ts +++ b/src/agents/agent-bundle-mcp-materialize.ts @@ -1,6 +1,7 @@ /** Materializes configured MCP catalog entries into agent tools and runtime helpers. */ import crypto from "node:crypto"; import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/sdk/types.js"; +import { normalizeToolParameterSchema } from "@openclaw/ai/internal/openai"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -16,7 +17,6 @@ import type { McpToolCatalog, SessionMcpRuntime, } from "./agent-bundle-mcp-types.js"; -import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js"; import type { AgentToolResult } from "./runtime/index.js"; import type { AnyAgentTool } from "./tools/common.js"; diff --git a/src/agents/agent-tools.schema.test.ts b/src/agents/agent-tools.schema.test.ts index 732349a5676..d03285379e9 100644 --- a/src/agents/agent-tools.schema.test.ts +++ b/src/agents/agent-tools.schema.test.ts @@ -1,3 +1,4 @@ +import { normalizeToolParameterSchema } from "@openclaw/ai/internal/openai"; /** * Tests provider-compatible tool schema normalization. * Protects caching, ref inlining, OpenAPI keyword cleanup, and no-parameter @@ -7,7 +8,6 @@ import { runAgentLoop, type AgentEvent, type StreamFn } from "openclaw/plugin-sd import { createAssistantMessageEventStream, validateToolArguments } from "openclaw/plugin-sdk/llm"; import { Type, type TSchema } from "typebox"; import { describe, expect, it, vi } from "vitest"; -import { normalizeToolParameterSchema } from "./agent-tools-parameter-schema.js"; import { isToolWrappedWithBeforeToolCallHook, testing as beforeToolCallTesting, diff --git a/src/agents/agent-tools.schema.ts b/src/agents/agent-tools.schema.ts index d6652a89df6..a6067cb51ef 100644 --- a/src/agents/agent-tools.schema.ts +++ b/src/agents/agent-tools.schema.ts @@ -1,13 +1,13 @@ +import { + normalizeToolParameterSchema, + type ToolParameterSchemaOptions, +} from "@openclaw/ai/internal/openai"; /** * Tool schema normalization wrappers. * Applies provider-compatible parameter schema cleanup while preserving plugin * and channel metadata on normalized tools. */ import { copyPluginToolMeta } from "../plugins/tools.js"; -import { - normalizeToolParameterSchema, - type ToolParameterSchemaOptions, -} from "./agent-tools-parameter-schema.js"; import type { AnyAgentTool } from "./agent-tools.types.js"; import { copyBeforeToolCallHookMarker } from "./before-tool-call-metadata.js"; import { copyChannelAgentToolMeta } from "./channel-tools.js"; diff --git a/src/agents/anthropic-payload-policy.test.ts b/src/agents/anthropic-payload-policy.test.ts index 9c90aa56513..b0543141e71 100644 --- a/src/agents/anthropic-payload-policy.test.ts +++ b/src/agents/anthropic-payload-policy.test.ts @@ -1,3 +1,4 @@ +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; /** * Tests Anthropic payload policy mutation. * Covers service tier, cache-control retention, prompt cache boundaries, and @@ -8,7 +9,6 @@ import { applyAnthropicPayloadPolicyToParams, resolveAnthropicPayloadPolicy, } from "./anthropic-payload-policy.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; type TestPayload = { messages: Array<{ role: string; content: unknown }>; diff --git a/src/agents/anthropic-payload-policy.ts b/src/agents/anthropic-payload-policy.ts index ff340219db3..a620613004b 100644 --- a/src/agents/anthropic-payload-policy.ts +++ b/src/agents/anthropic-payload-policy.ts @@ -1,13 +1,13 @@ +import { + splitSystemPromptCacheBoundary, + stripSystemPromptCacheBoundary, +} from "@openclaw/ai/internal/shared"; /** * Anthropic-family request payload policy helpers. * Applies service-tier and cache-control markers only when provider endpoint * capabilities allow them. */ import { resolveProviderRequestCapabilities } from "./provider-attribution.js"; -import { - splitSystemPromptCacheBoundary, - stripSystemPromptCacheBoundary, -} from "./system-prompt-cache-boundary.js"; /** @deprecated Anthropic-family provider payload helper; do not use from third-party plugins. */ type AnthropicServiceTier = "auto" | "standard_only"; diff --git a/src/agents/anthropic-transport-stream.live.test.ts b/src/agents/anthropic-transport-stream.live.test.ts index ad42c4adc9c..8575cff1171 100644 --- a/src/agents/anthropic-transport-stream.live.test.ts +++ b/src/agents/anthropic-transport-stream.live.test.ts @@ -4,9 +4,9 @@ * transport against the configured provider. */ import http from "node:http"; +import { streamAnthropic } from "@openclaw/ai/internal/anthropic"; import type { Model } from "openclaw/plugin-sdk/llm"; import { describe, expect, it } from "vitest"; -import { streamAnthropic } from "../llm/providers/anthropic.js"; import { createAnthropicMessagesTransportStreamFn } from "./anthropic-transport-stream.js"; import { isLiveTestEnabled } from "./live-test-helpers.js"; import { isLiveBillingDrift } from "./live-test-provider-drift.js"; diff --git a/src/agents/anthropic-transport-stream.ts b/src/agents/anthropic-transport-stream.ts index e8479d229ac..5e9763c4e40 100644 --- a/src/agents/anthropic-transport-stream.ts +++ b/src/agents/anthropic-transport-stream.ts @@ -1,3 +1,41 @@ +import { + ANTHROPIC_OMITTED_REASONING_TEXT, + ANTHROPIC_SERVER_SIDE_FALLBACK_BETA, + CLAUDE_FABLE_5_FALLBACK_MODEL_COST, + applyAnthropicFallbackBoundary, + buildAnthropicServerSideFallbacks, + applyAnthropicRefusal, + findActiveAnthropicToolTurnAssistantIndex, + omitFoundryBearerCredentialHeaders, + projectAnthropicTools, + reconcileAnthropicToolChoice, + requiresClaudeAdaptiveThinking, + resolveClaudeNativeThinkingLevelMap, + resolveOriginalAnthropicToolName, + readAnthropicFallbackBoundary, + supportsClaudeAdaptiveThinking, + supportsClaudeNativeMaxEffort, + supportsClaudeNativeXhighEffort, + usesClaudeFable5MessagesContract, + usesFoundryBearerAuth, + type AnthropicOptions, + type AnthropicProjectedToolChoice, + type AnthropicThinkingDisplay, + type AnthropicToolProjection, +} from "@openclaw/ai/internal/anthropic"; +import { + calculateCost, + clampThinkingLevel, + createDeferredEventBuffer, + getEnvApiKey, + notifyLlmRequestActivity, + parseStreamingJson, +} from "@openclaw/ai/internal/runtime"; +import { + describeToolResultMediaPlaceholder, + extractToolResultBlockText, + extractToolResultText, +} from "@openclaw/ai/internal/shared"; /** * Native Anthropic Messages streaming transport. * Converts OpenClaw contexts/tools into Anthropic payloads, streams SSE events @@ -7,18 +45,6 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import { createAbortError as createNamedAbortError } from "../infra/abort-signal.js"; import { toErrorObject } from "../infra/errors.js"; import { readResponseTextSnippet } from "../infra/http-body.js"; -import { getEnvApiKey } from "../llm/env-api-keys.js"; -import { calculateCost, clampThinkingLevel } from "../llm/model-utils.js"; -import { - ANTHROPIC_OMITTED_REASONING_TEXT, - findActiveAnthropicToolTurnAssistantIndex, -} from "../llm/providers/anthropic-thinking-replay.js"; -import type { AnthropicOptions, AnthropicThinkingDisplay } from "../llm/providers/anthropic.js"; -import { - describeToolResultMediaPlaceholder, - extractToolResultBlockText, - extractToolResultText, -} from "../llm/providers/tool-result-text.js"; import type { AssistantMessageDiagnostic, Context, @@ -26,41 +52,12 @@ import type { SimpleStreamOptions, ThinkingLevel, } from "../llm/types.js"; -import { parseStreamingJson } from "../llm/utils/json-parse.js"; -import { - omitFoundryBearerCredentialHeaders, - usesFoundryBearerAuth, -} from "../shared/anthropic-auth-headers.js"; -import { - resolveClaudeNativeThinkingLevelMap, - requiresClaudeAdaptiveThinking, - supportsClaudeAdaptiveThinking, - supportsClaudeNativeMaxEffort, - supportsClaudeNativeXhighEffort, - usesClaudeFable5MessagesContract, -} from "../shared/anthropic-model-contract.js"; -import { applyAnthropicRefusal } from "../shared/anthropic-refusal.js"; -import { - ANTHROPIC_SERVER_SIDE_FALLBACK_BETA, - CLAUDE_FABLE_5_FALLBACK_MODEL_COST, - applyAnthropicFallbackBoundary, - buildAnthropicServerSideFallbacks, - readAnthropicFallbackBoundary, -} from "../shared/anthropic-server-fallback.js"; +import "../llm/ai-transport-host.js"; import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js"; -import { createDeferredEventBuffer } from "../shared/deferred-event-buffer.js"; -import { notifyLlmRequestActivity } from "../shared/llm-request-activity.js"; import { applyAnthropicPayloadPolicyToParams, resolveAnthropicPayloadPolicy, } from "./anthropic-payload-policy.js"; -import { - projectAnthropicTools, - reconcileAnthropicToolChoice, - resolveOriginalAnthropicToolName, - type AnthropicProjectedToolChoice, - type AnthropicToolProjection, -} from "./anthropic-tool-projection.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js"; import { parseJsonObjectPreservingUnsafeIntegers } from "./json-unsafe-integers.js"; import { resolveProviderEndpoint } from "./provider-attribution.js"; diff --git a/src/agents/cli-runner.helpers.test.ts b/src/agents/cli-runner.helpers.test.ts index d59c2b5f906..da07bd50721 100644 --- a/src/agents/cli-runner.helpers.test.ts +++ b/src/agents/cli-runner.helpers.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; import type { ImageContent } from "openclaw/plugin-sdk/llm"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -20,7 +21,6 @@ import { } from "./cli-runner/helpers.js"; import * as promptImageUtils from "./embedded-agent-runner/run/images.js"; import type { SandboxFsBridge } from "./sandbox/fs-bridge.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; import * as toolImages from "./tool-images.js"; describe("loadPromptRefImages", () => { diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index a4dd55e57e9..0f09b7bb4fb 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -6,6 +6,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; import { extensionForMime } from "@openclaw/media-core/mime"; import { @@ -36,7 +37,6 @@ import { resolveDefaultModelForAgent } from "../model-selection.js"; import type { AgentTool } from "../runtime/index.js"; import type { SandboxFsBridge } from "../sandbox/fs-bridge.js"; import { detectRuntimeShell } from "../shell-utils.js"; -import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import { buildConfiguredAgentSystemPrompt } from "../system-prompt-config.js"; import { buildSystemPromptParams } from "../system-prompt-params.js"; import type { SilentReplyPromptMode } from "../system-prompt.types.js"; diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 88f71081b11..56cb8b310e8 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions"; import { Type } from "typebox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -32,7 +33,6 @@ import { resetContextWindowCacheForTest } from "../context.js"; import { buildActiveImageGenerationTaskPromptContextForSession } from "../image-generation-task-status.js"; import { buildActiveMusicGenerationTaskPromptContextForSession } from "../music-generation-task-status.js"; import type { SandboxWorkspaceInfo } from "../sandbox/types.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../system-prompt-cache-boundary.js"; import { buildActiveVideoGenerationTaskPromptContextForSession } from "../video-generation-task-status.js"; import { prepareCliRunContext, diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 60ad06c3ac8..2ef4cc6bcf7 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -1,3 +1,4 @@ +import { ensureSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; /** * Prepares CLI backend run context: backend config, prompts, bootstrap context, * MCP, auth epoch, and reusable session metadata. @@ -77,7 +78,6 @@ import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-promp import { applyPluginTextReplacements } from "../plugin-text-transforms.js"; import { collectRuntimeChannelCapabilities } from "../runtime-capabilities.js"; import { ensureSandboxWorkspaceForSession } from "../sandbox.js"; -import { ensureSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import { buildSystemPromptReport } from "../system-prompt-report.js"; import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js"; import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; diff --git a/src/agents/custom-api-registry.test.ts b/src/agents/custom-api-registry.test.ts index 4259e4d33da..1d93d615280 100644 --- a/src/agents/custom-api-registry.test.ts +++ b/src/agents/custom-api-registry.test.ts @@ -1,15 +1,13 @@ -// Covers dynamic registration of custom model API providers. -import { afterEach, describe, expect, it, vi } from "vitest"; import { clearApiProviders, + defaultApiRegistry, getApiProvider, registerApiProvider, unregisterApiProviders, -} from "../llm/api-registry.js"; -import { - registerBuiltInApiProviders, - resetApiProviders, -} from "../llm/providers/register-builtins.js"; +} from "@openclaw/ai/internal/runtime"; +import { registerBuiltInApiProviders, resetApiProviders } from "@openclaw/ai/providers"; +// Covers dynamic registration of custom model API providers. +import { afterEach, describe, expect, it, vi } from "vitest"; import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; import { ensureCustomApiRegistered } from "./custom-api-registry.js"; import { buildAssistantMessageWithZeroUsage } from "./stream-message-shared.js"; @@ -25,7 +23,7 @@ function getRegisteredTestProvider() { describe("ensureCustomApiRegistered", () => { afterEach(() => { clearApiProviders(); - registerBuiltInApiProviders(); + registerBuiltInApiProviders(defaultApiRegistry); }); it("registers a custom api provider once", () => { @@ -117,7 +115,7 @@ describe("ensureCustomApiRegistered", () => { sourceId, ); - resetApiProviders(); + resetApiProviders(defaultApiRegistry); expect(getApiProvider(api)).toBeDefined(); expect(getApiProvider("openai-responses")).toBeDefined(); diff --git a/src/agents/custom-api-registry.ts b/src/agents/custom-api-registry.ts index 493d8cc45da..97b06fecb76 100644 --- a/src/agents/custom-api-registry.ts +++ b/src/agents/custom-api-registry.ts @@ -1,7 +1,7 @@ /** * Registers caller-supplied custom API stream functions with the LLM registry. */ -import { getApiProvider, registerApiProvider } from "../llm/api-registry.js"; +import { getApiProvider, registerApiProvider } from "@openclaw/ai/internal/runtime"; import type { Api, AssistantMessageEventStreamContract, diff --git a/src/agents/embedded-agent-helpers/errors.ts b/src/agents/embedded-agent-helpers/errors.ts index 9bddf718f6e..8ac318f2fe6 100644 --- a/src/agents/embedded-agent-helpers/errors.ts +++ b/src/agents/embedded-agent-helpers/errors.ts @@ -1,3 +1,4 @@ +import { isConfiguredContextSizeOverflowError } from "@openclaw/ai/internal/runtime"; /** * Classifies provider/runtime failures and formats assistant-facing error text. */ @@ -5,9 +6,9 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { AssistantMessage } from "../../llm/types.js"; -import { isConfiguredContextSizeOverflowError } from "../../llm/utils/overflow.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { extractLeadingHttpStatus, @@ -15,7 +16,6 @@ import { isGenericProviderInternalError, parseApiErrorInfo, } from "../../shared/assistant-error-format.js"; -import { truncateUtf16Safe } from "../../shared/utf16-slice.js"; export { extractLeadingHttpStatus, formatRawAssistantErrorForUi, diff --git a/src/agents/embedded-agent-runner/google-prompt-cache.ts b/src/agents/embedded-agent-runner/google-prompt-cache.ts index 3cc2f9bfcf2..055fbc346b1 100644 --- a/src/agents/embedded-agent-runner/google-prompt-cache.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.ts @@ -2,6 +2,7 @@ * Prepares Google prompt-cache payloads for embedded-agent stream calls. */ import crypto from "node:crypto"; +import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; import { asDateTimestampMs, isFutureDateTimestampMs, @@ -17,7 +18,6 @@ import { buildGuardedModelFetch } from "../provider-transport-fetch.js"; import type { StreamFn } from "../runtime/index.js"; import { isSessionWriteLockAcquireError } from "../session-write-lock-error.js"; import { stableStringify } from "../stable-stringify.js"; -import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import { mergeTransportHeaders, sanitizeTransportPayloadText } from "../transport-stream-shared.js"; import { log } from "./logger.js"; import { isGooglePromptCacheEligible, resolveCacheRetention } from "./prompt-cache-retention.js"; diff --git a/src/agents/embedded-agent-runner/run/attempt.llm-boundary.cache-stability.test.ts b/src/agents/embedded-agent-runner/run/attempt.llm-boundary.cache-stability.test.ts index 3c24814c5a8..2b659a055d8 100644 --- a/src/agents/embedded-agent-runner/run/attempt.llm-boundary.cache-stability.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.llm-boundary.cache-stability.test.ts @@ -1,3 +1,4 @@ +import { streamOpenAICompletions, streamOpenAIResponses } from "@openclaw/ai/internal/openai"; /** * Cache-stability gate for the prompt-cache bust fix (issue #3658). * @@ -20,8 +21,6 @@ import { describe, expect, it } from "vitest"; import { stripInboundMetadata } from "../../../auto-reply/reply/strip-inbound-meta.js"; import { buildTimestampPrefix } from "../../../gateway/server-methods/agent-timestamp.js"; -import { streamOpenAICompletions } from "../../../llm/providers/openai-completions.js"; -import { streamOpenAIResponses } from "../../../llm/providers/openai-responses.js"; import type { Context, Model } from "../../../llm/types.js"; import { normalizeMessagesForLlmBoundary } from "./attempt.llm-boundary.js"; diff --git a/src/agents/embedded-agent-runner/run/attempt.media-hint-cache-boundary.test.ts b/src/agents/embedded-agent-runner/run/attempt.media-hint-cache-boundary.test.ts index 396e98436a0..b38f54c1ddd 100644 --- a/src/agents/embedded-agent-runner/run/attempt.media-hint-cache-boundary.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.media-hint-cache-boundary.test.ts @@ -35,7 +35,7 @@ import { ensureSystemPromptCacheBoundary, SYSTEM_PROMPT_CACHE_BOUNDARY, splitSystemPromptCacheBoundary, -} from "../../system-prompt-cache-boundary.js"; +} from "@openclaw/ai/internal/shared"; import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine, diff --git a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts index 77939e07a23..8cf27aafc8a 100644 --- a/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts @@ -1,3 +1,4 @@ +import { prependSystemPromptAdditionAfterCacheBoundary } from "@openclaw/ai/internal/shared"; /** * Builds and repairs prompt inputs for embedded-agent attempts. */ @@ -23,7 +24,6 @@ import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-pr import { wrapPluginSystemContextSection } from "../../hook-system-context-boundary.js"; import { buildActiveImageGenerationTaskPromptContextForSession } from "../../image-generation-task-status.js"; import { buildActiveMusicGenerationTaskPromptContextForSession } from "../../music-generation-task-status.js"; -import { prependSystemPromptAdditionAfterCacheBoundary } from "../../system-prompt-cache-boundary.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; import { derivePromptTokens, type NormalizedUsage } from "../../usage.js"; import { buildActiveVideoGenerationTaskPromptContextForSession } from "../../video-generation-task-status.js"; diff --git a/src/agents/embedded-agent-runner/run/attempt.test.ts b/src/agents/embedded-agent-runner/run/attempt.test.ts index a23d828e7ae..d3352fc3587 100644 --- a/src/agents/embedded-agent-runner/run/attempt.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.test.ts @@ -5,11 +5,11 @@ import { streamSimple } from "../../../llm/stream.js"; vi.mock("../context-engine-capabilities.js", () => ({ resolveContextEngineCapabilities: async () => ({ llm: undefined }), })); +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; import type { OpenClawConfig } from "../../../config/config.js"; import { addSession, resetProcessRegistryForTests } from "../../bash-process-registry.js"; import { createProcessSessionFixture } from "../../bash-process-registry.test-helpers.js"; import { wrapPluginSystemContextSection } from "../../hook-system-context-boundary.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../system-prompt-cache-boundary.js"; import { buildAgentSystemPrompt } from "../../system-prompt.js"; import { resetEmbeddedAgentBaseStreamFnCacheForTest, diff --git a/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts b/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts index c463389f9a6..65dc19a9ae5 100644 --- a/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts +++ b/src/agents/embedded-agent-runner/run/attempt.thread-helpers.ts @@ -1,9 +1,9 @@ +import { normalizeStructuredPromptSection } from "@openclaw/ai/internal/shared"; /** * Handles per-attempt thread prompt composition and cache TTL markers. */ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; -import { normalizeStructuredPromptSection } from "../../prompt-cache-stability.js"; /** Custom transcript marker used to preserve cache-TTL pruning state across attempts. */ export const ATTEMPT_CACHE_TTL_CUSTOM_TYPE = "openclaw.cache-ttl"; diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 6ad10185b7b..13bcc040163 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -3,6 +3,7 @@ */ import fs from "node:fs/promises"; import os from "node:os"; +import { ensureSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; @@ -212,7 +213,6 @@ import { prependAgentSteeringPrompt, releasePendingAgentSteeringItems, } from "../../subagent-registry.js"; -import { ensureSystemPromptCacheBoundary } from "../../system-prompt-cache-boundary.js"; import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts index b9eda038b16..0d150af1dc5 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts @@ -1,3 +1,4 @@ +import { notifyLlmRequestActivity } from "@openclaw/ai/internal/runtime"; // LLM idle-timeout tests cover timeout selection and stream wrapping for // embedded provider calls, including local-provider and cron exceptions. import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion"; @@ -7,7 +8,6 @@ import { } from "openclaw/plugin-sdk/llm"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; -import { notifyLlmRequestActivity } from "../../../shared/llm-request-activity.js"; import type { StreamFn } from "../../runtime/index.js"; import { resolveLlmFirstEventTimeoutMs, diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts index 7dab2db6427..d39daf0feb6 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts @@ -1,3 +1,4 @@ +import { onLlmRequestActivity } from "@openclaw/ai/internal/runtime"; /** * Wraps LLM streams with idle-timeout detection and diagnostics. */ @@ -8,7 +9,6 @@ import { } from "@openclaw/normalization-core/number-coercion"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { toErrorObject } from "../../../infra/errors.js"; -import { onLlmRequestActivity } from "../../../shared/llm-request-activity.js"; import type { StreamFn } from "../../runtime/index.js"; import type { MutableAssistantMessageEventStream } from "../../stream-compat.js"; import { createStreamIteratorWrapper } from "../../stream-iterator-wrapper.js"; diff --git a/src/agents/embedded-agent-runner/stream-resolution.test.ts b/src/agents/embedded-agent-runner/stream-resolution.test.ts index 7c14096e7a3..99b19d80a06 100644 --- a/src/agents/embedded-agent-runner/stream-resolution.test.ts +++ b/src/agents/embedded-agent-runner/stream-resolution.test.ts @@ -1,11 +1,11 @@ +import { getApiProvider } from "@openclaw/ai/internal/runtime"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; // Stream resolution tests cover how embedded runs choose provider, boundary, // native Codex, or custom stream functions and pass auth/cache/signal options. import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { getApiProvider } from "../../llm/api-registry.js"; import { streamSimple } from "../../llm/stream.js"; import * as providerTransportStream from "../provider-transport-stream.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../system-prompt-cache-boundary.js"; import { testing, describeEmbeddedAgentStreamStrategy, diff --git a/src/agents/embedded-agent-runner/stream-resolution.ts b/src/agents/embedded-agent-runner/stream-resolution.ts index 29d5b26900f..65b668d2348 100644 --- a/src/agents/embedded-agent-runner/stream-resolution.ts +++ b/src/agents/embedded-agent-runner/stream-resolution.ts @@ -1,12 +1,12 @@ /** * Resolves provider stream functions and API keys for embedded agents. */ -import { getApiProvider } from "../../llm/api-registry.js"; +import { getApiProvider } from "@openclaw/ai/internal/runtime"; +import { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; import { streamSimple } from "../../llm/stream.js"; import { createAnthropicVertexStreamFnForModel } from "../anthropic-vertex-stream.js"; import { createBoundaryAwareStreamFnForModel } from "../provider-transport-stream.js"; import type { StreamFn } from "../runtime/index.js"; -import { stripSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js"; import type { EmbeddedRunAttemptParams } from "./run/types.js"; let embeddedAgentBaseStreamFnCache = new WeakMap(); diff --git a/src/agents/hook-system-context-boundary.ts b/src/agents/hook-system-context-boundary.ts index e64a4fe90b0..81265a5c957 100644 --- a/src/agents/hook-system-context-boundary.ts +++ b/src/agents/hook-system-context-boundary.ts @@ -1,7 +1,7 @@ /** * Wraps plugin-provided system context in stable prompt-cache boundaries. */ -import { normalizeStructuredPromptSection } from "./prompt-cache-stability.js"; +import { normalizeStructuredPromptSection } from "@openclaw/ai/internal/shared"; // Labels plugin-provided system context so harness prompt compaction and user-facing // transcript views can distinguish it from real workspace files or chat content. diff --git a/src/agents/model-scan.ts b/src/agents/model-scan.ts index f7b85a1e72b..b74938ae997 100644 --- a/src/agents/model-scan.ts +++ b/src/agents/model-scan.ts @@ -1,3 +1,5 @@ +import type { OpenAICompletionsOptions } from "@openclaw/ai/internal/openai"; +import { getEnvApiKey } from "@openclaw/ai/internal/runtime"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { asDateTimestampMs, @@ -17,8 +19,6 @@ import { formatErrorMessage } from "../infra/errors.js"; * Scans remote provider model catalogs for configured providers. */ import { readResponseWithLimit } from "../infra/http-body.js"; -import { getEnvApiKey } from "../llm/env-api-keys.js"; -import type { OpenAICompletionsOptions } from "../llm/providers/openai-completions.js"; import { complete } from "../llm/stream.js"; import type { Context, Model, Tool } from "../llm/types.js"; import { inferParamBFromIdOrName } from "../shared/model-param-b.js"; diff --git a/src/agents/openai-responses-payload-policy.ts b/src/agents/openai-responses-payload-policy.ts index cdbe42655b7..571b2c90a65 100644 --- a/src/agents/openai-responses-payload-policy.ts +++ b/src/agents/openai-responses-payload-policy.ts @@ -1,3 +1,4 @@ +import { supportsOpenAIReasoningEffort } from "@openclaw/ai/internal/openai"; /** * OpenAI Responses payload policy. * Classifies endpoint capabilities and applies store, prompt-cache, @@ -6,7 +7,6 @@ import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { asBoolean } from "../utils/boolean.js"; -import { supportsOpenAIReasoningEffort } from "./openai-reasoning-effort.js"; type OpenAIResponsesPayloadModel = { api?: unknown; diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 3c6823c5ae5..d65c58e5c30 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -1,5 +1,6 @@ // Verifies OpenAI-compatible streaming payloads, failures, and transport wrapping. import { createServer } from "node:http"; +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; import OpenAI from "openai"; import type { ChatCompletionChunk } from "openai/resources/chat/completions.js"; import type { Api, Model } from "openclaw/plugin-sdk/llm"; @@ -26,7 +27,6 @@ import { prepareTransportAwareSimpleModel, resolveTransportAwareSimpleApi, } from "./provider-transport-stream.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; type OpenAICompletionsOutput = Parameters[1]; type OpenAIResponsesOutput = Parameters[1]; diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index c8635a04af3..a48c87e7f74 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -5,6 +5,45 @@ * provider-specific payload policy before converting SDK streams into OpenClaw assistant events. */ import { randomUUID } from "node:crypto"; +import { + clampOpenAIPromptCacheKey, + convertMessages, + findOpenAIStrictToolProjectionDiagnostics, + isOpenAICompatibleAzureResponsesBaseUrl, + isOpenAIGpt54MiniModel, + isOpenAIGpt55Model, + isResponsesTextContentPartType, + isResponsesTextDeltaEventType, + mapOpenAIStopReason, + normalizeOpenAIReasoningEffort, + normalizeOpenAIStrictToolParameters, + projectOpenAITools, + reconcileOpenAICompletionsToolChoice, + reconcileOpenAIResponsesToolChoice, + resolveAzureDeploymentNameFromMap, + resolveOpenAIProjectedToolsStrictToolFlag, + resolveOpenAIReasoningEffortForModel, + resolveResponsesMessageSnapshotCollapse, + type OpenAIApiReasoningEffort, + type OpenAICompletionsToolChoice, + type OpenAIReasoningEffort, + type OpenAIToolProjection, +} from "@openclaw/ai/internal/openai"; +import { + calculateCost, + createFirstStreamEventAbortController, + createReasoningTagTextPartitioner, + getEnvApiKey, + getFirstStreamEventTimeoutHandler, + getFirstStreamEventTimeoutMs, + parseStreamingJson, + withFirstStreamEventTimeout, +} from "@openclaw/ai/internal/runtime"; +import { + describeToolResultMediaPlaceholder, + extractToolResultText, + stripSystemPromptCacheBoundary, +} from "@openclaw/ai/internal/shared"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import OpenAI, { AzureOpenAI } from "openai"; @@ -22,31 +61,14 @@ import type { } from "openai/resources/responses/responses.js"; import type { ModelCompatConfig } from "../config/types.models.js"; import { sha256Hex, sha256HexPrefix } from "../infra/crypto-digest.js"; -import { getEnvApiKey } from "../llm/env-api-keys.js"; -import { calculateCost } from "../llm/model-utils.js"; -import { resolveAzureDeploymentNameFromMap } from "../llm/providers/azure-deployment-map.js"; -import { convertMessages } from "../llm/providers/openai-completions.js"; -import { clampOpenAIPromptCacheKey } from "../llm/providers/openai-prompt-cache.js"; -import { mapOpenAIStopReason } from "../llm/providers/openai-stop-reason.js"; -import { - describeToolResultMediaPlaceholder, - extractToolResultText, -} from "../llm/providers/tool-result-text.js"; import type { Api, Context, Model } from "../llm/types.js"; +import "../llm/ai-transport-host.js"; import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; -import { parseStreamingJson } from "../llm/utils/json-parse.js"; import { redactIdentifier } from "../logging/redact-identifier.js"; import { redactSensitiveText } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import { resolveProviderTransportTurnStateWithPlugin } from "../plugins/provider-runtime.js"; -import { isOpenAICompatibleAzureResponsesBaseUrl } from "../shared/azure-openai-responses-client-compat.js"; -import { - isResponsesTextContentPartType, - isResponsesTextDeltaEventType, - resolveResponsesMessageSnapshotCollapse, -} from "../shared/openai-responses-stream-compat.js"; -import { createReasoningTagTextPartitioner } from "../shared/text/reasoning-tag-text-partitioner.js"; import { CHARS_PER_TOKEN_ESTIMATE, estimateStringChars } from "../utils/cjk-chars.js"; import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./copilot-dynamic-headers.js"; import { createDeepSeekTextFilter } from "./deepseek-text-filter.js"; @@ -65,32 +87,12 @@ import { stripCompletionMessagesToRoleContent, } from "./openai-completions-string-content.js"; import { resolveOpenAIReasoningEffortMap } from "./openai-reasoning-compat.js"; -import { - isOpenAIGpt54MiniModel, - isOpenAIGpt55Model, - normalizeOpenAIReasoningEffort, - resolveOpenAIReasoningEffortForModel, - type OpenAIApiReasoningEffort, - type OpenAIReasoningEffort, -} from "./openai-reasoning-effort.js"; import { applyOpenAIResponsesPayloadPolicy, resolveOpenAIResponsesPayloadPolicy, } from "./openai-responses-payload-policy.js"; import { resolveReplayableResponsesMessageId } from "./openai-responses-replay.js"; import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js"; -import { - projectOpenAITools, - reconcileOpenAICompletionsToolChoice, - reconcileOpenAIResponsesToolChoice, - type OpenAICompletionsToolChoice, - type OpenAIToolProjection, -} from "./openai-tool-projection.js"; -import { - findOpenAIStrictToolProjectionDiagnostics, - normalizeOpenAIStrictToolParameters, - resolveOpenAIProjectedToolsStrictToolFlag, -} from "./openai-tool-schema.js"; import { resolveProviderEndpoint } from "./provider-attribution.js"; import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js"; import { @@ -99,13 +101,6 @@ import { } from "./provider-transport-fetch.js"; import { sanitizeResponsesImagePayload } from "./responses-image-payload-sanitizer.js"; import type { StreamFn } from "./runtime/index.js"; -import { - createFirstStreamEventAbortController, - getFirstStreamEventTimeoutHandler, - getFirstStreamEventTimeoutMs, - withFirstStreamEventTimeout, -} from "./stream-first-event-timeout.js"; -import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js"; import { transformTransportMessages } from "./transport-message-transform.js"; import { assignTransportErrorDetails, diff --git a/src/agents/runtime/proxy.ts b/src/agents/runtime/proxy.ts index 52d8a1c879a..860e2343083 100644 --- a/src/agents/runtime/proxy.ts +++ b/src/agents/runtime/proxy.ts @@ -3,6 +3,11 @@ * The server manages auth and proxies requests to LLM providers. */ +import { + createSseByteGuard, + parseStreamingJson, + type SseByteGuard, +} from "@openclaw/ai/internal/runtime"; import { resolvePositiveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { readResponseWithLimit } from "../../infra/http-body.js"; // Internal import for JSON parsing utility @@ -16,8 +21,6 @@ import type { ToolCall, } from "../../llm/types.js"; import { EventStream } from "../../llm/utils/event-stream.js"; -import { parseStreamingJson } from "../../llm/utils/json-parse.js"; -import { createSseByteGuard, type SseByteGuard } from "../streaming-byte-guard.js"; const PROXY_ERROR_BODY_MAX_BYTES = 16 * 1024 * 1024; const PROXY_SSE_STREAM_MAX_BYTES = 16 * 1024 * 1024; diff --git a/src/agents/sessions/agent-session.ts b/src/agents/sessions/agent-session.ts index 2e03408f0a1..2b9774ce9a0 100644 --- a/src/agents/sessions/agent-session.ts +++ b/src/agents/sessions/agent-session.ts @@ -15,14 +15,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; -import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import { clampThinkingLevel, + cleanupSessionResources, getSupportedThinkingLevels, + isContextOverflow, modelsAreEqual, -} from "../../llm/model-utils.js"; -import { resetApiProviders } from "../../llm/providers/register-builtins.js"; -import { cleanupSessionResources } from "../../llm/session-resources.js"; + defaultApiRegistry, +} from "@openclaw/ai/internal/runtime"; +import { resetApiProviders } from "@openclaw/ai/providers"; +import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js"; import { streamSimple } from "../../llm/stream.js"; import type { AssistantMessage, @@ -31,7 +33,6 @@ import type { Model, TextContent, } from "../../llm/types.js"; -import { isContextOverflow } from "../../llm/utils/overflow.js"; import { isRetryableAssistantError } from "../../llm/utils/retry.js"; import type { Agent, @@ -2572,7 +2573,7 @@ export class AgentSession { reason: "reload", }); await this.settingsManager.reload(); - resetApiProviders(); + resetApiProviders(defaultApiRegistry); await this.sessionResourceLoader.reload(); this.buildRuntime({ activeToolNames: this.getActiveToolNames(), diff --git a/src/agents/sessions/auth-storage.ts b/src/agents/sessions/auth-storage.ts index faf86e96ba0..d9fb08a4f50 100644 --- a/src/agents/sessions/auth-storage.ts +++ b/src/agents/sessions/auth-storage.ts @@ -8,9 +8,9 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { findEnvKeys, getEnvApiKey } from "@openclaw/ai/internal/runtime"; import lockfile from "proper-lockfile"; import { replaceFileAtomicSync } from "../../infra/replace-file.js"; -import { findEnvKeys, getEnvApiKey } from "../../llm/env-api-keys.js"; import { getOAuthApiKey, getOAuthProvider, diff --git a/src/agents/sessions/model-registry.ts b/src/agents/sessions/model-registry.ts index e1d600154db..5194736c842 100644 --- a/src/agents/sessions/model-registry.ts +++ b/src/agents/sessions/model-registry.ts @@ -4,11 +4,11 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { defaultApiRegistry, registerApiProvider } from "@openclaw/ai/internal/runtime"; +import { resetApiProviders } from "@openclaw/ai/providers"; import { type Static, Type } from "typebox"; import { Compile } from "typebox/compile"; import type { TLocalizedValidationError } from "typebox/error"; -import { registerApiProvider } from "../../llm/api-registry.js"; -import { resetApiProviders } from "../../llm/providers/register-builtins.js"; import type { AnthropicMessagesCompat, Api, @@ -348,7 +348,7 @@ export class ModelRegistry { this.loadError = undefined; // Ensure dynamic API/OAuth registrations are rebuilt from current provider state. - resetApiProviders(); + resetApiProviders(defaultApiRegistry); resetOAuthProviders(); this.loadModels(); diff --git a/src/agents/sessions/model-resolver.ts b/src/agents/sessions/model-resolver.ts index ecc7ff3d492..ac9c9895366 100644 --- a/src/agents/sessions/model-resolver.ts +++ b/src/agents/sessions/model-resolver.ts @@ -2,9 +2,9 @@ * Model resolution, scoping, and initial selection */ +import { modelsAreEqual } from "@openclaw/ai/internal/runtime"; import chalk from "chalk"; import { minimatch } from "minimatch"; -import { modelsAreEqual } from "../../llm/model-utils.js"; import type { Model } from "../../llm/types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import type { ThinkingLevel } from "../runtime/index.js"; diff --git a/src/agents/sessions/sdk.ts b/src/agents/sessions/sdk.ts index 3fc4e3f0883..4a697cb3c79 100644 --- a/src/agents/sessions/sdk.ts +++ b/src/agents/sessions/sdk.ts @@ -4,11 +4,11 @@ * Selects models, wires built-in/custom tools, loads resources, and creates AgentSession instances. */ import { join } from "node:path"; +import { clampThinkingLevel } from "@openclaw/ai/internal/runtime"; import { resolveThinkingDefaultForModel, type ThinkingCatalogEntry, } from "../../auto-reply/thinking.js"; -import { clampThinkingLevel } from "../../llm/model-utils.js"; import { streamSimple } from "../../llm/stream.js"; import type { Message, Model } from "../../llm/types.js"; import { getAgentDir } from "../config.js"; diff --git a/src/agents/sessions/session-manager.tool-result-replay.test.ts b/src/agents/sessions/session-manager.tool-result-replay.test.ts index ba6d3b5f390..b3f93dfb210 100644 --- a/src/agents/sessions/session-manager.tool-result-replay.test.ts +++ b/src/agents/sessions/session-manager.tool-result-replay.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { streamAnthropic } from "@openclaw/ai/internal/anthropic"; import { afterEach, describe, expect, it } from "vitest"; -import { streamAnthropic } from "../../llm/providers/anthropic.js"; import type { Context, Message, Model } from "../../llm/types.js"; import type { AgentMessage } from "../runtime/index.js"; import { SessionManager } from "./session-manager.js"; diff --git a/src/agents/sessions/tools/edit.ts b/src/agents/sessions/tools/edit.ts index 976f5e02622..8ef5502ee87 100644 --- a/src/agents/sessions/tools/edit.ts +++ b/src/agents/sessions/tools/edit.ts @@ -10,8 +10,8 @@ import { writeFile as fsWriteFile, } from "node:fs/promises"; import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { Type } from "typebox"; -import { truncateUtf16Safe } from "../../../shared/utf16-slice.js"; import { renderDiff } from "../../modes/interactive/components/diff.js"; import type { AgentTool } from "../../runtime/index.js"; import { textResult } from "../../tools/common.js"; diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 8bb8d8a6f76..fdb2494d8eb 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -1,3 +1,4 @@ +import { supportsOpenAIReasoningEffort } from "@openclaw/ai/internal/openai"; /** * Simple completion runtime preparation. * @@ -33,7 +34,6 @@ import { resolveDefaultModelForAgent, resolveModelRefFromString, } from "./model-selection.js"; -import { supportsOpenAIReasoningEffort } from "./openai-reasoning-effort.js"; import { OPENAI_PROVIDER_ID, isOpenAIProvider } from "./openai-routing.js"; import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js"; import { prepareModelForSimpleCompletion } from "./simple-completion-transport.js"; diff --git a/src/agents/simple-completion-transport.test.ts b/src/agents/simple-completion-transport.test.ts index a487dae070a..03ce19668bc 100644 --- a/src/agents/simple-completion-transport.test.ts +++ b/src/agents/simple-completion-transport.test.ts @@ -1,9 +1,9 @@ +import { registerApiProvider, unregisterApiProviders } from "@openclaw/ai/internal/runtime"; // Simple completion transport tests cover provider-specific stream alias // selection before the generic completion helper invokes the LLM layer. import type { Model } from "openclaw/plugin-sdk/llm"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { registerApiProvider, unregisterApiProviders } from "../llm/api-registry.js"; import { createMoonshotThinkingWrapper } from "../llm/providers/stream-wrappers/moonshot-thinking.js"; const createAnthropicVertexStreamFnForModel = vi.fn(); diff --git a/src/agents/simple-completion-transport.ts b/src/agents/simple-completion-transport.ts index 10ce19cbc37..941a349b0bb 100644 --- a/src/agents/simple-completion-transport.ts +++ b/src/agents/simple-completion-transport.ts @@ -1,10 +1,10 @@ +import { getApiProvider } from "@openclaw/ai/internal/runtime"; /** * Simple completion transport preparation. * * Registers provider-specific stream functions and rewrites models that need OpenClaw-managed transport semantics. */ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { getApiProvider } from "../llm/api-registry.js"; import type { Api, Model } from "../llm/types.js"; import { wrapProviderSimpleCompletionStreamFn } from "../plugins/provider-runtime.js"; import { createAnthropicVertexStreamFnForModel } from "./anthropic-vertex-stream.js"; diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 619c8546306..ad3358d3681 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -1,3 +1,4 @@ +import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "@openclaw/ai/internal/shared"; // System prompt tests cover the main prompt facade, prompt-surface routing, and // user-visible sections for owners, tools, safety, skills, and subagents. import { describe, expect, it } from "vitest"; @@ -6,7 +7,6 @@ import { typedCases } from "../test-utils/typed-cases.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import { resolveAgentPromptSurfaceForSessionKey } from "./prompt-surface.js"; import { buildSubagentSystemPrompt } from "./subagent-system-prompt.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; import { buildAgentBootstrapSystemContext, buildAgentBootstrapSystemPromptSections, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 1c43f6195a7..d3c9b6dca78 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -4,6 +4,11 @@ * Assembles runtime, workspace, tooling, memory, delegation, channel, and cache-boundary prompt sections. */ import { createHmac, createHash } from "node:crypto"; +import { + normalizePromptCapabilityIds, + normalizeStructuredPromptSection, + SYSTEM_PROMPT_CACHE_BOUNDARY, +} from "@openclaw/ai/internal/shared"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -38,10 +43,6 @@ import type { EmbeddedFullAccessBlockedReason, EmbeddedSandboxInfo, } from "./embedded-agent-runner/types.js"; -import { - normalizePromptCapabilityIds, - normalizeStructuredPromptSection, -} from "./prompt-cache-stability.js"; import { buildOpenClawToolFallbackText, shouldRenderOpenClawToolWorkflowHints, @@ -51,7 +52,6 @@ import { buildSkillWorkshopPromptSection, SKILL_WORKSHOP_TOOL_NAME, } from "./skill-workshop-prompt.js"; -import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "./system-prompt-cache-boundary.js"; import type { ProviderSystemPromptContribution, ProviderSystemPromptSectionId, diff --git a/src/agents/tool-description-summary.ts b/src/agents/tool-description-summary.ts index 26a4ca85422..d65d4a34cfe 100644 --- a/src/agents/tool-description-summary.ts +++ b/src/agents/tool-description-summary.ts @@ -5,7 +5,7 @@ */ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; -import { truncateUtf16Safe } from "../shared/utf16-slice.js"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; function normalizeSummaryWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); diff --git a/src/agents/tool-display-common.ts b/src/agents/tool-display-common.ts index 947eb830f70..9ed3b303480 100644 --- a/src/agents/tool-display-common.ts +++ b/src/agents/tool-display-common.ts @@ -8,9 +8,9 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { parseStrictFiniteNumber } from "../infra/parse-finite-number.js"; import { redactToolPayloadText } from "../logging/redact.js"; -import { sliceUtf16Safe } from "../shared/utf16-slice.js"; import { resolveExecDetail, type ToolDetailMode } from "./tool-display-exec.js"; type ToolDisplayActionSpec = { diff --git a/src/agents/tool-display-exec.ts b/src/agents/tool-display-exec.ts index 651b9f914f2..14200a40bec 100644 --- a/src/agents/tool-display-exec.ts +++ b/src/agents/tool-display-exec.ts @@ -1,12 +1,12 @@ -import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; -import { redactToolPayloadText } from "../logging/redact.js"; /** * Exec tool display summaries. * * Turns common shell commands into short redacted labels for tool timelines and transcripts. */ +import { asOptionalObjectRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { redactToolPayloadText } from "../logging/redact.js"; import { formatInlineCodeSpan } from "../shared/markdown-code.js"; -import { sliceUtf16Safe } from "../shared/utf16-slice.js"; import { binaryName, firstPositional, diff --git a/src/agents/tool-schema-projection.ts b/src/agents/tool-schema-projection.ts index 5dcc67bbda5..b89a229435a 100644 --- a/src/agents/tool-schema-projection.ts +++ b/src/agents/tool-schema-projection.ts @@ -1,4 +1,4 @@ -import { projectRuntimeToolInputSchema } from "./tool-schema-json-projection.js"; +import { projectRuntimeToolInputSchema } from "@openclaw/ai/internal/openai"; /** * Projects agent tool schemas into JSON-safe runtime shapes and diagnostics. * Provider/runtime dispatch uses this module to drop incompatible tools before @@ -6,11 +6,11 @@ import { projectRuntimeToolInputSchema } from "./tool-schema-json-projection.js" */ import type { AnyAgentTool } from "./tools/common.js"; -export { projectRuntimeToolInputSchema } from "./tool-schema-json-projection.js"; +export { projectRuntimeToolInputSchema } from "@openclaw/ai/internal/openai"; export type { RuntimeToolInputSchemaJson, RuntimeToolInputSchemaProjection, -} from "./tool-schema-json-projection.js"; +} from "@openclaw/ai/internal/openai"; /** Diagnostic for one incompatible runtime tool schema. */ export type RuntimeToolSchemaDiagnostic = { diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index dbbf6d19861..c944e29f02f 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -1,8 +1,8 @@ +import { normalizeToolParameterSchema } from "@openclaw/ai/internal/openai"; // Cron tool schema tests cover the provider-facing parameter shape and runtime // validation compatibility for cron jobs. import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; -import { normalizeToolParameterSchema } from "../agent-tools-parameter-schema.js"; import { createCronToolSchema } from "./cron-tool.js"; /** Walk a TypeBox schema by dot-separated property path and return sorted keys. */ diff --git a/src/agents/transport-message-transform.ts b/src/agents/transport-message-transform.ts index 689ad90a06c..f538150b290 100644 --- a/src/agents/transport-message-transform.ts +++ b/src/agents/transport-message-transform.ts @@ -1,10 +1,10 @@ +import { resolveModelBoundThinkingReplayMode } from "@openclaw/ai/internal/anthropic"; /** * Normalizes transcript messages before provider transport replay. It drops * unsafe failed turns, maps tool-call ids across model boundaries, and fills * strict provider tool-result gaps when supported. */ import type { Api, Context, Model } from "../llm/types.js"; -import { resolveModelBoundThinkingReplayMode } from "../shared/anthropic-model-contract.js"; import { isReasoningOnlyLengthAssistantTurn } from "./replay-turn-classification.js"; import { repairToolUseResultPairing } from "./session-transcript-repair.js"; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 688be7a23c7..ddb0fcaa75b 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -6,6 +6,7 @@ import { normalizeOptionalString, readStringValue, } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { hasOutboundReplyContent, resolveSendableOutboundReplyParts, @@ -90,7 +91,6 @@ import { CommandLaneClearedError, GatewayDrainingError } from "../../process/com import { CommandLane } from "../../process/lanes.js"; import { defaultRuntime } from "../../runtime.js"; import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js"; -import { truncateUtf16Safe } from "../../shared/utf16-slice.js"; import { isMarkdownCapableMessageChannel, resolveMessageChannel, diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index abd7b5232f3..1dcff172895 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -5,6 +5,7 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { hasOutboundReplyContent, isFastModeAutoProgressPayload, @@ -96,7 +97,6 @@ import { resolveSendPolicy } from "../../sessions/send-policy.js"; import type { SessionWorkAdmissionLease } from "../../sessions/session-lifecycle-admission.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { resolveSilentReplyPolicyFromPolicies } from "../../shared/silent-reply-policy.js"; -import { truncateUtf16Safe } from "../../shared/utf16-slice.js"; import { createTtsDirectiveTextStreamCleaner } from "../../tts/directives.js"; import { normalizeTtsAutoMode, diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index fc0f79198d6..35df5c87be9 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,5 +1,6 @@ /** OpenRouter free-model scanner and fallback updater for model commands. */ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompts"; +import { getEnvApiKey } from "@openclaw/ai/internal/runtime"; import { stylePromptHint, stylePromptMessage, @@ -15,7 +16,6 @@ import { parseStrictFiniteNumber, parseStrictPositiveInteger, } from "../../infra/parse-finite-number.js"; -import { getEnvApiKey } from "../../llm/env-api-keys.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; import { pad, truncate } from "./list.format.js"; import { loadModelsConfig } from "./load-config.js"; diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index 0a658d64c85..2d94e601ef7 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -1,5 +1,5 @@ // Context-engine delegates bridge custom engines to built-in compaction and memory prompt paths. -import { normalizeStructuredPromptSection } from "../agents/prompt-cache-stability.js"; +import { normalizeStructuredPromptSection } from "@openclaw/ai/internal/shared"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import { buildMemoryPromptSection } from "../plugins/memory-state.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; diff --git a/src/cron/service/failure-alerts.ts b/src/cron/service/failure-alerts.ts index 2c22b49ba8a..03d52449805 100644 --- a/src/cron/service/failure-alerts.ts +++ b/src/cron/service/failure-alerts.ts @@ -1,7 +1,7 @@ /** Resolves and emits cron failure-alert notifications. */ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { resolveFailoverReasonFromError } from "../../agents/failover-error.js"; -import { truncateUtf16Safe } from "../../shared/utf16-slice.js"; import type { CronFailureNotificationDelivery, CronJob, CronMessageChannel } from "../types.js"; import type { CronServiceState } from "./state.js"; diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts index 249e76a0971..b38f3091235 100644 --- a/src/infra/exec-approval-command-display.ts +++ b/src/infra/exec-approval-command-display.ts @@ -1,10 +1,10 @@ +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; // Sanitizes command text before it is displayed in approval prompts. import { computeSensitiveRedactionBitmap, redactSensitiveText, resolveRedactOptions, } from "../logging/redact.js"; -import { truncateUtf16Safe } from "../shared/utf16-slice.js"; import type { ExecApprovalRequestPayload } from "./exec-approvals.js"; // Escape control characters, Unicode format/line/paragraph separators, unpaired surrogates, diff --git a/src/llm/ai-transport-host.ts b/src/llm/ai-transport-host.ts new file mode 100644 index 00000000000..54a691f0e32 --- /dev/null +++ b/src/llm/ai-transport-host.ts @@ -0,0 +1,36 @@ +// Installs OpenClaw-owned policy ports before package providers or shared +// transport helpers run. Direct transport imports need the same wiring as the +// process-default stream facade. +import { configureAiTransportHost } from "@openclaw/ai"; +import { resolveOpenAIStrictToolSetting } from "../agents/openai-strict-tool-setting.js"; +import { buildGuardedModelFetch } from "../agents/provider-transport-fetch.js"; +import { redactSecrets, redactToolPayloadText } from "../logging/redact.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const transportLogBySubsystem = new Map>(); + +function transportLog(subsystem: string): ReturnType { + let log = transportLogBySubsystem.get(subsystem); + if (!log) { + log = createSubsystemLogger(subsystem); + transportLogBySubsystem.set(subsystem, log); + } + return log; +} + +configureAiTransportHost({ + buildModelFetch: buildGuardedModelFetch, + redactSecrets, + redactToolPayloadText, + resolveOpenAIStrictToolSetting, + logDebug: (subsystem, build) => { + const log = transportLog(subsystem); + if (!log.isEnabled("debug", "any")) { + return; + } + const entry = build(); + if (entry) { + log.debug(entry.message, entry.data); + } + }, +}); diff --git a/src/llm/api-registry.ts b/src/llm/api-registry.ts deleted file mode 100644 index 5a4b02da74b..00000000000 --- a/src/llm/api-registry.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Core facade for the shared LLM runtime API provider registry. -export * from "../../packages/llm-runtime/src/api-registry.js"; diff --git a/src/llm/env-api-keys.test.ts b/src/llm/env-api-keys.test.ts index df33c15993b..a21cfe9159a 100644 --- a/src/llm/env-api-keys.test.ts +++ b/src/llm/env-api-keys.test.ts @@ -27,7 +27,7 @@ afterEach(async () => { describe("getEnvApiKey", () => { it("returns no env auth in browser contexts without process", async () => { vi.resetModules(); - const { findEnvKeys, getEnvApiKey } = await import("./env-api-keys.js"); + const { findEnvKeys, getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); vi.stubGlobal("process", undefined); expect(findEnvKeys("openai")).toBeUndefined(); @@ -49,7 +49,7 @@ describe("getEnvApiKey", () => { }, async () => { vi.resetModules(); - const { getEnvApiKey } = await import("./env-api-keys.js"); + const { getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); expect(getEnvApiKey("google-vertex")).toBe(""); }, @@ -65,7 +65,7 @@ describe("getEnvApiKey", () => { }, async () => { vi.resetModules(); - const { findEnvKeys, getEnvApiKey } = await import("./env-api-keys.js"); + const { findEnvKeys, getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); expect(findEnvKeys("moonshot")).toEqual(["MOONSHOT_API_KEY", "KIMI_API_KEY"]); expect(getEnvApiKey("moonshot")).toBe("moonshot-key"); @@ -80,7 +80,7 @@ describe("getEnvApiKey", () => { it("falls back to alternate canonical Kimi env vars", async () => { await withEnvAsync({ KIMICODE_API_KEY: "kimicode-key" }, async () => { vi.resetModules(); - const { findEnvKeys, getEnvApiKey } = await import("./env-api-keys.js"); + const { findEnvKeys, getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); expect(findEnvKeys("kimi")).toEqual(["KIMICODE_API_KEY"]); expect(getEnvApiKey("kimi")).toBe("kimicode-key"); @@ -99,7 +99,7 @@ describe("getEnvApiKey", () => { }, async () => { vi.resetModules(); - const { getEnvApiKey } = await import("./env-api-keys.js"); + const { getEnvApiKey } = await import("@openclaw/ai/internal/runtime"); expect(getEnvApiKey("google-vertex")).toBeUndefined(); await writeFile(credentialsPath, "{}", "utf-8"); diff --git a/src/llm/model-utils.test.ts b/src/llm/model-utils.test.ts index 800ec2af016..9824006d984 100644 --- a/src/llm/model-utils.test.ts +++ b/src/llm/model-utils.test.ts @@ -1,5 +1,5 @@ +import { clampThinkingLevel, getSupportedThinkingLevels } from "@openclaw/ai/internal/runtime"; import { describe, expect, it } from "vitest"; -import { clampThinkingLevel, getSupportedThinkingLevels } from "./model-utils.js"; import type { Model } from "./types.js"; function makeModel( diff --git a/src/llm/providers/openai-compatible-auth.test.ts b/src/llm/openai-compatible-auth.test.ts similarity index 91% rename from src/llm/providers/openai-compatible-auth.test.ts rename to src/llm/openai-compatible-auth.test.ts index c3a27971e48..33685a31ec1 100644 --- a/src/llm/providers/openai-compatible-auth.test.ts +++ b/src/llm/openai-compatible-auth.test.ts @@ -1,17 +1,20 @@ +import type { Context, Model } from "@openclaw/ai"; +import { streamOpenAICompletions, streamOpenAIResponses } from "@openclaw/ai/internal/openai"; // OpenAI-compatible auth tests cover API key and base URL normalization. +// Lives in core: it proves the facade-installed guarded fetch routes provider +// requests through OpenClaw's SSRF guard. import { afterEach, describe, expect, it, vi } from "vitest"; -import { captureEnv } from "../../test-utils/env.js"; -import type { Context, Model } from "../types.js"; -import { streamOpenAICompletions } from "./openai-completions.js"; -import { streamOpenAIResponses } from "./openai-responses.js"; +import { captureEnv } from "../test-utils/env.js"; +// Importing the facade installs the OpenClaw AI transport host ports. +import "./stream.js"; const mocks = vi.hoisted(() => ({ fetchWithSsrFGuard: vi.fn(), })); -vi.mock("../../infra/net/fetch-guard.js", async () => { - const actual = await vi.importActual( - "../../infra/net/fetch-guard.js", +vi.mock("../infra/net/fetch-guard.js", async () => { + const actual = await vi.importActual( + "../infra/net/fetch-guard.js", ); return { ...actual, diff --git a/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts b/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts index 7c40fdd117e..51f04aa4002 100644 --- a/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts +++ b/src/llm/providers/anthropic-cloudflare.fetch-proof.test.ts @@ -1,13 +1,15 @@ +import type { Context, Model } from "@openclaw/ai"; // Anthropic Cloudflare AI Gateway constructor guard-specific proof: the SSRF // guard blocks a private-IP request before the SDK's default global fetch is -// ever reached. This proves `buildGuardedModelFetch` is actively wired into -// the Cloudflare branch, not just present in constructor options. +// ever reached. This proves the stream facade installs OpenClaw's guarded +// fetch through the AI transport host, not just that a fetch option exists. // // Unlike anthropic.test.ts (which mocks the Anthropic SDK to verify // constructor options), this test stubs `globalThis.fetch` to COUNT calls. -// Behavior only `buildGuardedModelFetch` can produce. +// Behavior only the guarded model fetch can produce. import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Context, Model } from "../types.js"; +// Importing the facade installs the OpenClaw AI transport host ports. +import "../stream.js"; const CLOUDFLARE_ANTHROPIC_MODEL = { id: "claude-sonnet-4-6", @@ -47,7 +49,7 @@ describe("Anthropic Cloudflare guard-specific SSRF blocking proof", () => { baseUrl: "http://169.254.169.254/v1", } satisfies Model<"anthropic-messages">; - const { streamAnthropic } = await import("./anthropic.js"); + const { streamAnthropic } = await import("@openclaw/ai/internal/anthropic"); const stream = streamAnthropic(blockedModel, context, { apiKey: "sk-ant-test" }); const result = await stream.result(); diff --git a/src/llm/providers/register-builtins.ts b/src/llm/providers/register-builtins.ts deleted file mode 100644 index 432cbb4332f..00000000000 --- a/src/llm/providers/register-builtins.ts +++ /dev/null @@ -1,420 +0,0 @@ -// Built-in provider registration installs the bundled provider factories. -import { registerApiProvider, unregisterApiProviders } from "../api-registry.js"; -import type { - Api, - AssistantMessage, - AssistantMessageEvent, - Context, - Model, - SimpleStreamOptions, - StreamFunction, - StreamOptions, -} from "../types.js"; -import { AssistantMessageEventStream } from "../utils/event-stream.js"; -import type { AnthropicOptions } from "./anthropic.js"; -import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses.js"; -import type { GoogleVertexOptions } from "./google-vertex.js"; -import type { GoogleOptions } from "./google.js"; -import type { MistralOptions } from "./mistral.js"; -import type { OpenAICodexResponsesOptions } from "./openai-chatgpt-responses.js"; -import type { OpenAICompletionsOptions } from "./openai-completions.js"; -import type { OpenAIResponsesOptions } from "./openai-responses.js"; - -// Lazy built-in provider registration keeps the main LLM stream facade cheap to import. -interface LazyProviderModule< - TApi extends Api, - TOptions extends StreamOptions, - TSimpleOptions extends SimpleStreamOptions, -> { - stream: ( - model: Model, - context: Context, - options?: TOptions, - ) => AsyncIterable; - streamSimple: ( - model: Model, - context: Context, - options?: TSimpleOptions, - ) => AsyncIterable; -} - -interface AnthropicProviderModule { - streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOptions>; - streamSimpleAnthropic: StreamFunction<"anthropic-messages", SimpleStreamOptions>; -} - -interface AzureOpenAIResponsesProviderModule { - streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions>; - streamSimpleAzureOpenAIResponses: StreamFunction<"azure-openai-responses", SimpleStreamOptions>; -} - -interface GoogleProviderModule { - streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions>; - streamSimpleGoogle: StreamFunction<"google-generative-ai", SimpleStreamOptions>; -} - -interface GoogleVertexProviderModule { - streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOptions>; - streamSimpleGoogleVertex: StreamFunction<"google-vertex", SimpleStreamOptions>; -} - -interface MistralProviderModule { - streamMistral: StreamFunction<"mistral-conversations", MistralOptions>; - streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions>; -} - -interface OpenAICodexResponsesProviderModule { - streamOpenAICodexResponses: StreamFunction< - "openai-chatgpt-responses", - OpenAICodexResponsesOptions - >; - streamSimpleOpenAICodexResponses: StreamFunction<"openai-chatgpt-responses", SimpleStreamOptions>; -} - -interface OpenAICompletionsProviderModule { - streamOpenAICompletions: StreamFunction<"openai-completions", OpenAICompletionsOptions>; - streamSimpleOpenAICompletions: StreamFunction<"openai-completions", SimpleStreamOptions>; -} - -interface OpenAIResponsesProviderModule { - streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions>; - streamSimpleOpenAIResponses: StreamFunction<"openai-responses", SimpleStreamOptions>; -} - -/** Source id used for built-in API provider registrations. */ -export const BUILT_IN_API_PROVIDER_SOURCE_ID = "core:built-in"; - -let anthropicProviderModulePromise: - | Promise> - | undefined; -let azureOpenAIResponsesProviderModulePromise: - | Promise< - LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions> - > - | undefined; -let googleProviderModulePromise: - | Promise> - | undefined; -let googleVertexProviderModulePromise: - | Promise> - | undefined; -let mistralProviderModulePromise: - | Promise> - | undefined; -let openAICodexResponsesProviderModulePromise: - | Promise< - LazyProviderModule< - "openai-chatgpt-responses", - OpenAICodexResponsesOptions, - SimpleStreamOptions - > - > - | undefined; -let openAICompletionsProviderModulePromise: - | Promise> - | undefined; -let openAIResponsesProviderModulePromise: - | Promise> - | undefined; - -function forwardStream( - target: AssistantMessageEventStream, - source: AsyncIterable, -): void { - void (async () => { - for await (const event of source) { - target.push(event); - } - target.end(); - })(); -} - -function createLazyLoadErrorMessage( - model: Model, - error: unknown, -): AssistantMessage { - return { - role: "assistant", - content: [], - api: model.api, - provider: model.provider, - model: model.id, - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "error", - errorMessage: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - }; -} - -// Provider modules load on first use, but callers still receive a stream synchronously. -function createLazyStream< - TApi extends Api, - TOptions extends StreamOptions, - TSimpleOptions extends SimpleStreamOptions, ->( - loadModule: () => Promise>, -): StreamFunction { - return (model, context, options) => { - const outer = new AssistantMessageEventStream(); - - loadModule() - .then((module) => { - const inner = module.stream(model, context, options); - forwardStream(outer, inner); - }) - .catch((error: unknown) => { - // Surface lazy-load failures as normal assistant error messages for stream consumers. - const message = createLazyLoadErrorMessage(model, error); - outer.push({ type: "error", reason: "error", error: message }); - outer.end(message); - }); - - return outer; - }; -} - -function createLazySimpleStream< - TApi extends Api, - TOptions extends StreamOptions, - TSimpleOptions extends SimpleStreamOptions, ->( - loadModule: () => Promise>, -): StreamFunction { - return (model, context, options) => { - const outer = new AssistantMessageEventStream(); - - loadModule() - .then((module) => { - const inner = module.streamSimple(model, context, options); - forwardStream(outer, inner); - }) - .catch((error: unknown) => { - const message = createLazyLoadErrorMessage(model, error); - outer.push({ type: "error", reason: "error", error: message }); - outer.end(message); - }); - - return outer; - }; -} - -function loadAnthropicProviderModule(): Promise< - LazyProviderModule<"anthropic-messages", AnthropicOptions, SimpleStreamOptions> -> { - anthropicProviderModulePromise ||= import("./anthropic.js").then((module) => { - const provider = module as AnthropicProviderModule; - return { - stream: provider.streamAnthropic, - streamSimple: provider.streamSimpleAnthropic, - }; - }); - return anthropicProviderModulePromise; -} - -function loadAzureOpenAIResponsesProviderModule(): Promise< - LazyProviderModule<"azure-openai-responses", AzureOpenAIResponsesOptions, SimpleStreamOptions> -> { - azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses.js").then( - (module) => { - const provider = module as AzureOpenAIResponsesProviderModule; - return { - stream: provider.streamAzureOpenAIResponses, - streamSimple: provider.streamSimpleAzureOpenAIResponses, - }; - }, - ); - return azureOpenAIResponsesProviderModulePromise; -} - -function loadGoogleProviderModule(): Promise< - LazyProviderModule<"google-generative-ai", GoogleOptions, SimpleStreamOptions> -> { - googleProviderModulePromise ||= import("./google.js").then((module) => { - const provider = module as GoogleProviderModule; - return { - stream: provider.streamGoogle, - streamSimple: provider.streamSimpleGoogle, - }; - }); - return googleProviderModulePromise; -} - -function loadGoogleVertexProviderModule(): Promise< - LazyProviderModule<"google-vertex", GoogleVertexOptions, SimpleStreamOptions> -> { - googleVertexProviderModulePromise ||= import("./google-vertex.js").then((module) => { - const provider = module as GoogleVertexProviderModule; - return { - stream: provider.streamGoogleVertex, - streamSimple: provider.streamSimpleGoogleVertex, - }; - }); - return googleVertexProviderModulePromise; -} - -function loadMistralProviderModule(): Promise< - LazyProviderModule<"mistral-conversations", MistralOptions, SimpleStreamOptions> -> { - mistralProviderModulePromise ||= import("./mistral.js").then((module) => { - const provider = module as MistralProviderModule; - return { - stream: provider.streamMistral, - streamSimple: provider.streamSimpleMistral, - }; - }); - return mistralProviderModulePromise; -} - -function loadOpenAICodexResponsesProviderModule(): Promise< - LazyProviderModule<"openai-chatgpt-responses", OpenAICodexResponsesOptions, SimpleStreamOptions> -> { - openAICodexResponsesProviderModulePromise ||= import("./openai-chatgpt-responses.js").then( - (module) => { - const provider = module as OpenAICodexResponsesProviderModule; - return { - stream: provider.streamOpenAICodexResponses, - streamSimple: provider.streamSimpleOpenAICodexResponses, - }; - }, - ); - return openAICodexResponsesProviderModulePromise; -} - -function loadOpenAICompletionsProviderModule(): Promise< - LazyProviderModule<"openai-completions", OpenAICompletionsOptions, SimpleStreamOptions> -> { - openAICompletionsProviderModulePromise ||= import("./openai-completions.js").then((module) => { - const provider = module as OpenAICompletionsProviderModule; - return { - stream: provider.streamOpenAICompletions, - streamSimple: provider.streamSimpleOpenAICompletions, - }; - }); - return openAICompletionsProviderModulePromise; -} - -function loadOpenAIResponsesProviderModule(): Promise< - LazyProviderModule<"openai-responses", OpenAIResponsesOptions, SimpleStreamOptions> -> { - openAIResponsesProviderModulePromise ||= import("./openai-responses.js").then((module) => { - const provider = module as OpenAIResponsesProviderModule; - return { - stream: provider.streamOpenAIResponses, - streamSimple: provider.streamSimpleOpenAIResponses, - }; - }); - return openAIResponsesProviderModulePromise; -} - -export const streamAnthropic = createLazyStream(loadAnthropicProviderModule); -export const streamSimpleAnthropic = createLazySimpleStream(loadAnthropicProviderModule); -export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule); -export const streamSimpleAzureOpenAIResponses = createLazySimpleStream( - loadAzureOpenAIResponsesProviderModule, -); -export const streamGoogle = createLazyStream(loadGoogleProviderModule); -export const streamSimpleGoogle = createLazySimpleStream(loadGoogleProviderModule); -export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule); -export const streamSimpleGoogleVertex = createLazySimpleStream(loadGoogleVertexProviderModule); -export const streamMistral = createLazyStream(loadMistralProviderModule); -export const streamSimpleMistral = createLazySimpleStream(loadMistralProviderModule); -export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule); -export const streamSimpleOpenAICodexResponses = createLazySimpleStream( - loadOpenAICodexResponsesProviderModule, -); -export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule); -export const streamSimpleOpenAICompletions = createLazySimpleStream( - loadOpenAICompletionsProviderModule, -); -export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule); -export const streamSimpleOpenAIResponses = createLazySimpleStream( - loadOpenAIResponsesProviderModule, -); - -/** Registers all built-in API providers into the shared runtime registry. */ -export function registerBuiltInApiProviders(): void { - registerApiProvider( - { - api: "anthropic-messages", - stream: streamAnthropic, - streamSimple: streamSimpleAnthropic, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); - - registerApiProvider( - { - api: "openai-completions", - stream: streamOpenAICompletions, - streamSimple: streamSimpleOpenAICompletions, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); - - registerApiProvider( - { - api: "mistral-conversations", - stream: streamMistral, - streamSimple: streamSimpleMistral, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); - - registerApiProvider( - { - api: "openai-responses", - stream: streamOpenAIResponses, - streamSimple: streamSimpleOpenAIResponses, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); - - registerApiProvider( - { - api: "azure-openai-responses", - stream: streamAzureOpenAIResponses, - streamSimple: streamSimpleAzureOpenAIResponses, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); - - registerApiProvider( - { - api: "openai-chatgpt-responses", - stream: streamOpenAICodexResponses, - streamSimple: streamSimpleOpenAICodexResponses, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); - - registerApiProvider( - { - api: "google-generative-ai", - stream: streamGoogle, - streamSimple: streamSimpleGoogle, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); - - registerApiProvider( - { - api: "google-vertex", - stream: streamGoogleVertex, - streamSimple: streamSimpleGoogleVertex, - }, - BUILT_IN_API_PROVIDER_SOURCE_ID, - ); -} - -/** Restores the built-in provider registry state for tests. */ -export function resetApiProviders(): void { - unregisterApiProviders(BUILT_IN_API_PROVIDER_SOURCE_ID); - registerBuiltInApiProviders(); -} diff --git a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts index 8832b6c81a9..8cd0a9b7602 100644 --- a/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts +++ b/src/llm/providers/stream-wrappers/anthropic-family-tool-payload-compat.ts @@ -1,8 +1,8 @@ +import { projectRuntimeToolInputSchema } from "@openclaw/ai/internal/openai"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; // Anthropic-family tool payload compatibility wraps provider tool payload shapes. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { StreamFn } from "../../../agents/runtime/index.js"; -import { projectRuntimeToolInputSchema } from "../../../agents/tool-schema-json-projection.js"; import { streamSimple } from "../../stream.js"; type AnthropicToolSchemaMode = "openai-functions"; type AnthropicToolChoiceMode = "openai-string-modes"; diff --git a/src/llm/providers/stream-wrappers/openai.ts b/src/llm/providers/stream-wrappers/openai.ts index 4348872dfe9..5d3763620ff 100644 --- a/src/llm/providers/stream-wrappers/openai.ts +++ b/src/llm/providers/stream-wrappers/openai.ts @@ -1,3 +1,7 @@ +import { + resolveOpenAIReasoningEffortForModel, + supportsOpenAIReasoningEffort, +} from "@openclaw/ai/internal/openai"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; // OpenAI stream wrapper normalizes OpenAI-compatible streamed tool and text events. import { @@ -14,10 +18,6 @@ import { flattenCompletionMessagesToStringContent, stripCompletionMessagesToRoleContent, } from "../../../agents/openai-completions-string-content.js"; -import { - resolveOpenAIReasoningEffortForModel, - supportsOpenAIReasoningEffort, -} from "../../../agents/openai-reasoning-effort.js"; import { applyOpenAIResponsesPayloadPolicy, resolveOpenAIResponsesPayloadPolicy, diff --git a/src/llm/stream.ts b/src/llm/stream.ts index ae95a8fe7e9..55f3ef9d84b 100644 --- a/src/llm/stream.ts +++ b/src/llm/stream.ts @@ -1,13 +1,17 @@ // Streams LLM responses through registered providers and normalizes events. -import { registerBuiltInApiProviders } from "./providers/register-builtins.js"; +// This facade owns the process-default AI runtime wiring: it installs the +// OpenClaw host policy ports and registers built-in providers exactly once, +// before any caller imports the stream API. +import { defaultApiRegistry } from "@openclaw/ai/internal/runtime"; +import { registerBuiltInApiProviders } from "@openclaw/ai/providers"; +import "./ai-transport-host.js"; -// Register built-ins as a side effect before re-exporting the shared runtime stream API. -registerBuiltInApiProviders(); +registerBuiltInApiProviders(defaultApiRegistry); export { complete, completeSimple, + getEnvApiKey, stream, streamSimple, -} from "../../packages/llm-runtime/src/stream.js"; -export { getEnvApiKey } from "./env-api-keys.js"; +} from "@openclaw/ai/internal/runtime"; diff --git a/src/llm/tool-result-redaction.test.ts b/src/llm/tool-result-redaction.test.ts new file mode 100644 index 00000000000..5b35994de54 --- /dev/null +++ b/src/llm/tool-result-redaction.test.ts @@ -0,0 +1,75 @@ +import { extractToolResultText } from "@openclaw/ai/internal/shared"; +// Proves the OpenClaw redaction contract applies to provider tool-result +// replay text once the stream facade installs the AI transport host ports. +import { describe, expect, it } from "vitest"; +// Importing the facade installs the OpenClaw AI transport host ports. +import "./stream.js"; + +describe("tool result redaction via AI transport host", () => { + it("redacts structured secret fields with the shared tool-payload contract", () => { + const text = extractToolResultText([ + { + type: "json", + apiToken: "api-token-value-1234567890", + privateKey: "private-key-value-1234567890", + private_key: "private-key-snake-1234567890", + key: "generic-key-value-1234567890", + keyMaterial: "key-material-value-1234567890", + bearerToken: "bearer-token-value-1234567890", + bearer_token: "bearer-token-snake-value-1234567890", + jwt: "jwt-value-1234567890", + session: "session-value-1234567890", + code: "code-value-1234567890", + error: { code: "ERR_VISIBLE_PROVIDER_CODE" }, + oauth: { code: "OPAQUEPROVIDERCODE1234567890" }, + providerError: { error: { code: "ERR_VISIBLE_PROVIDER_NESTED_CODE" } }, + signature: "signature-value-1234567890", + cookie: "cookie-value-1234567890", + "set-cookie": "set-cookie-value-1234567890", + paymentCredential: "payment-credential-value-1234567890", + cardNumber: 4111111111111111, + cvc: 123, + text: '{"apiToken":"api-token-in-text-1234567890","code":"oauth-code-in-text-1234567890","safe":"ok"}', + credential: "live-credential-value", + appSecret: "app-secret-value", + rawSecret: "raw-secret-value", + nested: { + token: "nested-token-value", + visible: "safe-value", + }, + }, + ]); + + expect(text).toContain('"credential":"'); + expect(text).toContain('"appSecret":"'); + expect(text).toContain('"rawSecret":"'); + expect(text).toContain('"token":"'); + expect(text).toContain('"visible":"safe-value"'); + expect(text).toContain('"code":"ERR_VISIBLE_PROVIDER_CODE"'); + expect(text).toContain('"code":"ERR_VISIBLE_PROVIDER_NESTED_CODE"'); + expect(text).not.toContain("api-token-value-1234567890"); + expect(text).not.toContain("private-key-value-1234567890"); + expect(text).not.toContain("private-key-snake-1234567890"); + expect(text).not.toContain("generic-key-value-1234567890"); + expect(text).not.toContain("key-material-value-1234567890"); + expect(text).not.toContain("bearer-token-value-1234567890"); + expect(text).not.toContain("bearer-token-snake-value-1234567890"); + expect(text).not.toContain("jwt-value-1234567890"); + expect(text).not.toContain("session-value-1234567890"); + expect(text).not.toContain("code-value-1234567890"); + expect(text).not.toContain("OPAQUEPROVIDERCODE1234567890"); + expect(text).not.toContain("signature-value-1234567890"); + expect(text).not.toContain("cookie-value-1234567890"); + expect(text).not.toContain("set-cookie-value-1234567890"); + expect(text).not.toContain("payment-credential-value-1234567890"); + expect(text).not.toContain("4111111111111111"); + expect(text).not.toContain('"cvc":123'); + expect(text).not.toContain("api-token-in-text-1234567890"); + expect(text).not.toContain("oauth-code-in-text-1234567890"); + expect(text).toContain('\\"safe\\":\\"ok\\"'); + expect(text).not.toContain("live-credential-value"); + expect(text).not.toContain("app-secret-value"); + expect(text).not.toContain("raw-secret-value"); + expect(text).not.toContain("nested-token-value"); + }); +}); diff --git a/src/llm/utils/diagnostics.ts b/src/llm/utils/diagnostics.ts deleted file mode 100644 index 5ed25f670b7..00000000000 --- a/src/llm/utils/diagnostics.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Core facade for shared LLM diagnostic helpers. -export * from "../../../packages/llm-core/src/utils/diagnostics.js"; diff --git a/src/llm/utils/event-stream.ts b/src/llm/utils/event-stream.ts index 11d1b75311d..679cc4c909a 100644 --- a/src/llm/utils/event-stream.ts +++ b/src/llm/utils/event-stream.ts @@ -1,2 +1,2 @@ -// Core facade for shared assistant-message event stream utilities. -export * from "../../../packages/llm-core/src/utils/event-stream.js"; +// Core facade uses the public package so all runtimes share constructor identity. +export * from "@openclaw/ai/event-stream"; diff --git a/src/llm/utils/json-parse.test.ts b/src/llm/utils/json-parse.test.ts index 57ee116c635..a919136121f 100644 --- a/src/llm/utils/json-parse.test.ts +++ b/src/llm/utils/json-parse.test.ts @@ -1,6 +1,6 @@ +import { parseJsonWithRepair, parseStreamingJson, repairJson } from "@openclaw/ai/internal/runtime"; // JSON parse tests cover tolerant parsing of partial model JSON output. import { describe, expect, it } from "vitest"; -import { parseJsonWithRepair, parseStreamingJson, repairJson } from "./json-parse.js"; describe("json-parse repairJson invalid \\u escapes", () => { it("repairs a \\u not followed by four hex digits so the result parses", () => { diff --git a/src/plugin-sdk/llm.ts b/src/plugin-sdk/llm.ts index 8ddeb591f15..577876962e2 100644 --- a/src/plugin-sdk/llm.ts +++ b/src/plugin-sdk/llm.ts @@ -1,21 +1,24 @@ /** * Public SDK subpath for LLM provider registration, streaming, model utils, and validation. */ +export type { ApiProvider } from "@openclaw/ai"; export { + calculateCost, + clampThinkingLevel, getApiProvider, getApiProviders, + getEnvApiKey, + parseStreamingJson, registerApiProvider, + sanitizeSurrogates, unregisterApiProviders, - type ApiProvider, -} from "../llm/api-registry.js"; -export { getEnvApiKey } from "../llm/env-api-keys.js"; -export { calculateCost, clampThinkingLevel } from "../llm/model-utils.js"; +} from "@openclaw/ai/internal/runtime"; export { adjustMaxTokensForThinking, buildBaseOptions, clampReasoning, -} from "../llm/providers/simple-options.js"; -export { transformMessages } from "../llm/providers/transform-messages.js"; +} from "@openclaw/ai/internal/shared"; +export { transformMessages } from "@openclaw/ai/internal/shared"; export { complete, completeSimple, stream, streamSimple } from "../llm/stream.js"; export type { Api, @@ -48,7 +51,5 @@ export { AssistantMessageEventStream, createAssistantMessageEventStream, } from "../../packages/llm-core/src/utils/event-stream.js"; -export { parseStreamingJson } from "../llm/utils/json-parse.js"; export { createHttpProxyAgentsForTarget } from "../llm/utils/node-http-proxy.js"; -export { sanitizeSurrogates } from "../llm/utils/sanitize-unicode.js"; export { validateToolArguments, validateToolCall } from "../../packages/llm-core/src/validation.js"; diff --git a/src/plugin-sdk/provider-stream-shared.ts b/src/plugin-sdk/provider-stream-shared.ts index 7b30a97d6d1..b6448d8aef8 100644 --- a/src/plugin-sdk/provider-stream-shared.ts +++ b/src/plugin-sdk/provider-stream-shared.ts @@ -1,5 +1,6 @@ // Provider stream shared helpers implement reusable stream wrappers and payload policies. import { randomUUID } from "node:crypto"; +import { resolveOpenAIReasoningEffortForModel } from "@openclaw/ai/internal/openai"; import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js"; import { extractStandalonePlainTextToolCallText, @@ -10,16 +11,15 @@ import { type PlainTextToolCallMessageNormalization, } from "../../packages/tool-call-repair/src/index.js"; import { resolveOpenAIReasoningEffortMap } from "../agents/openai-reasoning-compat.js"; -import { resolveOpenAIReasoningEffortForModel } from "../agents/openai-reasoning-effort.js"; import type { StreamFn } from "../agents/runtime/index.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import { mapThinkingLevelToReasoningEffort } from "../llm/providers/stream-wrappers/reasoning-effort-utils.js"; import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js"; import { streamSimple } from "../llm/stream.js"; import { createAssistantMessageEventStream } from "../llm/utils/event-stream.js"; -export { applyAnthropicRefusal } from "../shared/anthropic-refusal.js"; -export { createDeferredEventBuffer } from "../shared/deferred-event-buffer.js"; -export { notifyLlmRequestActivity, onLlmRequestActivity } from "../shared/llm-request-activity.js"; +export { applyAnthropicRefusal } from "@openclaw/ai/internal/anthropic"; +export { createDeferredEventBuffer } from "@openclaw/ai/internal/runtime"; +export { notifyLlmRequestActivity, onLlmRequestActivity } from "@openclaw/ai/internal/runtime"; type ProviderWrapStreamFnContext = import("../plugins/types.js").ProviderWrapStreamFnContext; diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts index e2ce87e1b91..d5c7a010494 100644 --- a/src/plugin-sdk/provider-tools.ts +++ b/src/plugin-sdk/provider-tools.ts @@ -1,10 +1,10 @@ -// Provider tool helpers expose shared tool-call payload contracts for provider plugins. -import type { TSchema } from "typebox"; import { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, -} from "../agents/schema/clean-for-gemini.js"; -import { stripUnsupportedSchemaKeywords } from "../shared/schema-keyword-strip.js"; + stripUnsupportedSchemaKeywords, +} from "@openclaw/ai/internal/openai"; +// Provider tool helpers expose shared tool-call payload contracts for provider plugins. +import type { TSchema } from "typebox"; import type { AnyAgentTool, ProviderNormalizeToolSchemasContext, diff --git a/src/plugin-sdk/provider-transport-runtime.ts b/src/plugin-sdk/provider-transport-runtime.ts index 2e1118ef165..0ff946bc621 100644 --- a/src/plugin-sdk/provider-transport-runtime.ts +++ b/src/plugin-sdk/provider-transport-runtime.ts @@ -3,12 +3,12 @@ */ export { buildGuardedModelFetch } from "../agents/provider-transport-fetch.js"; export { buildOpenAICompletionsParams } from "../agents/openai-transport-stream.js"; -export { stripSystemPromptCacheBoundary } from "../agents/system-prompt-cache-boundary.js"; +export { stripSystemPromptCacheBoundary } from "@openclaw/ai/internal/shared"; export { transformTransportMessages } from "../agents/transport-message-transform.js"; export { describeToolResultMediaPlaceholder, extractToolResultText, -} from "../llm/providers/tool-result-text.js"; +} from "@openclaw/ai/internal/shared"; export { coerceTransportToolCallArguments, createEmptyTransportUsage, diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index cfea3ea7fbf..c8ff1bb7de9 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -12,38 +12,111 @@ const pluginSdkSubpathsCache = new Map(); const pluginSdkPackageNames = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"]; const pluginSdkSourceExtensions = [".ts", ".mts", ".js", ".mjs", ".cts", ".cjs"]; const privateQaExcludedPluginSdkSubpaths = new Set(["ssrf-runtime-internal"]); -const workspacePackageAliases = [ - { - name: "@openclaw/llm-core", - subpath: "", - srcFile: "src/index.ts", - distFile: "dist/index.mjs", +// Subpath -> source entry for private/bundled workspace packages that internal +// source imports by package name. Dist paths mirror each package's export map. +const workspacePackageAliasEntries = { + "@openclaw/llm-core": { + dir: "llm-core", + subpaths: { + "": { srcFile: "src/index.ts", distFile: "dist/index.mjs" }, + diagnostics: { srcFile: "src/utils/diagnostics.ts", distFile: "dist/utils/diagnostics.mjs" }, + "event-stream": { + srcFile: "src/utils/event-stream.ts", + distFile: "dist/utils/event-stream.mjs", + }, + types: { srcFile: "src/types.ts", distFile: "dist/types.mjs" }, + validation: { srcFile: "src/validation.ts", distFile: "dist/validation.mjs" }, + }, }, - { - name: "@openclaw/llm-core", - subpath: "diagnostics", - srcFile: "src/utils/diagnostics.ts", - distFile: "dist/utils/diagnostics.mjs", + "@openclaw/ai": { + dir: "ai", + subpaths: { + "": { srcFile: "src/index.ts", distFile: "dist/index.mjs" }, + providers: { srcFile: "src/providers.ts", distFile: "dist/providers.mjs" }, + diagnostics: { srcFile: "src/utils/diagnostics.ts", distFile: "dist/diagnostics.mjs" }, + "event-stream": { srcFile: "src/utils/event-stream.ts", distFile: "dist/event-stream.mjs" }, + types: { srcFile: "src/types.ts", distFile: "dist/types.mjs" }, + validation: { srcFile: "src/validation.ts", distFile: "dist/validation.mjs" }, + "internal/anthropic": { + srcFile: "src/internal/anthropic.ts", + distFile: "dist/internal/anthropic.mjs", + }, + "internal/openai": { + srcFile: "src/internal/openai.ts", + distFile: "dist/internal/openai.mjs", + }, + "internal/runtime": { + srcFile: "src/internal/runtime.ts", + distFile: "dist/internal/runtime.mjs", + }, + "internal/shared": { + srcFile: "src/internal/shared.ts", + distFile: "dist/internal/shared.mjs", + }, + }, }, - { - name: "@openclaw/llm-core", - subpath: "event-stream", - srcFile: "src/utils/event-stream.ts", - distFile: "dist/utils/event-stream.mjs", + "@openclaw/markdown-core": { + dir: "markdown-core", + subpaths: { + "": { srcFile: "src/index.ts", distFile: "dist/index.mjs" }, + "code-spans": { srcFile: "src/code-spans.ts", distFile: "dist/code-spans.mjs" }, + fences: { srcFile: "src/fences.ts", distFile: "dist/fences.mjs" }, + frontmatter: { srcFile: "src/frontmatter.ts", distFile: "dist/frontmatter.mjs" }, + ir: { srcFile: "src/ir.ts", distFile: "dist/ir.mjs" }, + render: { srcFile: "src/render.ts", distFile: "dist/render.mjs" }, + "render-aware-chunking": { + srcFile: "src/render-aware-chunking.ts", + distFile: "dist/render-aware-chunking.mjs", + }, + tables: { srcFile: "src/tables.ts", distFile: "dist/tables.mjs" }, + types: { srcFile: "src/types.ts", distFile: "dist/types.mjs" }, + }, }, - { - name: "@openclaw/llm-core", - subpath: "types", - srcFile: "src/types.ts", - distFile: "dist/types.mjs", + "@openclaw/normalization-core": { + dir: "normalization-core", + subpaths: { + "": { srcFile: "src/index.ts", distFile: "dist/index.mjs" }, + "boolean-coercion": { + srcFile: "src/boolean-coercion.ts", + distFile: "dist/boolean-coercion.mjs", + }, + "error-coercion": { + srcFile: "src/error-coercion.ts", + distFile: "dist/error-coercion.mjs", + }, + "number-coercion": { + srcFile: "src/number-coercion.ts", + distFile: "dist/number-coercion.mjs", + }, + "record-coerce": { + srcFile: "src/record-coerce.ts", + distFile: "dist/record-coerce.mjs", + }, + "string-coerce": { + srcFile: "src/string-coerce.ts", + distFile: "dist/string-coerce.mjs", + }, + "string-normalization": { + srcFile: "src/string-normalization.ts", + distFile: "dist/string-normalization.mjs", + }, + "utf16-slice": { + srcFile: "src/utf16-slice.ts", + distFile: "dist/utf16-slice.mjs", + }, + }, }, - { - name: "@openclaw/llm-core", - subpath: "validation", - srcFile: "src/validation.ts", - distFile: "dist/validation.mjs", - }, -]; +}; +const workspacePackageAliases = Object.entries(workspacePackageAliasEntries).flatMap( + ([name, pkg]) => + Object.entries(pkg.subpaths).map(([subpath, files]) => ({ + name, + dir: pkg.dir, + subpath, + srcFile: files.srcFile, + distFile: files.distFile, + })), +); const DIAGNOSTIC_EVENTS_STATE_KEY = Symbol.for("openclaw.diagnosticEvents.state.v1"); const isDistRootAlias = __filename.includes( `${path.sep}dist${path.sep}plugin-sdk${path.sep}root-alias.cjs`, @@ -350,22 +423,21 @@ function buildPluginSdkAliasMap(useDist) { } } - // Agent-core intentionally imports @openclaw/llm-core by package name so built - // package entrypoints share constructor identity. In source-checkout live - // tests, keep that package specifier on the same source graph instead of - // falling through to pnpm's package export and requiring a prebuilt dist. + // Internal source still imports private workspace packages by package name. + // Keep live source-checkout tests on the same source graph instead of + // requiring prebuilt package dist files. for (const entry of workspacePackageAliases) { const alias = entry.subpath ? `${entry.name}/${entry.subpath}` : entry.name; const preferred = path.join( packageRoot, "packages", - "llm-core", + entry.dir, useDist ? entry.distFile : entry.srcFile, ); const fallback = path.join( packageRoot, "packages", - "llm-core", + entry.dir, useDist ? entry.srcFile : entry.distFile, ); const target = fs.existsSync(preferred) ? preferred : fs.existsSync(fallback) ? fallback : null; diff --git a/src/plugins/contracts/extension-package-project-boundaries.test.ts b/src/plugins/contracts/extension-package-project-boundaries.test.ts index fc4f71efd97..bcbbe753f16 100644 --- a/src/plugins/contracts/extension-package-project-boundaries.test.ts +++ b/src/plugins/contracts/extension-package-project-boundaries.test.ts @@ -195,6 +195,7 @@ describe("opt-in extension package boundaries", () => { expect(tsconfig.compilerOptions?.outDir).toBe("dist"); expect(tsconfig.compilerOptions?.rootDir).toBe("../.."); expect(tsconfig.include).toEqual([ + "../../packages/ai/src/**/*.ts", "../../packages/markdown-core/src/**/*.ts", "../../packages/media-core/src/**/*.ts", "../../packages/media-generation-core/src/**/*.ts", diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 8cf0f4558bc..17e1f58e1f5 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -504,6 +504,35 @@ describe("plugin-sdk root alias", () => { expect(aliasMap["@openclaw/llm-core"]).toBe(sourceLlmCorePath); }); + it("keeps AI runtime transitive package imports on the source graph", () => { + const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath))); + const sourcePaths = { + aiRuntime: path.join(packageRoot, "packages", "ai", "src", "internal", "runtime.ts"), + codeSpans: path.join(packageRoot, "packages", "markdown-core", "src", "code-spans.ts"), + fences: path.join(packageRoot, "packages", "markdown-core", "src", "fences.ts"), + numberCoercion: path.join( + packageRoot, + "packages", + "normalization-core", + "src", + "number-coercion.ts", + ), + }; + const lazyModule = loadRootAliasWithStubs({ + existingPaths: Object.values(sourcePaths), + monolithicExports: { slowHelper: (): string => "loaded" }, + }); + + expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded"); + const aliasMap = (lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record; + expect(aliasMap["@openclaw/ai/internal/runtime"]).toBe(sourcePaths.aiRuntime); + expect(aliasMap["@openclaw/markdown-core/code-spans"]).toBe(sourcePaths.codeSpans); + expect(aliasMap["@openclaw/markdown-core/fences"]).toBe(sourcePaths.fences); + expect(aliasMap["@openclaw/normalization-core/number-coercion"]).toBe( + sourcePaths.numberCoercion, + ); + }); + it("keeps bootstrap plugin-sdk aliases deterministic and ignores unsafe subpaths", () => { const lazyModule = loadRootAliasWithStubs({ distExists: true, @@ -533,6 +562,33 @@ describe("plugin-sdk root alias", () => { "@openclaw/llm-core/event-stream", "@openclaw/llm-core/types", "@openclaw/llm-core/validation", + "@openclaw/ai", + "@openclaw/ai/providers", + "@openclaw/ai/diagnostics", + "@openclaw/ai/event-stream", + "@openclaw/ai/types", + "@openclaw/ai/validation", + "@openclaw/ai/internal/anthropic", + "@openclaw/ai/internal/openai", + "@openclaw/ai/internal/runtime", + "@openclaw/ai/internal/shared", + "@openclaw/markdown-core", + "@openclaw/markdown-core/code-spans", + "@openclaw/markdown-core/fences", + "@openclaw/markdown-core/frontmatter", + "@openclaw/markdown-core/ir", + "@openclaw/markdown-core/render", + "@openclaw/markdown-core/render-aware-chunking", + "@openclaw/markdown-core/tables", + "@openclaw/markdown-core/types", + "@openclaw/normalization-core", + "@openclaw/normalization-core/boolean-coercion", + "@openclaw/normalization-core/error-coercion", + "@openclaw/normalization-core/number-coercion", + "@openclaw/normalization-core/record-coerce", + "@openclaw/normalization-core/string-coerce", + "@openclaw/normalization-core/string-normalization", + "@openclaw/normalization-core/utf16-slice", "openclaw/plugin-sdk", "@openclaw/plugin-sdk", ]); diff --git a/src/plugins/plugin-sdk-native-resolver.test.ts b/src/plugins/plugin-sdk-native-resolver.test.ts index b7b51c90ef4..415884c4af3 100644 --- a/src/plugins/plugin-sdk-native-resolver.test.ts +++ b/src/plugins/plugin-sdk-native-resolver.test.ts @@ -390,6 +390,16 @@ describe("installOpenClawPluginSdkNativeResolver", () => { "boolean-coercion.ts", ); const mediaCoreSource = writeInternalCorePackageSource(root, "media-core", "mime.ts"); + const markdownCoreSource = writeInternalCorePackageSource( + root, + "markdown-core", + "code-spans.ts", + ); + const aiRuntimeSource = writeInternalCorePackageSource( + root, + "ai", + path.join("internal", "runtime.ts"), + ); const acpCoreSource = writeInternalCorePackageSource( root, "acp-core", @@ -410,6 +420,8 @@ describe("installOpenClawPluginSdkNativeResolver", () => { expect(installedAliases).toContain("@openclaw/normalization-core/string-coerce"); expect(installedAliases).toContain("@openclaw/normalization-core/boolean-coercion"); expect(installedAliases).toContain("@openclaw/media-core/mime"); + expect(installedAliases).toContain("@openclaw/markdown-core/code-spans"); + expect(installedAliases).toContain("@openclaw/ai/internal/runtime"); expect(installedAliases).toContain("@openclaw/acp-core/runtime/types"); expect(installedAliases).toContain("@openclaw/llm-core"); const requireFromCoreSource = createRequire(coreSourceParent); @@ -425,6 +437,12 @@ describe("installOpenClawPluginSdkNativeResolver", () => { expect(fs.realpathSync(requireFromCoreSource.resolve("@openclaw/media-core/mime"))).toBe( fs.realpathSync(mediaCoreSource), ); + expect( + fs.realpathSync(requireFromCoreSource.resolve("@openclaw/markdown-core/code-spans")), + ).toBe(fs.realpathSync(markdownCoreSource)); + expect(fs.realpathSync(requireFromCoreSource.resolve("@openclaw/ai/internal/runtime"))).toBe( + fs.realpathSync(aiRuntimeSource), + ); expect(fs.realpathSync(requireFromCoreSource.resolve("@openclaw/acp-core/runtime/types"))).toBe( fs.realpathSync(acpCoreSource), ); @@ -436,6 +454,8 @@ describe("installOpenClawPluginSdkNativeResolver", () => { requireFromPlugin.resolve("@openclaw/normalization-core/boolean-coercion"), ).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/media-core/mime")).toThrow(); + expect(() => requireFromPlugin.resolve("@openclaw/markdown-core/code-spans")).toThrow(); + expect(() => requireFromPlugin.resolve("@openclaw/ai/internal/runtime")).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/acp-core/runtime/types")).toThrow(); expect(() => requireFromPlugin.resolve("@openclaw/llm-core")).toThrow(); }); diff --git a/src/plugins/plugin-sdk-native-resolver.ts b/src/plugins/plugin-sdk-native-resolver.ts index ba8b35d0003..644b19871cb 100644 --- a/src/plugins/plugin-sdk-native-resolver.ts +++ b/src/plugins/plugin-sdk-native-resolver.ts @@ -52,16 +52,52 @@ const moduleWithResolver = Module as ModuleWithResolver; const nodeResolveFilenameProperty = "_resolveFilename" as const; const PLUGIN_SDK_PACKAGE_PREFIXES = ["openclaw/plugin-sdk", "@openclaw/plugin-sdk"] as const; const INTERNAL_CORE_PACKAGE_ALIASES = [ + { + packageName: "@openclaw/markdown-core", + packageDir: "markdown-core", + subpaths: [ + ["", "index.ts"], + ["code-spans", "code-spans.ts"], + ["fences", "fences.ts"], + ["frontmatter", "frontmatter.ts"], + ["ir", "ir.ts"], + ["render", "render.ts"], + ["render-aware-chunking", "render-aware-chunking.ts"], + ["tables", "tables.ts"], + ["types", "types.ts"], + ], + }, { packageName: "@openclaw/normalization-core", packageDir: "normalization-core", subpaths: [ ["", "index.ts"], ["boolean-coercion", "boolean-coercion.ts"], + ["error-coercion", "error-coercion.ts"], ["number-coercion", "number-coercion.ts"], ["record-coerce", "record-coerce.ts"], ["string-coerce", "string-coerce.ts"], ["string-normalization", "string-normalization.ts"], + ["utf16-slice", "utf16-slice.ts"], + ], + }, + { + // Mirrors packages/ai/package.json exports; dist file names do not follow + // the src layout (dist/diagnostics.mjs <- src/utils/diagnostics.ts), so the + // generic export-map derivation cannot be used here. + packageName: "@openclaw/ai", + packageDir: "ai", + subpaths: [ + ["", "index.ts"], + ["providers", "providers.ts"], + ["diagnostics", path.join("utils", "diagnostics.ts")], + ["event-stream", path.join("utils", "event-stream.ts")], + ["types", "types.ts"], + ["validation", "validation.ts"], + ["internal/anthropic", path.join("internal", "anthropic.ts")], + ["internal/openai", path.join("internal", "openai.ts")], + ["internal/runtime", path.join("internal", "runtime.ts")], + ["internal/shared", path.join("internal", "shared.ts")], ], }, { diff --git a/src/plugins/provider-model-compat.ts b/src/plugins/provider-model-compat.ts index ba1270dbb67..17a42059108 100644 --- a/src/plugins/provider-model-compat.ts +++ b/src/plugins/provider-model-compat.ts @@ -1,5 +1,8 @@ // Normalizes provider model compatibility metadata from plugins. -import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization"; +import { + resolveUnsupportedToolSchemaKeywords, + shouldOmitEmptyArrayItems, +} from "@openclaw/ai/internal/openai"; import { detectOpenAICompletionsCompat } from "../agents/openai-completions-compat.js"; import type { ModelCompatConfig } from "../config/types.models.js"; import type { Model } from "../llm/types.js"; @@ -55,25 +58,9 @@ export function resolveToolCallArgumentsEncoding( return extractModelCompat(modelOrCompat)?.toolCallArgumentsEncoding; } -export function resolveUnsupportedToolSchemaKeywords( - modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, -): ReadonlySet { - const keywords = extractModelCompat(modelOrCompat)?.unsupportedToolSchemaKeywords ?? []; - return new Set( - normalizeStringEntries( - keywords.filter((keyword): keyword is string => typeof keyword === "string"), - ), - ); -} - -export function shouldOmitEmptyArrayItems( - modelOrCompat: { compat?: unknown } | ModelCompatConfig | undefined, -): boolean { - const compat = extractModelCompat(modelOrCompat) as - | (ModelCompatConfig & { omitEmptyArrayItems?: unknown }) - | undefined; - return compat?.omitEmptyArrayItems === true; -} +// Tool-schema compat predicates moved into @openclaw/ai (agent-tools-parameter-schema); +// re-export so existing core/plugin callers keep one canonical import site. +export { resolveUnsupportedToolSchemaKeywords, shouldOmitEmptyArrayItems }; function isOpenAiCompletionsModel(model: Model): model is Model<"openai-completions"> { return model.api === "openai-completions"; diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 21f8e90fc17..ddced1d9dfa 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -798,6 +798,13 @@ const WORKSPACE_PACKAGE_ALIAS_ENTRIES: WorkspacePackageAliasEntry[] = [ srcFile: "string-normalization.ts", distFile: "string-normalization.mjs", }, + { + packageName: "@openclaw/normalization-core", + packageDir: "normalization-core", + subpath: "utf16-slice", + srcFile: "utf16-slice.ts", + distFile: "utf16-slice.mjs", + }, { packageName: "@openclaw/terminal-core", packageDir: "terminal-core", diff --git a/src/scripts/sync-plugin-versions.test.ts b/src/scripts/sync-plugin-versions.test.ts index 2afe5d27d95..3b3f59ffad8 100644 --- a/src/scripts/sync-plugin-versions.test.ts +++ b/src/scripts/sync-plugin-versions.test.ts @@ -24,6 +24,15 @@ describe("syncPluginVersions", () => { name: "openclaw", version: "2026.4.1", }); + writeJson(path.join(rootDir, "packages/ai/package.json"), { + name: "@openclaw/ai", + version: "2026.3.30", + }); + writeJson(path.join(rootDir, "packages/llm-core/package.json"), { + name: "@openclaw/llm-core", + version: "0.0.0-private", + private: true, + }); writeJson(path.join(rootDir, "extensions/imessage/package.json"), { name: "@openclaw/imessage", version: "2026.3.30", @@ -67,6 +76,14 @@ describe("syncPluginVersions", () => { }; expect(summary.updated).toContain("@openclaw/imessage"); + expect(summary.updated).toContain("@openclaw/ai"); + expect(summary.updated).not.toContain("@openclaw/llm-core"); + expect( + JSON.parse(fs.readFileSync(path.join(rootDir, "packages/ai/package.json"), "utf8")), + ).toMatchObject({ version: "2026.4.1" }); + expect( + JSON.parse(fs.readFileSync(path.join(rootDir, "packages/llm-core/package.json"), "utf8")), + ).toMatchObject({ private: true, version: "0.0.0-private" }); expect(updatedPackage.version).toBe("2026.4.1"); expect(updatedPackage.devDependencies?.openclaw).toBe("workspace:*"); expect(updatedPackage.peerDependencies?.openclaw).toBe(">=2026.4.1"); diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts index c8c7ea9e970..6e6659f1645 100644 --- a/src/shared/assistant-error-format.ts +++ b/src/shared/assistant-error-format.ts @@ -1,5 +1,5 @@ // Assistant error formatting helpers normalize assistant-visible error payloads. -import { truncateUtf16Safe } from "./utf16-slice.js"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; const ERROR_PAYLOAD_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error)(?:\s+\d{3})?[:\s-]+/i; const HTTP_STATUS_DELIMITER_RE = /(?:\s*:\s*|\s+)/; diff --git a/src/shared/subagents-format.ts b/src/shared/subagents-format.ts index e73d83d0338..508915ba6ae 100644 --- a/src/shared/subagents-format.ts +++ b/src/shared/subagents-format.ts @@ -1,5 +1,5 @@ // Subagent formatting helpers expose compact durations and status text. -import { truncateUtf16Safe } from "./utf16-slice.js"; +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; export { formatDurationCompact } from "../infra/format-time/format-duration.ts"; /** Formats token counts using compact k/m suffixes for subagent summaries. */ diff --git a/src/utils.ts b/src/utils.ts index 7ec5c58f269..e10790fd21f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -65,7 +65,7 @@ export function normalizeE164(number: string): string { // Surrogate-safe slicing helpers live in a node-free leaf module so browser/UI // bundles can import them without pulling in filesystem code. Re-exported here // to preserve the historical `utils.ts` import surface. -export { sliceUtf16Safe, truncateUtf16Safe } from "./shared/utf16-slice.js"; +export { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; /** Resolves `~` and OpenClaw home-relative paths with injectable env/home sources. */ export function resolveUserPath( diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index ae9576546ae..b2e49691869 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -10,9 +10,12 @@ import { buildPackageArtifacts, packOpenClawPackageForDocker, parseArgs, + prepareBundledAiRuntimePackage, runCommandForTest, } from "../../../../scripts/package-openclaw-for-docker.mjs"; +const skipBundledAiRuntime = async (): Promise<() => Promise> => async () => {}; + function isProcessAlive(pid: number): boolean { if (!Number.isSafeInteger(pid) || pid <= 0) { return false; @@ -215,9 +218,83 @@ describe("package-openclaw-for-docker", () => { } }); + it("bundles and restores the separately packed AI runtime", async () => { + const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-ai-source-")); + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-ai-output-")); + const packageJsonPath = path.join(sourceDir, "package.json"); + const originalPackageJson = `${JSON.stringify( + { + dependencies: { "@openclaw/ai": "workspace:*", "dep-a": "1.2.3" }, + files: ["dist"], + name: "openclaw", + version: "2026.6.17", + }, + null, + 2, + )}\n`; + const installedAiPath = path.join(sourceDir, "node_modules", "@openclaw", "ai"); + fs.mkdirSync(path.join(sourceDir, "packages", "ai"), { recursive: true }); + fs.mkdirSync(installedAiPath, { recursive: true }); + fs.writeFileSync(path.join(installedAiPath, "original-marker"), "workspace package"); + fs.writeFileSync(packageJsonPath, originalPackageJson); + + try { + const cleanup = await prepareBundledAiRuntimePackage( + sourceDir, + outputDir, + async (command: string, args: string[], cwd: string) => { + expect({ args, command, cwd }).toEqual({ + args: ["--dir", "packages/ai", "pack", "--silent", "--pack-destination", outputDir], + command: "pnpm", + cwd: sourceDir, + }); + fs.writeFileSync(path.join(outputDir, "openclaw-ai-2026.6.17.tgz"), "ai package"); + return ""; + }, + { + extractAiRuntime: async (_tarballPath: string, destination: string) => { + fs.writeFileSync( + path.join(destination, "package.json"), + `${JSON.stringify({ + dependencies: { "dep-a": "1.2.3" }, + name: "@openclaw/ai", + version: "2026.6.17", + })}\n`, + ); + fs.writeFileSync(path.join(destination, "runtime.js"), "export {};\n"); + }, + }, + ); + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + bundleDependencies: string[]; + dependencies: Record; + }; + expect(packageJson.dependencies["@openclaw/ai"]).toBe("2026.6.17"); + expect(packageJson.bundleDependencies).toContain("@openclaw/ai"); + expect(fs.existsSync(path.join(installedAiPath, "original-marker"))).toBe(false); + expect(fs.existsSync(path.join(installedAiPath, "runtime.js"))).toBe(true); + const stagedAiPackageJson = JSON.parse( + fs.readFileSync(path.join(installedAiPath, "package.json"), "utf8"), + ) as { dependencies?: Record }; + expect(stagedAiPackageJson.dependencies).toBeUndefined(); + + await cleanup(); + expect(fs.readFileSync(packageJsonPath, "utf8")).toBe(originalPackageJson); + expect(fs.readFileSync(path.join(installedAiPath, "original-marker"), "utf8")).toBe( + "workspace package", + ); + expect(fs.existsSync(path.join(outputDir, "openclaw-ai-2026.6.17.tgz"))).toBe(false); + } finally { + fs.rmSync(sourceDir, { recursive: true, force: true }); + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); + it("trims and restores the changelog around ignore-scripts package artifacts", async () => { const calls: string[] = []; const tarball = await packOpenClawPackageForDocker("/repo", "/out", { + prepareBundledAiRuntime: skipBundledAiRuntime, prepareChangelog: async (cwd: string) => { calls.push(`prepare:${cwd}`); }, @@ -255,6 +332,7 @@ describe("package-openclaw-for-docker", () => { ]) { await expect( packOpenClawPackageForDocker("/repo", "/out", { + prepareBundledAiRuntime: skipBundledAiRuntime, prepareChangelog: async () => {}, restoreChangelog: async () => {}, runCaptureImpl: async () => `${filename}\n`, @@ -270,6 +348,7 @@ describe("package-openclaw-for-docker", () => { fs.writeFileSync(path.join(outputDir, String.raw`openclaw-nested\evil.tgz`), ""); await expect( packOpenClawPackageForDocker("/repo", outputDir, { + prepareBundledAiRuntime: skipBundledAiRuntime, prepareChangelog: async () => {}, restoreChangelog: async () => {}, runCaptureImpl: async () => "npm notice\n", @@ -278,6 +357,7 @@ describe("package-openclaw-for-docker", () => { await expect( packOpenClawPackageForDocker("/repo", outputDir, { + prepareBundledAiRuntime: skipBundledAiRuntime, prepareChangelog: async () => {}, restoreChangelog: async () => {}, runCaptureImpl: async () => { @@ -298,6 +378,7 @@ describe("package-openclaw-for-docker", () => { await expect( packOpenClawPackageForDocker("/repo", outputDir, { + prepareBundledAiRuntime: skipBundledAiRuntime, prepareChangelog: async () => {}, restoreChangelog: async () => {}, runCaptureImpl: async () => { @@ -321,6 +402,12 @@ describe("package-openclaw-for-docker", () => { await expect( packOpenClawPackageForDocker("/repo", "/out", { + prepareBundledAiRuntime: async () => { + calls.push("embed"); + return async () => { + calls.push("cleanup"); + }; + }, prepareChangelog: async (cwd: string) => { calls.push(`prepare:${cwd}`); }, @@ -334,7 +421,7 @@ describe("package-openclaw-for-docker", () => { }), ).rejects.toThrow("pack failed"); - expect(calls).toEqual(["prepare:/repo", "pack", "restore:/repo"]); + expect(calls).toEqual(["prepare:/repo", "embed", "pack", "cleanup", "restore:/repo"]); }); it("clamps oversized command timers before scheduling", async () => { diff --git a/test/openclaw-npm-prepublish-verify.test.ts b/test/openclaw-npm-prepublish-verify.test.ts index 08778fdc1f1..3468035e3a6 100644 --- a/test/openclaw-npm-prepublish-verify.test.ts +++ b/test/openclaw-npm-prepublish-verify.test.ts @@ -7,14 +7,17 @@ import { describe("parseOpenClawNpmPrepublishVerifyArgs", () => { it("supports help, optional versions, and package-manager separators", () => { expect(parseOpenClawNpmPrepublishVerifyArgs(["--help"])).toEqual({ + dependencyTarballPaths: [], help: true, tarballPath: "", }); expect(parseOpenClawNpmPrepublishVerifyArgs(["openclaw.tgz"])).toEqual({ + dependencyTarballPaths: [], help: false, tarballPath: "openclaw.tgz", }); expect(parseOpenClawNpmPrepublishVerifyArgs(["--", "openclaw.tgz", "2026.3.23"])).toEqual({ + dependencyTarballPaths: [], expectedVersion: "2026.3.23", help: false, tarballPath: "openclaw.tgz", @@ -31,8 +34,16 @@ describe("parseOpenClawNpmPrepublishVerifyArgs", () => { expect(() => parseOpenClawNpmPrepublishVerifyArgs(["openclaw.tgz", "--tag"])).toThrow( "Unknown openclaw npm prepublish verifier option: --tag", ); + expect( + parseOpenClawNpmPrepublishVerifyArgs(["openclaw.tgz", "2026.3.23", "llm-core.tgz", "ai.tgz"]), + ).toEqual({ + dependencyTarballPaths: ["llm-core.tgz", "ai.tgz"], + expectedVersion: "2026.3.23", + help: false, + tarballPath: "openclaw.tgz", + }); expect(() => - parseOpenClawNpmPrepublishVerifyArgs(["openclaw.tgz", "2026.3.23", "extra"]), - ).toThrow("Unexpected openclaw npm prepublish verifier argument: extra"); + parseOpenClawNpmPrepublishVerifyArgs(["openclaw.tgz", "2026.3.23", "--bad"]), + ).toThrow("Invalid dependency tarball path: --bad"); }); }); diff --git a/test/scripts/generate-npm-shrinkwrap.test.ts b/test/scripts/generate-npm-shrinkwrap.test.ts index 237aca43e1c..a1a1735f4b9 100644 --- a/test/scripts/generate-npm-shrinkwrap.test.ts +++ b/test/scripts/generate-npm-shrinkwrap.test.ts @@ -12,6 +12,7 @@ import { exactOverrideRulesFromOverrides, exactVersionFromOverrideSpec, normalizeNpmVersionDrift, + packageJsonForShrinkwrap, packageDependencyInputsChanged, pnpmLockOverrideVersionForVersions, parsePnpmPackageKey, @@ -27,6 +28,21 @@ describe("generate-npm-shrinkwrap", () => { return path.relative(process.cwd(), value).replaceAll("\\", "/"); } + it("omits workspace packages that are published beside the package", () => { + const normalized = packageJsonForShrinkwrap( + { + dependencies: { "@openclaw/ai": "workspace:2026.6.11", chalk: "5.6.2" }, + devDependencies: { local: "workspace:*" }, + peerDependencies: { host: "workspace:^1.2.3" }, + }, + {}, + ); + + expect(normalized).not.toHaveProperty("devDependencies"); + expect(normalized.dependencies).toEqual({ chalk: "5.6.2" }); + expect(normalized.peerDependencies).toEqual({}); + }); + it("runs npm shrinkwrap through cmd.exe for Windows npm shims", () => { const execPath = "C:\\nodejs\\node.exe"; const npmCmdPath = path.win32.resolve(path.win32.dirname(execPath), "npm.cmd"); diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index a2bc68425e3..3a3de3abf52 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1685,7 +1685,8 @@ describe("package artifact reuse", () => { expectTextToIncludeAll(fullReleaseDocs, [ "cross_os_suite_filter", "QA release-check failures block normal release validation", - "input capture fails instead of silently skipping the lane", + "input capture fails", + "skipping the lane", "does not duplicate that", "canonical Package Acceptance Telegram E2E", "| `npm-telegram` | Published-package Telegram E2E; requires `release_package_spec` or `npm_telegram_package_spec`. |", diff --git a/test/scripts/tsdown-build.test.ts b/test/scripts/tsdown-build.test.ts index 8ab9900f74b..dccb5338593 100644 --- a/test/scripts/tsdown-build.test.ts +++ b/test/scripts/tsdown-build.test.ts @@ -15,6 +15,7 @@ import { pruneStaleRootChunkFiles, pruneUntrackedGeneratedSourceDeclarations, resolveTsdownBuildInvocation, + resolveTsdownBuildInvocations, runTsdownBuildInvocation, signalTsdownBuildProcessTree, } from "../../scripts/tsdown-build.mjs"; @@ -133,6 +134,23 @@ describe("resolveTsdownBuildInvocation", () => { expect(result.args.slice(-2)).toEqual(["--format", "esm"]); }); + it("builds AI package declarations before the main graph", () => { + const results = resolveTsdownBuildInvocations({ + args: ["--format", "esm"], + platform: "linux", + nodeExecPath: "/usr/bin/node", + npmExecPath: "/tmp/pnpm.cjs", + env: {}, + ...NO_MEMORY_LIMIT, + }); + + expect(results).toHaveLength(2); + expect(results[0]?.args).toEqual( + expect.arrayContaining(["--config", "tsdown.ai.config.ts", "--format", "esm"]), + ); + expect(results[1]?.args).not.toContain("tsdown.ai.config.ts"); + }); + it("routes Windows tsdown builds through the pnpm runner instead of shell=true", () => { const rootDir = createTempDir("openclaw-pnpm-runner-"); const npmExecPath = path.join(rootDir, "pnpm.cjs"); diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index e79fca60986..79bd0c86272 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -241,6 +241,38 @@ export const sharedVitestConfig = { find: "@openclaw/gateway-protocol", replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "index.ts"), }, + { + find: /^@openclaw\/ai\/internal\/(.+)$/, + replacement: path.join(repoRoot, "packages", "ai", "src", "internal", "$1.ts"), + }, + { + find: "@openclaw/ai/diagnostics", + replacement: path.join(repoRoot, "packages", "ai", "src", "utils", "diagnostics.ts"), + }, + { + find: "@openclaw/ai/event-stream", + replacement: path.join(repoRoot, "packages", "ai", "src", "utils", "event-stream.ts"), + }, + { + find: "@openclaw/ai/providers", + replacement: path.join(repoRoot, "packages", "ai", "src", "providers.ts"), + }, + { + find: "@openclaw/ai/types", + replacement: path.join(repoRoot, "packages", "ai", "src", "types.ts"), + }, + { + find: "@openclaw/ai/validation", + replacement: path.join(repoRoot, "packages", "ai", "src", "validation.ts"), + }, + { + find: /^@openclaw\/ai\/(.+)$/, + replacement: path.join(repoRoot, "packages", "ai", "src", "$1.ts"), + }, + { + find: "@openclaw/ai", + replacement: path.join(repoRoot, "packages", "ai", "src", "index.ts"), + }, { find: "@openclaw/llm-core/diagnostics", replacement: path.join(repoRoot, "packages", "llm-core", "src", "utils", "diagnostics.ts"), @@ -415,10 +447,16 @@ export const sharedVitestConfig = { "string-normalization.ts", ), }, + { + find: "@openclaw/normalization-core/utf16-slice", + replacement: path.join(repoRoot, "packages", "normalization-core", "src", "utf16-slice.ts"), + }, { find: /^@openclaw\/normalization-core$/u, replacement: path.join(repoRoot, "packages", "normalization-core", "src", "index.ts"), }, + sourcePackageAlias("markdown-core", "code-spans"), + sourcePackageAlias("markdown-core", "fences"), sourcePackageAlias("media-core", "base64"), sourcePackageAlias("media-core", "constants"), sourcePackageAlias("media-core", "content-length"), diff --git a/tsconfig.json b/tsconfig.json index 3d247db031f..69aa173de5b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,13 @@ "openclaw/extension-api": ["./src/extensionAPI.ts"], "openclaw/plugin-sdk": ["./src/plugin-sdk/index.ts"], "openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"], + "@openclaw/ai": ["./packages/ai/src/index.ts"], + "@openclaw/ai/diagnostics": ["./packages/ai/src/utils/diagnostics.ts"], + "@openclaw/ai/event-stream": ["./packages/ai/src/utils/event-stream.ts"], + "@openclaw/ai/providers": ["./packages/ai/src/providers.ts"], + "@openclaw/ai/types": ["./packages/ai/src/types.ts"], + "@openclaw/ai/validation": ["./packages/ai/src/validation.ts"], + "@openclaw/ai/internal/*": ["./packages/ai/src/internal/*.ts"], "@openclaw/agent-core": ["./packages/agent-core/src/index.ts"], "@openclaw/agent-core/*": ["./packages/agent-core/src/*"], "@openclaw/llm-core": ["./packages/llm-core/src/index.ts"], @@ -34,8 +41,6 @@ "@openclaw/llm-core/event-stream": ["./packages/llm-core/src/utils/event-stream.ts"], "@openclaw/llm-core/validation": ["./packages/llm-core/src/validation.ts"], "@openclaw/llm-core/*": ["./packages/llm-core/src/*"], - "@openclaw/llm-runtime": ["./packages/llm-runtime/src/index.ts"], - "@openclaw/llm-runtime/*": ["./packages/llm-runtime/src/*"], "@openclaw/model-catalog-core": ["./packages/model-catalog-core/src/index.ts"], "@openclaw/model-catalog-core/configured-model-refs": [ "./packages/model-catalog-core/src/configured-model-refs.ts" @@ -144,6 +149,9 @@ "@openclaw/normalization-core/string-normalization": [ "./packages/normalization-core/src/string-normalization.ts" ], + "@openclaw/normalization-core/utf16-slice": [ + "./packages/normalization-core/src/utf16-slice.ts" + ], "@openclaw/normalization-core/*": ["./packages/normalization-core/src/*"], "@openclaw/acp-core": ["./packages/acp-core/src/index.ts"], "@openclaw/acp-core/meta": ["./packages/acp-core/src/meta.ts"], diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index 1c004b6887e..b4373a7b4c8 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -13,6 +13,7 @@ }, "include": [ "src/plugin-sdk/**/*.ts", + "packages/ai/src/**/*.ts", "packages/llm-core/src/**/*.ts", "packages/markdown-core/src/**/*.ts", "packages/media-core/src/**/*.ts", diff --git a/tsdown.ai.config.ts b/tsdown.ai.config.ts new file mode 100644 index 00000000000..4868127539b --- /dev/null +++ b/tsdown.ai.config.ts @@ -0,0 +1,39 @@ +// Builds the reusable AI package separately to keep provider bundling out of +// the already-parallel main package graph. +import { defineConfig } from "tsdown"; + +const externalDependencies = [ + "@anthropic-ai/sdk", + "@google/genai", + "@mistralai/mistralai", + "openai", + "typebox", +] as const; + +export default defineConfig({ + clean: true, + dts: process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD === "1" ? false : true, + entry: { + index: "packages/ai/src/index.ts", + providers: "packages/ai/src/providers.ts", + diagnostics: "packages/ai/src/utils/diagnostics.ts", + "event-stream": "packages/ai/src/utils/event-stream.ts", + types: "packages/ai/src/types.ts", + validation: "packages/ai/src/validation.ts", + "internal/anthropic": "packages/ai/src/internal/anthropic.ts", + "internal/openai": "packages/ai/src/internal/openai.ts", + "internal/runtime": "packages/ai/src/internal/runtime.ts", + "internal/shared": "packages/ai/src/internal/shared.ts", + }, + env: { NODE_ENV: "production" }, + format: "esm", + outDir: "packages/ai/dist", + platform: "node", + deps: { + neverBundle(id) { + return externalDependencies.some( + (dependency) => id === dependency || id.startsWith(`${dependency}/`), + ); + }, + }, +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index bbca3d85192..3c1fc5b457d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -198,6 +198,7 @@ const explicitNeverBundleDependencies = [ "@lancedb/lancedb", "@larksuiteoapi/node-sdk", "@matrix-org/matrix-sdk-crypto-nodejs", + "@openclaw/ai", "@vitest/expect", "jimp", "matrix-js-sdk", @@ -436,10 +437,12 @@ function buildNormalizationCoreDistEntries(): Record { return { index: "packages/normalization-core/src/index.ts", "boolean-coercion": "packages/normalization-core/src/boolean-coercion.ts", + "error-coercion": "packages/normalization-core/src/error-coercion.ts", "number-coercion": "packages/normalization-core/src/number-coercion.ts", "record-coerce": "packages/normalization-core/src/record-coerce.ts", "string-coerce": "packages/normalization-core/src/string-coerce.ts", "string-normalization": "packages/normalization-core/src/string-normalization.ts", + "utf16-slice": "packages/normalization-core/src/utf16-slice.ts", }; } @@ -552,16 +555,10 @@ function buildModelCatalogCoreDistEntries(): Record { }; } -function buildLlmRuntimeDistEntries(): Record { - return { - index: "packages/llm-runtime/src/index.ts", - "api-registry": "packages/llm-runtime/src/api-registry.ts", - stream: "packages/llm-runtime/src/stream.ts", - }; -} - function shouldExternalizeAgentCoreDependency(id: string): boolean { return ( + id === "@openclaw/ai" || + id.startsWith("@openclaw/ai/") || id === "@openclaw/llm-core" || id.startsWith("@openclaw/llm-core/") || id === "ignore" || @@ -599,10 +596,6 @@ function shouldExternalizeLlmCoreDependency(id: string): boolean { return id === "typebox" || id.startsWith("typebox/"); } -function shouldExternalizeLlmRuntimeDependency(id: string): boolean { - return id === "@openclaw/llm-core" || id.startsWith("@openclaw/llm-core/"); -} - function shouldExternalizeMarkdownCoreDependency(id: string): boolean { return ( id === "markdown-it" || id.startsWith("markdown-it/") || id === "yaml" || id.startsWith("yaml/") @@ -784,15 +777,6 @@ export default defineConfig([ entry: buildModelCatalogCoreDistEntries(), outDir: tsdownPackageOutputRoot("model-catalog-core"), }), - nodeWorkspacePackageBuildConfig({ - clean: true, - dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined, - entry: buildLlmRuntimeDistEntries(), - outDir: tsdownPackageOutputRoot("llm-runtime"), - deps: { - neverBundle: shouldExternalizeLlmRuntimeDependency, - }, - }), nodeBuildConfig({ // Build core entrypoints, plugin-sdk subpaths, bundled plugin entrypoints, // and bundled hooks in one graph so runtime singletons are emitted once. @@ -802,6 +786,8 @@ export default defineConfig([ deps: { alwaysBundle: shouldAlwaysBundleDependency, neverBundle: shouldNeverBundleDependency, + // Keep dts generation from inlining externalized package types. + dts: { neverBundle: shouldNeverBundleDependency }, }, }), ]); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f4e40a7a78b..cfb8f6c4c79 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -156,6 +156,7 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] { sourcePackageAlias("normalization-core", "record-coerce"), sourcePackageAlias("normalization-core", "string-coerce"), sourcePackageAlias("normalization-core", "string-normalization"), + sourcePackageAlias("normalization-core", "utf16-slice"), sourcePackageAlias("normalization-core"), ]; }