diff --git a/.env.example b/.env.example index 7c400c907e..cc72c3c0b2 100644 --- a/.env.example +++ b/.env.example @@ -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) # ----------------------------------------------------------------------------- diff --git a/docker/platformclaw-runtime/compose.yaml b/docker/platformclaw-runtime/compose.yaml index 458a54c588..1c2d56cfa4 100644 --- a/docker/platformclaw-runtime/compose.yaml +++ b/docker/platformclaw-runtime/compose.yaml @@ -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 diff --git a/docker/platformclaw-runtime/deployment.env.example b/docker/platformclaw-runtime/deployment.env.example index 8fe0d2b0a3..e3bd0ed2a9 100644 --- a/docker/platformclaw-runtime/deployment.env.example +++ b/docker/platformclaw-runtime/deployment.env.example @@ -3,6 +3,8 @@ PLATFORMCLAW_SANDBOX_IMAGE=platformclaw-sandbox: # 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 diff --git a/docker/platformclaw-runtime/platformclaw-compose b/docker/platformclaw-runtime/platformclaw-compose index f18ee94c34..8b615380bf 100755 --- a/docker/platformclaw-runtime/platformclaw-compose +++ b/docker/platformclaw-runtime/platformclaw-compose @@ -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 \ diff --git a/docs/platformclaw/web-ingress-runtime.md b/docs/platformclaw/web-ingress-runtime.md index 8d4da7f912..5c30c88c46 100644 --- a/docs/platformclaw/web-ingress-runtime.md +++ b/docs/platformclaw/web-ingress-runtime.md @@ -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. diff --git a/extensions/admin-http-rpc/index.test.ts b/extensions/admin-http-rpc/index.test.ts index e4ff108359..10b7404cb8 100644 --- a/extensions/admin-http-rpc/index.test.ts +++ b/extensions/admin-http-rpc/index.test.ts @@ -18,6 +18,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({ afterEach(() => { vi.resetAllMocks(); + vi.unstubAllEnvs(); }); describe("admin-http-rpc plugin entry", () => { @@ -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[0]> = []; const hooks = new Map unknown>(); const stores = new Map>(); diff --git a/extensions/admin-http-rpc/openclaw.plugin.json b/extensions/admin-http-rpc/openclaw.plugin.json index 4d07c4e80d..f52a9d7a9e 100644 --- a/extensions/admin-http-rpc/openclaw.plugin.json +++ b/extensions/admin-http-rpc/openclaw.plugin.json @@ -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." } } } diff --git a/extensions/admin-http-rpc/src/composer-predictions.test.ts b/extensions/admin-http-rpc/src/composer-predictions.test.ts index 3e0269f84b..0c01817a28 100644 --- a/extensions/admin-http-rpc/src/composer-predictions.test.ts +++ b/extensions/admin-http-rpc/src/composer-predictions.test.ts @@ -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() })); @@ -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(); 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 }], }, }, }, @@ -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 }); @@ -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); @@ -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 }); diff --git a/extensions/admin-http-rpc/src/composer-predictions.ts b/extensions/admin-http-rpc/src/composer-predictions.ts index 8faab453ed..a86e67eda5 100644 --- a/extensions/admin-http-rpc/src/composer-predictions.ts +++ b/extensions/admin-http-rpc/src/composer-predictions.ts @@ -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.", @@ -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({ namespace: "platformclaw.composer-preferences", maxEntries: 50_000, @@ -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 }; } @@ -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,