diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 514b5fd..6b4f573 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -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" }, diff --git a/src/config.ts b/src/config.ts index 30a68e3..1bfdf4f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/offload/index.ts b/src/offload/index.ts index 33283df..058ffa2 100644 --- a/src/offload/index.ts +++ b/src/offload/index.ts @@ -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. @@ -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."); @@ -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; + } } } } diff --git a/src/offload/types.ts b/src/offload/types.ts index a8ca581..ae85095 100644 --- a/src/offload/types.ts +++ b/src/offload/types.ts @@ -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; diff --git a/src/utils/clean-context-runner.ts b/src/utils/clean-context-runner.ts index 868d787..fe89881 100644 --- a/src/utils/clean-context-runner.ts +++ b/src/utils/clean-context-runner.ts @@ -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. @@ -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. * diff --git a/src/utils/model-ref.test.ts b/src/utils/model-ref.test.ts new file mode 100644 index 0000000..7952ce6 --- /dev/null +++ b/src/utils/model-ref.test.ts @@ -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(); + }); +}); diff --git a/src/utils/model-ref.ts b/src/utils/model-ref.ts new file mode 100644 index 0000000..23f2e1c --- /dev/null +++ b/src/utils/model-ref.ts @@ -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), + }; +}