diff --git a/src/core/ai/recipes/litellm-proxy.ts b/src/core/ai/recipes/litellm-proxy.ts index bc82b63e8..138e0746b 100644 --- a/src/core/ai/recipes/litellm-proxy.ts +++ b/src/core/ai/recipes/litellm-proxy.ts @@ -6,7 +6,7 @@ import type { Recipe } from '../types.ts'; * gbrain at it via `LITELLM_BASE_URL`. The proxy normalizes to * OpenAI-compatible API. * - * See docs/guides/litellm-proxy.md for the setup recipe. + * See docs/integrations/embedding-providers.md for the setup recipe. */ export const litellmProxy: Recipe = { id: 'litellm', @@ -25,6 +25,7 @@ export const litellmProxy: Recipe = { models: [], user_provided_models: true, // v0.32 D8=A wire-through for the litellm hardcode default_dims: 0, // user must declare --embedding-dimensions explicitly + trust_custom_dims: true, // #2271: proxy-backed model dim is user-declared cost_per_1m_tokens_usd: undefined, price_last_verified: '2026-04-20', // LiteLLM's batch capacity is determined by the backend it proxies; @@ -41,5 +42,5 @@ export const litellmProxy: Recipe = { supports_multimodal: true, }, }, - setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL + pass --embedding-model litellm: and --embedding-dimensions .', + setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm: and --embedding-dimensions .', }; diff --git a/src/core/ai/recipes/llama-server.ts b/src/core/ai/recipes/llama-server.ts index 1bc8b97cc..212bd5478 100644 --- a/src/core/ai/recipes/llama-server.ts +++ b/src/core/ai/recipes/llama-server.ts @@ -32,6 +32,7 @@ export const llamaServer: Recipe = { models: [], // user-driven; whatever model the server was launched with user_provided_models: true, default_dims: 0, // forces explicit --embedding-dimensions + trust_custom_dims: true, // #2271: user knows the launched model's native dim cost_per_1m_tokens_usd: 0, price_last_verified: '2026-05-10', // llama-server's batch capacity is set by `--ctx-size` at launch diff --git a/src/core/ai/recipes/ollama.ts b/src/core/ai/recipes/ollama.ts index 0f355cdae..361192f3f 100644 --- a/src/core/ai/recipes/ollama.ts +++ b/src/core/ai/recipes/ollama.ts @@ -13,8 +13,20 @@ export const ollama: Recipe = { }, touchpoints: { embedding: { - models: ['nomic-embed-text', 'mxbai-embed-large', 'all-minilm'], + // #2271: modern local embed models added so assertTouchpoint accepts them. + // Each carries its own native dim (qwen3-embed-8b=4096, arctic-l-v2=1024); + // the recipe-wide default_dims below is only the nomic fallback, so users + // of the larger models pass --embedding-dimensions (allowed via + // trust_custom_dims). Per-model dims metadata is a tracked follow-up. + models: [ + 'nomic-embed-text', + 'mxbai-embed-large', + 'all-minilm', + 'qwen3-embed-8b', + 'snowflake-arctic-embed-l-v2', + ], default_dims: 768, // nomic-embed-text native dim + trust_custom_dims: true, // #2271: local models carry varied native dims cost_per_1m_tokens_usd: 0, price_last_verified: '2026-04-20', // Ollama's batch capacity depends on the locally loaded model + the diff --git a/src/core/ai/types.ts b/src/core/ai/types.ts index 79e4122e9..4dced5c6d 100644 --- a/src/core/ai/types.ts +++ b/src/core/ai/types.ts @@ -87,6 +87,17 @@ export interface EmbeddingTouchpoint { * for shorthand `--model ` and prints a setup hint. */ user_provided_models?: true; + /** + * #2271: trust a user-supplied `--embedding-dimensions` for this recipe even + * when it's not in the known-Matryoshka allowlist. Set ONLY on local / + * bring-your-own-backend recipes where the user knows their model's native dim + * and we can't enumerate every model (ollama, llama-server, litellm). The + * provider's `/embeddings` response-dim validation catches a genuine mismatch + * pre-storage. Must NOT be set on fixed-dim hosted providers (openai/voyage/ + * zeroentropy stay fail-closed) or on recipes that declare recipe-wide + * `dims_options` (e.g. openrouter, whose Tier-1 options legitimately govern). + */ + trust_custom_dims?: true; /** * v0.32 (#779 reworked): explicit opt-out of the missing-max_batch_tokens * startup warning. Set to `true` for recipes whose batch capacity is diff --git a/src/core/embedding-dim-check.ts b/src/core/embedding-dim-check.ts index 220bcca05..b8d022a8f 100644 --- a/src/core/embedding-dim-check.ts +++ b/src/core/embedding-dim-check.ts @@ -451,6 +451,18 @@ function isCustomDimValidForProvider( }; } + // Passthrough tier (#2271): local / bring-your-own-backend recipes (ollama, + // llama-server, litellm) flag trust_custom_dims because the user knows their + // model's native dim and we can't enumerate every locally-pulled model. Trust + // the requested dim; the provider's /embeddings response-dim validation catches + // a genuine mismatch pre-storage. Runs AFTER Tier 1 (recipe dims_options) and + // Tier 2 (provider Matryoshka allowlists) so a recipe that DOES declare fixed + // options (e.g. openrouter) is still governed by those, and fixed-dim hosted + // providers (openai/voyage/zeroentropy) never reach here as valid. + if (recipe.touchpoints.embedding?.trust_custom_dims === true) { + return { valid: true, error: '' }; + } + // Tier 3: provider not known to support custom dims at all. return { valid: false, diff --git a/test/embedding-dim-check.test.ts b/test/embedding-dim-check.test.ts index 6a945b98c..d2a37d8e6 100644 --- a/test/embedding-dim-check.test.ts +++ b/test/embedding-dim-check.test.ts @@ -257,6 +257,43 @@ describe('resolveSchemaEmbeddingDim', () => { if (got.ok) expect(got.dim).toBe(768); }); + // #2271 — trust_custom_dims passthrough for local / BYO-backend recipes. + test('ollama accepts a custom dim for a modern model via trust_custom_dims', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'ollama:qwen3-embed-8b', + embedding_dimensions: 4096, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(4096); + }); + + test('litellm accepts a user-declared custom dim via trust_custom_dims', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'litellm:bge-large', + embedding_dimensions: 1024, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(1024); + }); + + test('llama-server accepts a user-declared custom dim via trust_custom_dims', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'llama-server:nomic-embed-text-v1.5', + embedding_dimensions: 2560, + }); + expect(got.ok).toBe(true); + if (got.ok) expect(got.dim).toBe(2560); + }); + + test('[REGRESSION] openrouter (declares dims_options, NOT flagged) still fail-closed on an unlisted dim', () => { + const got = resolveSchemaEmbeddingDim({ + embedding_model: 'openrouter:openai/text-embedding-3-small', + embedding_dimensions: 4096, + }); + expect(got.ok).toBe(false); + if (!got.ok) expect(got.error).toMatch(/rejects custom dimensions 4096|does not support custom dimensions/); + }); + test('unknown provider rejected with provider list hint', () => { const got = resolveSchemaEmbeddingDim({ embedding_model: 'notarealprovider:foo' }); expect(got.ok).toBe(false);