Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ OPENCLAW_GATEWAY_TOKEN=
# MINIMAX_API_KEY=...
# SYNTHETIC_API_KEY=...

# Optional Composer Predictions model: configured provider/model (default shown).
# PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL=codemate/codellm-fast

# -----------------------------------------------------------------------------
# Channels (only set what you enable)
# -----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions docker/platformclaw-runtime/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ services:
condition: service_completed_successfully
environment:
PLATFORMCLAW_GATEWAY_MAX_OLD_SPACE_SIZE: ${PLATFORMCLAW_GATEWAY_MAX_OLD_SPACE_SIZE:-8192}
PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL: ${PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL:-}
ACPX_CLAUDE_INCLUDE_USER_SETTINGS: "1"
DOCKER_HOST: unix:///run/platformclaw-sandbox-docker/docker.sock
HOME: ${PLATFORMCLAW_DEPLOY_ROOT:?run platformclaw-compose with a service user}/data/gateway-home
Expand Down
2 changes: 2 additions & 0 deletions docker/platformclaw-runtime/deployment.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ PLATFORMCLAW_SANDBOX_IMAGE=platformclaw-sandbox:<sha12>
# Gateway V8 old-space ceiling in MiB. This is not an RSS/container memory limit.
# The launcher appends this heap option to existing NODE_OPTIONS.
PLATFORMCLAW_GATEWAY_MAX_OLD_SPACE_SIZE=8192
# Composer Predictions model must name a configured provider/model.
PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL=codemate/codellm-fast
PLATFORMCLAW_SKILL_HUB_ENABLED=true
PLATFORMCLAW_SKILL_HUB_SERVER_IMAGE=platformclaw-skillhub-server:6e133c006e49cb1217a0ba75
PLATFORMCLAW_SKILL_HUB_SCANNER_IMAGE=platformclaw-skillhub-scanner:6e133c006e49
Expand Down
1 change: 1 addition & 0 deletions docker/platformclaw-runtime/platformclaw-compose
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ skillhub_enabled="$(sed -n 's/^PLATFORMCLAW_SKILL_HUB_ENABLED=//p' "$PLATFORMCLA
# must not silently override an image update, rollback, origin, or secret path.
unset PLATFORMCLAW_IMAGE PLATFORMCLAW_SANDBOX_IMAGE \
PLATFORMCLAW_GATEWAY_MAX_OLD_SPACE_SIZE \
PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL \
PLATFORMCLAW_PUBLIC_ORIGIN PLATFORMCLAW_PUBLIC_PORT PLATFORMCLAW_GUIDE_VIDEO_URL \
PLATFORMCLAW_GUIDE_VIDEO_S3_ENDPOINT PLATFORMCLAW_GUIDE_VIDEO_S3_REGION \
PLATFORMCLAW_GUIDE_VIDEO_S3_BUCKET PLATFORMCLAW_GUIDE_VIDEO_S3_KEY \
Expand Down
18 changes: 15 additions & 3 deletions docs/platformclaw/web-ingress-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,21 @@ Predictions are returned only to that request; they are neither broadcast nor
written to conversation history. Caller-supplied user identities, context, model,
or endpoint parameters are rejected.

The trusted admin HTTP plugin calls only configured `codemate/codellm-fast`
(`ornith-1.5-35b` in the company catalog), using the existing OpenAI-compatible
provider URL, headers, and resolved credentials. The destination must resolve to
Set the prediction model separately from conversation models:

```dotenv
PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL=codemate/codellm-fast
```

Use a full `provider/model` reference from the existing Gateway model configuration.
Unset or blank values keep the default `codemate/codellm-fast`. For a Docker runtime
deployment, edit `deployment.env` and run `./platformclaw-compose up -d openclaw-gateway`
to recreate the Gateway with the new environment. For a directly launched Gateway,
set the variable in its environment and restart it. The plugin pins the selection
until it is recreated.

