diff --git a/src/core/ai/recipes/index.ts b/src/core/ai/recipes/index.ts index 098691b46..2e6954089 100644 --- a/src/core/ai/recipes/index.ts +++ b/src/core/ai/recipes/index.ts @@ -24,6 +24,7 @@ import { azureOpenAI } from './azure-openai.ts'; import { zeroentropyai } from './zeroentropyai.ts'; import { llamaServerReranker } from './llama-server-reranker.ts'; import { moonshot } from './moonshot.ts'; +import { mistral } from './mistral.ts'; const ALL: Recipe[] = [ openai, @@ -44,6 +45,7 @@ const ALL: Recipe[] = [ azureOpenAI, zeroentropyai, moonshot, + mistral, ]; /** Map from `provider:id` key to recipe. */ diff --git a/src/core/ai/recipes/mistral.ts b/src/core/ai/recipes/mistral.ts new file mode 100644 index 000000000..c22cce549 --- /dev/null +++ b/src/core/ai/recipes/mistral.ts @@ -0,0 +1,84 @@ +import type { Recipe } from '../types.ts'; + +/** + * Mistral AI exposes an OpenAI-compatible API at https://api.mistral.ai/v1 + * (/embeddings + /chat/completions). EU-hosted — the reason this recipe + * exists: a brain that must stay inside EU jurisdiction can run embed + + * expansion + chat on a single provider without a US hop. + * + * Verified against the live API on 2026-07-19 (model catalog, embedding + * dimensions, dimension-parameter rejection, and the batch ceiling — see + * the notes on each field below). + * + * DIMENSIONS — mistral-embed is FIXED 1024 and accepts NO dimension + * parameter at all. Both spellings are rejected upstream: + * {"dimensions": 512} -> 400 extra_forbidden (not in the API schema) + * {"output_dimension": 512} -> 400 "This model does not support output_dimension" + * The generic `openai-compatible` branch of dims.ts:dimsProviderOptions() + * already falls through to `return undefined` for these model ids, so no + * dimension field is emitted. Do NOT add mistral-embed to any of the + * flexible-dim allowlists there — it would 400 every embed call. Same + * contract as voyage-4-nano, for the same reason. + * + * codestral-embed / codestral-embed-2505 are deliberately NOT listed: they + * return 1536 dims, and a touchpoint carries a single `default_dims`. + * Mixing them under a 1024 declaration is the mixed-dim footgun + * embedding-dim-check.ts exists to catch. They are code-retrieval models + * anyway; a prose brain wants mistral-embed. + */ +export const mistral: Recipe = { + id: 'mistral', + name: 'Mistral AI', + tier: 'openai-compat', + implementation: 'openai-compatible', + base_url_default: 'https://api.mistral.ai/v1', + auth_env: { + required: ['MISTRAL_API_KEY'], + setup_url: 'https://console.mistral.ai/api-keys', + }, + touchpoints: { + embedding: { + models: ['mistral-embed', 'mistral-embed-2312'], + default_dims: 1024, + // Mistral's published list price. Advisory only — canonical embedding + // spend accounting lives in src/core/embedding-pricing.ts. + cost_per_1m_tokens_usd: 0.1, + price_last_verified: '2026-07-19', + // Measured ceiling, not a doc guess: the /embeddings endpoint accepts a + // 65,286-token batch and rejects 66,960 with + // 400 code 3210 "Too many tokens overall, split into more batches." + // -> the real cap is 65,536 (64K) tokens per request. + max_batch_tokens: 65_536, + // chars_per_token is a DIVISOR in splitByTokenBudget() + // (estTokens = text.length / charsPerToken), so a LOWER value is the + // conservative direction. The module default of 4 is an English-prose + // assumption; German prose measured 3.58 here, and code/JSON/CJK runs + // denser still. 2 keeps the estimate above the real token count for + // every content shape we see. + chars_per_token: 2, + // With safety_factor 0.5 the pre-split budget is 32,768 estimated + // tokens = 65,536 chars. Worst realistic density (~1.5 chars/token) + // puts that at ~43.7K real tokens — still clear of the 64K ceiling. + safety_factor: 0.5, + }, + expansion: { + models: ['ministral-3b-latest', 'mistral-small-latest'], + price_last_verified: '2026-07-19', + }, + chat: { + models: [ + 'mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest', + 'ministral-3b-latest', 'ministral-8b-latest', 'magistral-small-latest', + ], + supports_tools: true, + // Same call as the Moonshot recipe: ordinary tool calls are fine, but + // gbrain's subagent loop stays Anthropic-pinned for stable tool_use_id + // behavior across crashes/replays. + supports_subagent_loop: false, + supports_prompt_cache: false, + max_context_tokens: 262144, + price_last_verified: '2026-07-19', + }, + }, + setup_hint: 'Get an API key at https://console.mistral.ai/api-keys, then `export MISTRAL_API_KEY=...` and use `mistral:mistral-embed` (1024 dims) for embeddings.', +}; diff --git a/src/core/embedding-pricing.ts b/src/core/embedding-pricing.ts index 1bb37375c..18774727d 100644 --- a/src/core/embedding-pricing.ts +++ b/src/core/embedding-pricing.ts @@ -37,6 +37,9 @@ export const EMBEDDING_PRICING: Record = { 'voyage:voyage-4-large': { pricePerMTok: 0.18 }, // ZeroEntropy (https://zeroentropy.dev/pricing — zembed-1) 'zeroentropyai:zembed-1': { pricePerMTok: 0.05 }, + // Mistral (https://mistral.ai/pricing/api/, verified 2026-07-19) + 'mistral:mistral-embed': { pricePerMTok: 0.10 }, + 'mistral:mistral-embed-2312': { pricePerMTok: 0.10 }, }; export type PriceLookupResult = diff --git a/test/ai/recipe-mistral.test.ts b/test/ai/recipe-mistral.test.ts new file mode 100644 index 000000000..80490cff6 --- /dev/null +++ b/test/ai/recipe-mistral.test.ts @@ -0,0 +1,85 @@ +/** + * Mistral recipe smoke. + * + * The load-bearing assertion here is the negative one: mistral-embed rejects + * every dimension parameter with HTTP 400, so dimsProviderOptions() must emit + * no dimension field for it. Same contract as voyage-4-nano, pinned the same + * way (see the negative regression assertion in test/ai/gateway.test.ts). + */ + +import { describe, expect, test } from 'bun:test'; +import { getRecipe } from '../../src/core/ai/recipes/index.ts'; +import { defaultResolveAuth } from '../../src/core/ai/gateway.ts'; +import { assertTouchpoint } from '../../src/core/ai/model-resolver.ts'; +import { AIConfigError } from '../../src/core/ai/errors.ts'; +import { dimsProviderOptions } from '../../src/core/ai/dims.ts'; +import { lookupEmbeddingPrice } from '../../src/core/embedding-pricing.ts'; + +describe('recipe: mistral', () => { + test('registered with expected OpenAI-compatible shape', () => { + const r = getRecipe('mistral'); + expect(r).toBeDefined(); + expect(r!.id).toBe('mistral'); + expect(r!.tier).toBe('openai-compat'); + expect(r!.implementation).toBe('openai-compatible'); + expect(r!.base_url_default).toBe('https://api.mistral.ai/v1'); + expect(r!.auth_env?.required).toEqual(['MISTRAL_API_KEY']); + }); + + test('embedding touchpoint pins the measured 1024 dims and 64K batch ceiling', () => { + const e = getRecipe('mistral')!.touchpoints.embedding; + expect(e).toBeDefined(); + expect(e!.models).toContain('mistral-embed'); + expect(e!.default_dims).toBe(1024); + // Measured: a 65,286-token batch is accepted, 66,960 returns 400 code 3210. + expect(e!.max_batch_tokens).toBe(65_536); + // chars_per_token is a DIVISOR in splitByTokenBudget(), so a lower value + // is the conservative direction. The module default of 4 is an English + // assumption and overshoots on denser prose. + expect(e!.chars_per_token).toBe(2); + }); + + test('NEGATIVE: no dimension parameter is emitted for mistral-embed', () => { + // Mistral rejects both spellings: + // {"dimensions": N} -> 400 extra_forbidden + // {"output_dimension": N} -> 400 "does not support output_dimension" + // If a future change adds mistral-embed to a flexible-dim allowlist in + // dims.ts, this assertion fails before it reaches users as a 400 on every + // embed call. + expect(dimsProviderOptions('openai-compatible', 'mistral-embed', 1024)).toBeUndefined(); + expect(dimsProviderOptions('openai-compatible', 'mistral-embed-2312', 1024)).toBeUndefined(); + }); + + test('embedding models resolve to a known price', () => { + // An unknown price makes the embedding spend cap fail closed. + expect(lookupEmbeddingPrice('mistral:mistral-embed').kind).toBe('known'); + expect(lookupEmbeddingPrice('mistral:mistral-embed-2312').kind).toBe('known'); + }); + + test('chat and expansion touchpoints accept their configured models', () => { + const r = getRecipe('mistral')!; + expect(r.touchpoints.chat!.supports_tools).toBe(true); + expect(r.touchpoints.chat!.supports_subagent_loop).toBe(false); + expect(() => assertTouchpoint(r, 'chat', 'mistral-small-latest')).not.toThrow(); + expect(() => assertTouchpoint(r, 'expansion', 'ministral-3b-latest')).not.toThrow(); + expect(() => assertTouchpoint(r, 'embedding', 'mistral-embed')).not.toThrow(); + }); + + test('codestral-embed is deliberately absent (1536 dims would mix under a 1024 declaration)', () => { + const e = getRecipe('mistral')!.touchpoints.embedding!; + expect(e.models).not.toContain('codestral-embed'); + expect(e.models).not.toContain('codestral-embed-2505'); + }); + + test('default auth: MISTRAL_API_KEY set -> Bearer token', () => { + const r = getRecipe('mistral')!; + const auth = defaultResolveAuth(r, { MISTRAL_API_KEY: 'fake-mistral-key' }, 'embedding'); + expect(auth.headerName).toBe('Authorization'); + expect(auth.token).toBe('Bearer fake-mistral-key'); + }); + + test('default auth: missing MISTRAL_API_KEY -> AIConfigError', () => { + const r = getRecipe('mistral')!; + expect(() => defaultResolveAuth(r, {}, 'embedding')).toThrow(AIConfigError); + }); +});