Skip to content
Open
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
2 changes: 1 addition & 1 deletion openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@
"description": "Context Offload 设置 — 多层上下文压缩系统(独立开关,默认关闭)",
"properties": {
"enabled": { "type": "boolean", "default": false, "description": "是否启用 Context Offload(默认关闭,不影响 Memory 功能)" },
"model": { "type": "string", "description": "Offload 使用的 LLM 模型(格式: provider/model),未填写时使用 openclaw 默认模型" },
"model": { "type": "string", "description": "Offload 使用的 LLM 模型(格式: provider/model-id,model-id 可包含 /,如 siliconflow/deepseek-ai/DeepSeek-V4-Flash),未填写时使用 openclaw 默认模型" },
"temperature": { "type": "number", "default": 0.2, "description": "LLM 温度参数" },
"forceTriggerThreshold": { "type": "number", "default": 4, "description": "累积多少个 tool pair 后强制触发 L1" },
"dataDir": { "type": "string", "description": "自定义数据目录(绝对路径),默认 ~/.openclaw/context-offload" },
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export interface OffloadConfig {
* Default: "local" (auto-detects based on backendUrl presence for backward compat)
*/
mode: "local" | "backend";
/** LLM model for offload tasks, format: "provider/model-id". Falls back to agents.defaults.model when omitted. */
/** LLM model for offload tasks, format: "provider/model-id"; model-id may contain "/". Falls back to agents.defaults.model when omitted. */
model?: string;
/** LLM temperature (default: 0.2) */
temperature: number;
Expand Down
54 changes: 32 additions & 22 deletions src/offload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { SessionRegistry } from "./session-registry.js";
import { reclaimOffloadData } from "./reclaimer.js";
import { buildL3TriggerReport, reportL3Trigger } from "./state-reporter.js";
import { resolveUserId, getUserIdSource } from "./user-id.js";
import { parseModelRef } from "../utils/model-ref.js";

// ─── Module-level state ──────────────────────────────────────────────────────
// OpenClaw calls registerOffload() multiple times during lifecycle.
Expand Down Expand Up @@ -351,24 +352,31 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
}

if (resolvedModelRef) {
const modelParts = resolvedModelRef.split("/", 2);
const providerKey = modelParts[0];
const modelId = modelParts[1] ?? resolvedModelRef;
const models = (api.config as any)?.models;
const providerCfg = models?.providers?.[providerKey];
const baseUrl = providerCfg?.baseUrl ?? providerCfg?.baseURL;
const apiKey = providerCfg?.apiKey;

if (baseUrl && apiKey) {
backendClient = new LocalLlmClient(
{ baseUrl, apiKey, model: modelId, temperature: offloadConfig.temperature, timeoutMs: offloadConfig.backendTimeoutMs },
logger,
);
} else {
const modelRef = parseModelRef(resolvedModelRef);
if (!modelRef) {
logger.error(
`[context-offload] Local LLM mode failed: provider "${providerKey}" not found or missing baseUrl/apiKey in models.providers. ` +
`L1/L1.5/L2 disabled.`,
`[context-offload] Local LLM mode failed: invalid model reference "${resolvedModelRef}". ` +
`Expected "provider/model". L1/L1.5/L2 disabled.`,
);
} else {
const providerKey = modelRef.provider;
const modelId = modelRef.model;
const models = (api.config as any)?.models;
const providerCfg = models?.providers?.[providerKey];
const baseUrl = providerCfg?.baseUrl ?? providerCfg?.baseURL;
const apiKey = providerCfg?.apiKey;

if (baseUrl && apiKey) {
backendClient = new LocalLlmClient(
{ baseUrl, apiKey, model: modelId, temperature: offloadConfig.temperature, timeoutMs: offloadConfig.backendTimeoutMs },
logger,
);
} else {
logger.error(
`[context-offload] Local LLM mode failed: provider "${providerKey}" not found or missing baseUrl/apiKey in models.providers. ` +
`L1/L1.5/L2 disabled.`,
);
}
}
} else {
logger.warn("[context-offload] No model resolved (offload.model not set, agents.defaults.model not found). L1/L1.5/L2 disabled.");
Expand Down Expand Up @@ -838,12 +846,14 @@ export function registerOffload(api: any, offloadConfig: OffloadConfig): void {
const models = config?.models;
// 1. If we know the model, find its exact contextWindow from providers
if (defaultModel && models) {
const [providerKey, modelId] = defaultModel.split("/", 2);
const provider = models.providers?.[providerKey];
if (provider?.models) {
const modelList = Array.isArray(provider.models) ? provider.models : [];
for (const m of modelList) {
if (m.id === modelId && typeof m.contextWindow === "number") return m.contextWindow;
const modelRef = parseModelRef(defaultModel);
if (modelRef) {
const provider = models.providers?.[modelRef.provider];
if (provider?.models) {
const modelList = Array.isArray(provider.models) ? provider.models : [];
for (const m of modelList) {
if (m.id === modelRef.model && typeof m.contextWindow === "number") return m.contextWindow;
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/offload/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export interface ModelProvider {
* All fields are optional; defaults are used when not specified.
*/
export interface PluginConfig {
/** Explicit LLM model for offload tasks, format: "provider/model-id" (e.g. "dashscope/kimi-k2.5") */
/** Explicit LLM model for offload tasks, format: "provider/model-id"; model-id may contain "/" (e.g. "siliconflow/deepseek-ai/DeepSeek-V4-Flash"). */
model?: string;
/** LLM temperature for offload tasks. Default: 0.2 */
temperature?: number;
Expand Down
33 changes: 3 additions & 30 deletions src/utils/clean-context-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import os from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { getEnv } from "./env.js";
import { parseModelRef, type ModelRef } from "./model-ref.js";
import { report } from "../core/report/reporter.js";
export type { ModelRef } from "./model-ref.js";
export { parseModelRef } from "./model-ref.js";

/**
* Resolve a preferred temporary directory for memory-tdai operations.
Expand Down Expand Up @@ -187,36 +190,6 @@ function collectText(payloads: Array<{ text?: string; isError?: boolean }> | und

// ── Model resolution utilities ──

/** Parsed model reference: { provider, model } */
export interface ModelRef {
provider: string;
model: string;
}

/**
* Parse a "provider/model" string into its components.
* Returns undefined if the input is empty or doesn't contain a "/".
*
* Examples:
* "azure/gpt-5.2-chat" → { provider: "azure", model: "gpt-5.2-chat" }
* "custom-host/org/model-v2" → { provider: "custom-host", model: "org/model-v2" }
* "" → undefined
* "bare-model-name" → undefined (no "/" — may be an alias)
*/
export function parseModelRef(raw: string | undefined): ModelRef | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;

const slashIdx = trimmed.indexOf("/");
if (slashIdx <= 0 || slashIdx === trimmed.length - 1) return undefined;

return {
provider: trimmed.slice(0, slashIdx),
model: trimmed.slice(slashIdx + 1),
};
}

/**
* Resolve the user's default model from the main OpenClaw config.
*
Expand Down
33 changes: 33 additions & 0 deletions src/utils/model-ref.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { parseModelRef } from "./model-ref.js";

describe("parseModelRef", () => {
it("parses a simple provider/model reference", () => {
expect(parseModelRef("openai/gpt-4o")).toEqual({
provider: "openai",
model: "gpt-4o",
});
});

it("keeps namespace slashes in the model id", () => {
expect(parseModelRef("siliconflow/deepseek-ai/DeepSeek-V4-Flash")).toEqual({
provider: "siliconflow",
model: "deepseek-ai/DeepSeek-V4-Flash",
});
});

it("trims surrounding whitespace", () => {
expect(parseModelRef(" custom-host/org/model-v2 ")).toEqual({
provider: "custom-host",
model: "org/model-v2",
});
});

it("rejects invalid model references", () => {
expect(parseModelRef(undefined)).toBeUndefined();
expect(parseModelRef("")).toBeUndefined();
expect(parseModelRef("bare-model-name")).toBeUndefined();
expect(parseModelRef("/missing-provider")).toBeUndefined();
expect(parseModelRef("missing-model/")).toBeUndefined();
});
});
25 changes: 25 additions & 0 deletions src/utils/model-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Parsed model reference: { provider, model }. */
export interface ModelRef {
provider: string;
model: string;
}

/**
* Parse "provider/model-id" into its components.
*
* The provider is the segment before the first slash. The model id may contain
* additional slashes, for example "siliconflow/deepseek-ai/DeepSeek-V4-Flash".
*/
export function parseModelRef(raw: string | undefined): ModelRef | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;

const slashIdx = trimmed.indexOf("/");
if (slashIdx <= 0 || slashIdx === trimmed.length - 1) return undefined;

return {
provider: trimmed.slice(0, slashIdx),
model: trimmed.slice(slashIdx + 1),
};
}