From 3a6461e6e7b73483a84f73380f31107d8386cfbc Mon Sep 17 00:00:00 2001 From: Heng Xia Date: Thu, 9 Apr 2026 18:29:08 +0800 Subject: [PATCH] fix: align requestDimensions schema sizing and request payloads --- index.ts | 12 +++++-- openclaw.plugin.json | 22 +++++++++--- src/embedder.ts | 19 +++++++++-- test/embedder-error-hints.test.mjs | 26 +++++++++++--- test/plugin-manifest-regression.mjs | 53 ++++++++++++++++++++++++++--- 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/index.ts b/index.ts index f6e202dc..f2d319c4 100644 --- a/index.ts +++ b/index.ts @@ -21,7 +21,10 @@ const isCliMode = () => process.env.OPENCLAW_CLI === "1"; // Import core components import { MemoryStore, validateStoragePath } from "./src/store.js"; -import { createEmbedder, getVectorDimensions } from "./src/embedder.js"; +import { + createEmbedder, + getEffectiveVectorDimensions, +} from "./src/embedder.js"; import { createRetriever, DEFAULT_RETRIEVAL_CONFIG } from "./src/retriever.js"; import { createScopeManager, resolveScopeFilter, isSystemBypassId, parseAgentIdFromSessionKey } from "./src/scopes.js"; import { createMigrator } from "./src/migrate.js"; @@ -91,6 +94,7 @@ interface PluginConfig { model?: string; baseURL?: string; dimensions?: number; + requestDimensions?: number; omitDimensions?: boolean; taskQuery?: string; taskPassage?: string; @@ -1651,9 +1655,10 @@ const memoryLanceDBProPlugin = { ); } - const vectorDim = getVectorDimensions( + const vectorDim = getEffectiveVectorDimensions( config.embedding.model || "text-embedding-3-small", config.embedding.dimensions, + config.embedding.requestDimensions, ); // Initialize core components @@ -1664,6 +1669,7 @@ const memoryLanceDBProPlugin = { model: config.embedding.model || "text-embedding-3-small", baseURL: config.embedding.baseURL, dimensions: config.embedding.dimensions, + requestDimensions: config.embedding.requestDimensions, omitDimensions: config.embedding.omitDimensions, taskQuery: config.embedding.taskQuery, taskPassage: config.embedding.taskPassage, @@ -3845,6 +3851,8 @@ export function parsePluginConfig(value: unknown): PluginConfig { // Accept number, numeric string, or env-var string (e.g. "${EMBED_DIM}"). // Also accept legacy top-level `dimensions` for convenience. dimensions: parsePositiveInt(embedding.dimensions ?? cfg.dimensions), + // Intentionally no top-level fallback: requestDimensions is request-only. + requestDimensions: parsePositiveInt(embedding.requestDimensions), omitDimensions: typeof embedding.omitDimensions === "boolean" ? embedding.omitDimensions diff --git a/openclaw.plugin.json b/openclaw.plugin.json index bf274e03..665414a6 100644 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -45,11 +45,17 @@ }, "dimensions": { "type": "integer", - "minimum": 1 + "minimum": 1, + "description": "Internal vector dimensions for LanceDB schema sizing and local embedding validation" + }, + "requestDimensions": { + "type": "integer", + "minimum": 1, + "description": "Optional dimensions/output_dimension value to send to embedding providers that support variable output sizes" }, "omitDimensions": { "type": "boolean", - "description": "When true, omit the dimensions parameter from embedding requests even if dimensions is configured" + "description": "When true, omit dimensions/output_dimension from embedding requests even if requestDimensions is configured" }, "taskQuery": { "type": "string", @@ -875,14 +881,20 @@ "advanced": true }, "embedding.dimensions": { - "label": "Vector Dimensions", + "label": "Schema Dimensions", "placeholder": "auto-detected from model", - "help": "Override vector dimensions for custom models not in the built-in lookup table", + "help": "Internal vector dimensions used for LanceDB schema sizing and local embedding validation. Override this for custom models not in the built-in lookup table.", + "advanced": true + }, + "embedding.requestDimensions": { + "label": "Request Dimensions", + "placeholder": "omit by default", + "help": "Optional dimensions/output_dimension value to send to the embedding API. If unset, no request-side dimensions field is sent.", "advanced": true }, "embedding.omitDimensions": { "label": "Omit Request Dimensions", - "help": "Do not send the dimensions parameter to the embedding API even if embedding.dimensions is configured. Useful for local models like Qwen3-Embedding that reject the field.", + "help": "Do not send dimensions/output_dimension to the embedding API even if embedding.requestDimensions is configured. Useful for local models like Qwen3-Embedding that reject the field.", "advanced": true }, "embedding.taskQuery": { diff --git a/src/embedder.ts b/src/embedder.ts index b881aa80..c5dcb6eb 100644 --- a/src/embedder.ts +++ b/src/embedder.ts @@ -90,7 +90,10 @@ export interface EmbeddingConfig { apiKey: string | string[]; model: string; baseURL?: string; + /** Internal vector dimension for schema sizing and local validation. */ dimensions?: number; + /** Optional API request output dimension for providers that support it. */ + requestDimensions?: number; /** Optional task type for query embeddings (e.g. "retrieval.query") */ taskQuery?: string; @@ -419,6 +422,14 @@ export function getVectorDimensions(model: string, overrideDims?: number): numbe return dims; } +export function getEffectiveVectorDimensions( + model: string, + dimensions?: number, + requestDimensions?: number, +): number { + return getVectorDimensions(model, requestDimensions ?? dimensions); +} + // ============================================================================ // Embedder Class // ============================================================================ @@ -456,7 +467,7 @@ export class Embedder { this._taskQuery = config.taskQuery; this._taskPassage = config.taskPassage; this._normalized = config.normalized; - this._requestDimensions = config.dimensions; + this._requestDimensions = config.requestDimensions; this._omitDimensions = config.omitDimensions === true; // Enable auto-chunking by default for better handling of long documents this._autoChunk = config.chunking !== false; @@ -500,7 +511,11 @@ export class Embedder { console.log(`[memory-lancedb-pro] Initialized ${this.clients.length} API keys for round-robin rotation`); } - this.dimensions = getVectorDimensions(config.model, config.dimensions); + this.dimensions = getEffectiveVectorDimensions( + config.model, + config.dimensions, + config.requestDimensions, + ); this._cache = new EmbeddingCache(256, 30); // 256 entries, 30 min TTL } diff --git a/test/embedder-error-hints.test.mjs b/test/embedder-error-hints.test.mjs index 38db8ae1..48dd3360 100644 --- a/test/embedder-error-hints.test.mjs +++ b/test/embedder-error-hints.test.mjs @@ -130,7 +130,7 @@ async function run() { installMockEmbeddingClient(jinaEmbedder, async (payload) => { assert.equal(payload.task, "retrieval.passage"); assert.equal(payload.normalized, true); - assert.equal(payload.dimensions, 1024); + assert.equal(payload.dimensions, undefined, "jina should not send dimensions unless requestDimensions is set"); return createEmbeddingResponse(1024); }); await jinaEmbedder.embedPassage("hello"); @@ -144,7 +144,7 @@ async function run() { }); installMockEmbeddingClient(genericEmbedder, async (payload) => { assert.equal(payload.encoding_format, "float"); - assert.equal(payload.dimensions, 384); + assert.equal(payload.dimensions, undefined, "generic profile should not send dimensions unless requestDimensions is set"); return createEmbeddingResponse(384); }); await genericEmbedder.embedPassage("hello"); @@ -196,7 +196,7 @@ async function run() { apiKey: "test-key", model: "voyage-4-lite", baseURL: "https://api.voyageai.com/v1", - dimensions: 512, + requestDimensions: 512, }); installMockEmbeddingClient(voyageDimEmbedder, async (payload) => { assert.equal(payload.output_dimension, 512, "voyage should send output_dimension"); @@ -211,7 +211,7 @@ async function run() { await withEmbeddingCaptureServer( (payload) => { assert.equal(payload.encoding_format, "float", "generic profile should send encoding_format"); - assert.equal(payload.dimensions, 384, "generic profile should send dimensions"); + assert.equal(payload.dimensions, undefined, "generic profile should not send dimensions by default"); assert.equal(payload.task, undefined, "generic profile should not send task"); assert.equal(payload.normalized, undefined, "generic profile should not send normalized"); return { body: createEmbeddingResponse(384) }; @@ -228,6 +228,24 @@ async function run() { }, ); + await withEmbeddingCaptureServer( + (payload) => { + assert.equal(payload.encoding_format, "float", "generic profile should send encoding_format"); + assert.equal(payload.dimensions, 384, "generic profile should send dimensions when requestDimensions is set"); + return { body: createEmbeddingResponse(384) }; + }, + async ({ baseURL }) => { + const embedder = new Embedder({ + provider: "openai-compatible", + apiKey: "test-key", + model: "text-embedding-3-small", + baseURL, + requestDimensions: 384, + }); + await embedder.embedPassage("hello world"); + }, + ); + await withJsonServer( 403, { error: { message: "Invalid API key", code: "invalid_api_key" } }, diff --git a/test/plugin-manifest-regression.mjs b/test/plugin-manifest-regression.mjs index cc5232bd..b51131f2 100644 --- a/test/plugin-manifest-regression.mjs +++ b/test/plugin-manifest-regression.mjs @@ -109,6 +109,15 @@ assert.equal( "boolean", "embedding.omitDimensions should be declared in the plugin schema", ); +assert.equal( + manifest.configSchema.properties.embedding.properties.requestDimensions?.type, + "integer", + "embedding.requestDimensions should be declared in the plugin schema", +); +assert.ok( + Object.prototype.hasOwnProperty.call(manifest.uiHints, "embedding.requestDimensions"), + "uiHints should expose embedding.requestDimensions", +); assert.equal( manifest.configSchema.properties.sessionMemory.properties.enabled.default, false, @@ -327,14 +336,48 @@ try { }); const requestCountBeforeWithDimensions = embeddingRequests.length; await withDimensionsTool.execute("tool-3", { - text: "dimensions should be sent by default", + text: "dimensions should stay internal by default", scope: "global", }); const withDimensionsRequest = embeddingRequests.at(requestCountBeforeWithDimensions); assert.equal( - withDimensionsRequest?.dimensions, + Object.prototype.hasOwnProperty.call(withDimensionsRequest ?? {}, "dimensions"), + false, + "embedding.dimensions should be used for local schema sizing, not forwarded by default", + ); + + const withRequestDimensionsApi = createMockApi({ + dbPath: path.join(workDir, "db-with-request-dimensions"), + autoCapture: false, + autoRecall: false, + embedding: { + provider: "openai-compatible", + apiKey: "dummy", + model: "text-embedding-3-small", + baseURL: embeddingBaseURL, + requestDimensions: 4, + }, + }); + plugin.register(withRequestDimensionsApi); + const withRequestDimensionsTool = withRequestDimensionsApi.toolFactories.memory_store({ + agentId: "main", + sessionKey: "agent:main:test", + }); + const requestCountBeforeRequestDimensions = embeddingRequests.length; + const withRequestDimensionsResult = await withRequestDimensionsTool.execute("tool-3b", { + text: "requestDimensions should drive both request payload and local schema size", + scope: "global", + }); + assert.equal( + withRequestDimensionsResult.details.action, + "created", + "requestDimensions-only config should still create memories end-to-end", + ); + const withRequestDimensionsRequest = embeddingRequests.at(requestCountBeforeRequestDimensions); + assert.equal( + withRequestDimensionsRequest?.dimensions, 4, - "embedding.dimensions should be forwarded by default", + "embedding.requestDimensions should be forwarded to embedding requests", ); const omitDimensionsApi = createMockApi({ @@ -346,7 +389,7 @@ try { apiKey: "dummy", model: "text-embedding-3-small", baseURL: embeddingBaseURL, - dimensions: 4, + requestDimensions: 4, omitDimensions: true, }, }); @@ -364,7 +407,7 @@ try { assert.equal( Object.prototype.hasOwnProperty.call(omitDimensionsRequest, "dimensions"), false, - "embedding.omitDimensions=true should omit dimensions from embedding requests", + "embedding.omitDimensions=true should omit dimensions from embedding requests even when requestDimensions is set", ); } finally { await new Promise((resolve) => embeddingServer.close(resolve));