The trusted admin HTTP plugin calls only that selected model using the existing
OpenAI-compatible provider URL, headers, and resolved credentials. The destination must resolve to
private or loopback addresses, DNS is pinned, and redirects are forbidden. Missing
configuration or unsupported API adapters yield no suggestion. No external LLM,
Agent Loop, tools, skills, memory retrieval, retries, or model fallback are used.
Expand Down
2 changes: 2 additions & 0 deletions extensions/admin-http-rpc/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({

afterEach(() => {
vi.resetAllMocks();
vi.unstubAllEnvs();
});

describe("admin-http-rpc plugin entry", () => {
Expand Down Expand Up @@ -146,6 +147,7 @@ describe("admin-http-rpc plugin entry", () => {
});

it("routes ordinary model lifecycle hooks to HTTP prediction admission", async () => {
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", "codemate/codellm-fast");
const routes: Array<Parameters<PluginApi["registerHttpRoute"]>[0]> = [];
const hooks = new Map<string, (event: { callId: string }) => unknown>();
const stores = new Map<string, Map<string, unknown>>();
Expand Down
2 changes: 1 addition & 1 deletion extensions/admin-http-rpc/openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"composerPredictionsEnabled": {
"type": "boolean",
"default": false,
"description": "Enable Web-only, account-opt-in predictions using codemate/codellm-fast."
"description": "Enable Web-only, account-opt-in predictions using the model selected by PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL."
}
}
}
Expand Down
57 changes: 49 additions & 8 deletions extensions/admin-http-rpc/src/composer-predictions.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createComposerPredictions } from "./composer-predictions.js";

const mocks = vi.hoisted(() => ({ fetch: vi.fn(), lookup: vi.fn() }));
Expand All @@ -9,16 +9,16 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
isPrivateOrLoopbackHost: (address: string) => address === "10.1.2.3",
}));

function setup() {
function setup(providerId = "codemate", modelId = "codellm-fast", apiType = "openai-completions") {
const values = new Map<string, boolean>();
const config = {
plugins: { entries: { "admin-http-rpc": { config: { composerPredictionsEnabled: true } } } },
models: {
providers: {
codemate: {
[providerId]: {
baseUrl: "http://company.internal/v1",
api: "openai-completions",
models: [{ id: "codellm-fast" }],
api: apiType,
models: [{ id: modelId }],
},
},
},
Expand Down Expand Up @@ -52,14 +52,19 @@ function setup() {
],
};
const predict = (signal = new AbortController().signal) => call(params, signal);
return { call, predict, composer, config, values, params };
return { call, predict, composer, values, params };
}
beforeEach(() => {
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", undefined);
});

afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
vi.useRealTimers();
});

