refactor: extract reusable AI runtime package (#99059)

* refactor: extract reusable AI runtime package

* refactor: complete AI provider relocation

* refactor: keep llm core internal

* refactor(ai): make @openclaw/ai self-contained with host policy ports

Move pure transport helpers (tool projections, strict-schema normalization,
prompt-cache boundary, stream guards, anthropic/openai compat, request
activity) from src into packages/ai; move utf16-slice into
normalization-core. Inject host policy (guarded fetch, redaction,
strict-tool defaults, diagnostics logging) through AiTransportHost with
inert library defaults installed by src/llm/stream.ts. Narrow the public
barrel to instance-scoped createApiRegistry/createLlmRuntime; the
process-default runtime moves behind internal/ and
registerBuiltInApiProviders takes an explicit registry. Delete the
src/llm/api-registry re-export facade.

* fix(ai): teach node, jiti, and vite resolvers the @openclaw/ai and utf16-slice subpaths

The workspace alias tables in root-alias.cjs, plugin-sdk-native-resolver,
sdk-alias, the shared vitest config, and the Control UI vite config only
knew @openclaw/llm-core; Node-side plugin loading resolved @openclaw/ai
through the pnpm symlink to the unbuilt dist (checks-node-compact CI
failures), and the Control UI build broke on the new
normalization-core/utf16-slice subpath.

* chore(ui): drop leftover service-worker debug logging

* build(release): ship @openclaw/ai with its own shrinkwrap and honest dependency set

packages/ai declares only its six real runtime deps (kysely, chalk, json5,
tslog, zod, fs-safe, and proxyline were never imported); orphaned root deps
removed. generate-npm-shrinkwrap now treats publishable packages/* like
publishable plugins so the AI tarball pins its transitive tree even though
workspace deps are omitted from the root shrinkwrap. knip learns the
package entry points; the tsdown dts neverBundle option moves to its
documented deps.dts home; the README documents the no-semver internal/*
contract and host ports.

* docs(ai): add minimal external-consumer example app

examples/ai-chat consumes only the public @openclaw/ai surface (built dist
via the workspace link): isolated runtime, built-in provider registration,
one streamed completion. Supports Anthropic/OpenAI via env keys and a
keyless local Ollama target; live-verified against Ollama.

* docs(ai): document the @openclaw/ai package and workspace shrinkwrap boundary

* chore(check): include examples/ in duplicate-scan targets

* fix: emit normalization package subpaths

* fix: complete AI package boundary artifacts

* fix: align AI package boundary contracts

* fix(ci): stabilize package release contracts

* test: align documentation contract checks

* test: keep cron docs guard aligned

* test: align restored docs contract guards

* test: follow upstream docs contracts

* docs: drop superseded talk wording
This commit is contained in:
Peter Steinberger 2026-07-05 01:56:40 -04:00 committed by GitHub
parent 913311845e
commit 062f88e3e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
230 changed files with 3122 additions and 1248 deletions

View file

@ -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

View file

@ -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

View file

@ -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!"],

View file

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

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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 |

View file

@ -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`.

View file

@ -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}`);

View file

@ -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:*"
}
}

View file

@ -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"
],

View file

@ -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"
],

View file

@ -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",

View file

@ -95,7 +95,7 @@
}
},
"dependencies": {
"@openclaw/llm-core": "workspace:*",
"@openclaw/ai": "workspace:*",
"typebox": "1.3.3"
}
}

View file

@ -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();

View file

@ -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,

View file

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

View file

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

21
packages/ai/LICENSE Normal file
View file

@ -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.

28
packages/ai/README.md Normal file
View file

@ -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.

645
packages/ai/npm-shrinkwrap.json generated Normal file
View file

@ -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"
}
}
}
}

89
packages/ai/package.json Normal file
View file

@ -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
}
}
}

View file

@ -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([]);
});
});

View file

@ -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<TApi, SimpleStreamOptions>;
}
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<string, RegisteredApiProvider>();
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
stream: StreamFunction<TApi, TOptions>,
@ -73,13 +72,16 @@ function wrapStreamSimple<TApi extends Api>(
};
}
/** Registers or replaces the provider implementation for an API id. */
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
/** Creates an isolated provider registry for one runtime or tenant. */
export function createApiRegistry() {
const providers = new Map<string, RegisteredApiProviderEntry>();
function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
/** Optional source id used to unregister all providers owned by one plugin/runtime. */
sourceId?: string,
): void {
apiProviderRegistry.set(provider.api, {
providers.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
@ -89,26 +91,29 @@ export function registerApiProvider<TApi extends Api, TOptions extends StreamOpt
});
}
/** Looks up a registered API provider by API id. */
export function getApiProvider(api: Api): ApiProviderInternal | undefined {
return apiProviderRegistry.get(api)?.provider;
function getApiProvider(api: Api): RegisteredApiProvider | undefined {
return providers.get(api)?.provider;
}
/** Lists all currently registered API providers. */
export function getApiProviders(): ApiProviderInternal[] {
return Array.from(apiProviderRegistry.values(), (entry) => entry.provider);
function getApiProviders(): RegisteredApiProvider[] {
return Array.from(providers.values(), (entry) => entry.provider);
}
/** Removes all providers registered by a plugin/source id. */
export function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of apiProviderRegistry.entries()) {
function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of providers.entries()) {
if (entry.sourceId === sourceId) {
apiProviderRegistry.delete(api);
providers.delete(api);
}
}
}
/** Clears the registry for test teardown and runtime reset flows. */
export function clearApiProviders(): void {
apiProviderRegistry.clear();
return {
registerApiProvider,
getApiProvider,
getApiProviders,
unregisterApiProviders,
clearApiProviders: () => providers.clear(),
};
}
export type ApiRegistry = ReturnType<typeof createApiRegistry>;

66
packages/ai/src/host.ts Normal file
View file

@ -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<T>(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<Model, "provider" | "api" | "baseUrl" | "id"> & { 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<string, unknown> } | 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<AiTransportHost>): void {
activeAiTransportHost = { ...inertAiTransportHost, ...host };
}
/** Returns the active transport host (inert defaults unless configured). */
export function getAiTransportHost(): AiTransportHost {
return activeAiTransportHost;
}

5
packages/ai/src/index.ts Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
/** Lazy built-in protocol adapter registration. */
export {
BUILT_IN_API_PROVIDER_SOURCE_ID,
registerBuiltInApiProviders,
resetApiProviders,
} from "./providers/register-builtins.js";

View file

@ -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<string> {
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;

View file

@ -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,

View file

@ -1,4 +1,4 @@
import type { AssistantMessageDiagnostic } from "../llm/types.js";
import type { AssistantMessageDiagnostic } from "../types.js";
type AnthropicRefusalOutput = {
stopReason: string;

View file

@ -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);

View file

@ -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 () => {

View file

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

View file

@ -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({

View file

@ -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,

View file

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

View file

@ -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: [
{

View file

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

View file

@ -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,

View file

@ -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,

View file

@ -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<T> = { [P in keyof T]?: DeepPartial<T[P]> };
type OpenAICompatibleDelta = DeepPartial<ChatCompletionChunk["choices"][number]["delta"]> & {
@ -160,10 +161,13 @@ function createNeverYieldingStream(): AsyncIterable<OpenAICompatibleChatCompleti
}
describe("OpenAI-compatible completions params", () => {
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 });
try {
const stream = streamOpenAICompletions(model, context, {
apiKey: "sk-test",
});
@ -175,9 +179,10 @@ describe("OpenAI-compatible completions params", () => {
baseURL: "https://api.openai.com/v1",
dangerouslyAllowBrowser: true,
});
expect((mockOpenAIOptionsRef.options[0] as { fetch?: unknown }).fetch).toEqual(
expect.any(Function),
);
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 () => {

View file

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

View file

@ -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<Record<string, unknown>>) {
}
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 = [
{

View file

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

View file

@ -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<string>();
@ -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")) {
if (strictSetting === true && strict === false && model) {
getAiTransportHost().logDebug(LOG_SUBSYSTEM, () => {
const diagnostics = findOpenAIStrictToolProjectionDiagnostics(projection);
if (shouldLogStrictToolDowngradeDiagnostic(diagnostics, model)) {
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(
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;
}

View file

@ -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<object, Array<{ key: string; value: un
function resolveToolSchemaModelCompat(
compat: ToolSchemaCompatInput | null | undefined,
): ModelCompatConfig | undefined {
): ToolSchemaModelCompat | undefined {
if (!compat) {
return undefined;
}

View file

@ -0,0 +1,164 @@
// Built-in provider registration installs lazy protocol adapters.
import type { ApiRegistry } from "../api-registry.js";
import type {
Api,
AssistantMessage,
AssistantMessageEvent,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
type ProviderStreams<TApi extends Api, TOptions extends StreamOptions> = {
stream: StreamFunction<TApi, TOptions>;
streamSimple: StreamFunction<TApi, SimpleStreamOptions>;
};
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<AssistantMessageEvent>,
): void {
void (async () => {
for await (const event of source) {
target.push(event);
}
target.end();
})();
}
function createLazyLoadErrorMessage<TApi extends Api>(
model: Model<TApi>,
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<TApi extends Api, TOptions extends StreamOptions, TStreams>(
load: () => Promise<TStreams>,
select: (streams: TStreams) => StreamFunction<TApi, TOptions>,
): StreamFunction<TApi, TOptions> {
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<TApi extends Api, TOptions extends StreamOptions, TModule>(
api: TApi,
importModule: () => Promise<TModule>,
select: (module: TModule) => ProviderStreams<TApi, TOptions>,
): RegisterBuiltIn {
let streamsPromise: Promise<ProviderStreams<TApi, TOptions>> | undefined;
const load = () => (streamsPromise ??= importModule().then(select));
const stream = createLazyStream(load, (streams) => streams.stream);
const streamSimple = createLazyStream<TApi, SimpleStreamOptions, ProviderStreams<TApi, TOptions>>(
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);
}

View file

@ -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" },

View file

@ -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, unknown>): string | undefined {
const seen = new WeakSet<object>();
try {
const redactedWrapper = redactSecrets({ structuredToolResult: block });
const redactedWrapper = getAiTransportHost().redactSecrets({ structuredToolResult: block });
const redactedBlock = redactedWrapper.structuredToolResult;
const serialized = JSON.stringify(
redactedBlock,

View file

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

59
packages/ai/src/stream.ts Normal file
View file

@ -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<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStreamContract {
return resolveApiProvider(model.api).stream(model, context, options as StreamOptions);
}
async function complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): Promise<AssistantMessage> {
return stream(model, context, options).result();
}
function streamSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStreamContract {
return resolveApiProvider(model.api).streamSimple(model, context, options);
}
async function completeSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): Promise<AssistantMessage> {
return streamSimple(model, context, options).result();
}
return { registry, stream, complete, streamSimple, completeSimple };
}
export type LlmRuntime = ReturnType<typeof createLlmRuntime>;

2
packages/ai/src/types.ts Normal file
View file

@ -0,0 +1,2 @@
/** Shared model, message, tool, and streaming contracts. */
export * from "@openclaw/llm-core";

View file

@ -0,0 +1,2 @@
/** Shared provider diagnostics. */
export * from "@openclaw/llm-core/diagnostics";

View file

@ -0,0 +1,2 @@
/** Assistant message event stream implementation. */
export * from "@openclaw/llm-core/event-stream";

Some files were not shown because too many files have changed in this diff Show more