describe("Fast-only isolated inference", () => {
describe("Explicit isolated prediction inference", () => {
it("defaults OFF, isolates account preferences, and never follows conversation models", async () => {
const { call, predict, params } = setup();
expect(await predict()).toEqual({ text: null });
Expand All @@ -74,6 +79,41 @@ describe("Fast-only isolated inference", () => {
expect(request.capture).toBe(false);
expect(mocks.fetch).toHaveBeenCalledTimes(1);
});
it("uses the configured provider/model and pins it until plugin recreation", async () => {
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", " company-fast/org/followup-v2 ");
const { predict, values } = setup("company-fast", "org/followup-v2");
values.set("user-1", true);
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", "codemate/codellm-fast");
expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" });
const request = mocks.fetch.mock.calls[0]?.[0];
expect(JSON.parse(request.init.body).model).toBe("org/followup-v2");
expect(mocks.fetch).toHaveBeenCalledTimes(1);
});
it.each(["", " "])("keeps the default for an empty model environment (%j)", async (value) => {
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", value);
const { predict, values } = setup();
values.set("user-1", true);
await predict();
expect(JSON.parse(mocks.fetch.mock.calls[0]?.[0].init.body).model).toBe("codellm-fast");
});
it.each(["codellm-fast", "/fast", "codemate/", "missing/fast", "codemate/missing"])(
"skips an invalid or unavailable explicit selection (%s) without fallback",
async (value) => {
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", value);
const { predict, values } = setup();
values.set("user-1", true);
expect(await predict()).toEqual({ text: null });
expect(mocks.fetch).not.toHaveBeenCalled();
expect(mocks.lookup).not.toHaveBeenCalled();
},
);
it("rejects an unsupported API on the selected provider", async () => {
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", "company-fast/followup-v2");
const { predict, values } = setup("company-fast", "followup-v2", "anthropic-messages");
values.set("user-1", true);
expect(await predict()).toEqual({ text: null });
expect(mocks.fetch).not.toHaveBeenCalled();
});
it("skips concurrent predictions and preempts them for ordinary chat", async () => {
const { predict, composer, values } = setup();
values.set("user-1", true);
Expand All @@ -96,7 +136,8 @@ describe("Fast-only isolated inference", () => {
expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" });
});
it("rejects public destinations and does not retry or fall back on failure", async () => {
const { predict, values } = setup();
vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", "company-fast/followup-v2");
const { predict, values } = setup("company-fast", "followup-v2");
values.set("user-1", true);
mocks.lookup.mockResolvedValueOnce([{ address: "8.8.8.8", family: 4 }]);
expect(await predict()).toEqual({ text: null });
Expand Down
25 changes: 21 additions & 4 deletions extensions/admin-http-rpc/src/composer-predictions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { lookup } from "node:dns/promises";
import { findNormalizedProviderValue, parseModelRef } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { fetchWithSsrFGuard, isPrivateOrLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";

export const COMPOSER_RPC = "platformclaw.composer";
const MODEL_ID = "codellm-fast";
const DEFAULT_MODEL_REF = "codemate/codellm-fast";
const SYSTEM_PROMPT = [
"Predict one short, useful next message the USER might type in this conversation.",
"Write in the user's voice and language (Korean when appropriate). Return only one concise question.",
Expand All @@ -17,6 +18,16 @@ const SYSTEM_PROMPT = [

/** Trusted HTTP only: deliberately not registered as a Gateway/agent RPC. */
export function createComposerPredictions(api: OpenClawPluginApi) {
const modelRefText =
process.env.PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL?.trim() || DEFAULT_MODEL_REF;
// Pin the explicit deployment selection for this plugin lifecycle; never
// resolve aliases or fall back to the employee's conversation model.
const modelRef = modelRefText.includes("/")
? parseModelRef(modelRefText, "", {
allowManifestNormalization: false,
allowPluginNormalization: false,
})
: null;
const preferences = api.runtime.state.openKeyedStore<boolean>({
namespace: "platformclaw.composer-preferences",
maxEntries: 50_000,
Expand Down Expand Up @@ -64,8 +75,14 @@ export function createComposerPredictions(api: OpenClawPluginApi) {
active = controller;
const combined = AbortSignal.any([signal, controller.signal, AbortSignal.timeout(10_000)]);
try {
const provider = api.runtime.config.current().models?.providers?.codemate;
const model = provider?.models.find((candidate) => candidate.id === MODEL_ID);
if (!modelRef) {
return { text: null };
}
const provider = findNormalizedProviderValue(
api.runtime.config.current().models?.providers,
modelRef.provider,
);
const model = provider?.models.find((candidate) => candidate.id === modelRef.model);
if (!provider || !model || (model.api ?? provider.api) !== "openai-completions") {
return { text: null };
}
Expand Down Expand Up @@ -111,7 +128,7 @@ export function createComposerPredictions(api: OpenClawPluginApi) {
method: "POST",
headers,
body: JSON.stringify({
model: MODEL_ID,
model: model.id,
messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages],
max_tokens: 64,
stream: false,
Expand Down
Loading