From eb9f1183ad30c5c0aa4e7c67f1bc88879808c43f Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 3 Mar 2026 11:33:17 +0000 Subject: [PATCH] refactor(ai): migrate mistral provider to conversations sdk --- package-lock.json | 29 +- packages/ai/README.md | 7 +- packages/ai/package.json | 2 +- packages/ai/scripts/generate-models.ts | 4 +- packages/ai/src/index.ts | 1 + packages/ai/src/models.generated.ts | 150 ++--- packages/ai/src/providers/mistral.ts | 572 ++++++++++++++++++ .../ai/src/providers/openai-completions.ts | 44 +- .../ai/src/providers/register-builtins.ts | 7 + packages/ai/src/types.ts | 3 +- packages/ai/src/utils/overflow.ts | 7 +- packages/ai/test/context-overflow.test.ts | 2 +- packages/ai/test/image-tool-result.test.ts | 4 +- ...nai-completions-tool-result-images.test.ts | 1 - packages/ai/test/stream.test.ts | 42 +- packages/ai/test/tokens.test.ts | 1 + packages/coding-agent/docs/custom-provider.md | 18 +- 17 files changed, 723 insertions(+), 171 deletions(-) create mode 100644 packages/ai/src/providers/mistral.ts diff --git a/package-lock.json b/package-lock.json index a32dc6a1..b3c37fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1797,23 +1797,15 @@ "link": true }, "node_modules/@mistralai/mistralai": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.10.0.tgz", - "integrity": "sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-1.14.1.tgz", + "integrity": "sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==", "dependencies": { - "zod": "^3.20.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.1" } }, - "node_modules/@mistralai/mistralai/node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, "node_modules/@napi-rs/canvas": { "version": "0.1.95", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.95.tgz", @@ -6360,6 +6352,7 @@ "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", @@ -7721,6 +7714,7 @@ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -7749,7 +7743,8 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -7867,6 +7862,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7963,6 +7959,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -8051,6 +8048,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8165,6 +8163,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8492,7 +8491,7 @@ "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", - "@mistralai/mistralai": "1.10.0", + "@mistralai/mistralai": "1.14.1", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", diff --git a/packages/ai/README.md b/packages/ai/README.md index 18b3f1c3..4fddee41 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -626,6 +626,7 @@ The library uses a registry of API implementations. Built-in APIs include: - **`google-generative-ai`**: Google Generative AI API (`streamGoogle`, `GoogleOptions`) - **`google-gemini-cli`**: Google Cloud Code Assist API (`streamGoogleGeminiCli`, `GoogleGeminiCliOptions`) - **`google-vertex`**: Google Vertex AI API (`streamGoogleVertex`, `GoogleVertexOptions`) +- **`mistral-conversations`**: Mistral Conversations API (`streamMistral`, `MistralOptions`) - **`openai-completions`**: OpenAI Chat Completions API (`streamOpenAICompletions`, `OpenAICompletionsOptions`) - **`openai-responses`**: OpenAI Responses API (`streamOpenAIResponses`, `OpenAIResponsesOptions`) - **`openai-codex-responses`**: OpenAI Codex Responses API (`streamOpenAICodexResponses`, `OpenAICodexResponsesOptions`) @@ -638,7 +639,8 @@ A **provider** offers models through a specific API. For example: - **Anthropic** models use the `anthropic-messages` API - **Google** models use the `google-generative-ai` API - **OpenAI** models use the `openai-responses` API -- **Mistral, xAI, Cerebras, Groq, etc.** models use the `openai-completions` API (OpenAI-compatible) +- **Mistral** models use the `mistral-conversations` API +- **xAI, Cerebras, Groq, etc.** models use the `openai-completions` API (OpenAI-compatible) ### Querying Providers and Models @@ -728,7 +730,7 @@ const response = await stream(ollamaModel, context, { ### OpenAI Compatibility Settings -The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for known providers (Cerebras, xAI, Mistral, Chutes, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. +The `openai-completions` API is implemented by many providers with minor differences. By default, the library auto-detects compatibility settings based on `baseUrl` for a small set of known OpenAI-compatible providers (Cerebras, xAI, Chutes, DeepSeek, zAi, OpenCode, etc.). For custom proxies or unknown endpoints, you can override these settings via the `compat` field. For `openai-responses` models, the compat field only supports Responses-specific flags. ```typescript interface OpenAICompletionsCompat { @@ -741,7 +743,6 @@ interface OpenAICompletionsCompat { requiresToolResultName?: boolean; // Whether tool results require the `name` field (default: false) requiresAssistantAfterToolResult?: boolean; // Whether tool results must be followed by an assistant message (default: false) requiresThinkingAsText?: boolean; // Whether thinking blocks must be converted to text (default: false) - requiresMistralToolIds?: boolean; // Whether tool call IDs must be normalized to Mistral format (default: false) thinkingFormat?: 'openai' | 'zai' | 'qwen'; // Format for reasoning param: 'openai' uses reasoning_effort, 'zai' uses thinking: { type: "enabled" }, 'qwen' uses enable_thinking: boolean (default: openai) openRouterRouting?: OpenRouterRouting; // OpenRouter routing preferences (default: {}) vercelGatewayRouting?: VercelGatewayRouting; // Vercel AI Gateway routing preferences (default: {}) diff --git a/packages/ai/package.json b/packages/ai/package.json index 07e38fcd..d344fc35 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -25,7 +25,7 @@ "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", - "@mistralai/mistralai": "1.10.0", + "@mistralai/mistralai": "1.14.1", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index ea51057d..4165ee59 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -414,9 +414,9 @@ async function loadModelsDevData(): Promise[]> { models.push({ id: modelId, name: m.name || modelId, - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: m.reasoning === true, input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], cost: { diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 48c74953..55c78de0 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -9,6 +9,7 @@ export * from "./providers/azure-openai-responses.js"; export * from "./providers/google.js"; export * from "./providers/google-gemini-cli.js"; export * from "./providers/google-vertex.js"; +export * from "./providers/mistral.js"; export * from "./providers/openai-completions.js"; export * from "./providers/openai-responses.js"; export * from "./providers/register-builtins.js"; diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 1acf2ca9..55b7dbfc 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -4485,9 +4485,9 @@ export const MODELS = { "codestral-latest": { id: "codestral-latest", name: "Codestral", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4498,13 +4498,13 @@ export const MODELS = { }, contextWindow: 256000, maxTokens: 4096, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "devstral-2512": { id: "devstral-2512", name: "Devstral 2", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4515,13 +4515,13 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 262144, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "devstral-medium-2507": { id: "devstral-medium-2507", name: "Devstral Medium", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4532,13 +4532,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "devstral-medium-latest": { id: "devstral-medium-latest", name: "Devstral 2", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4549,13 +4549,13 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 262144, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "devstral-small-2505": { id: "devstral-small-2505", name: "Devstral Small 2505", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4566,13 +4566,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "devstral-small-2507": { id: "devstral-small-2507", name: "Devstral Small", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4583,13 +4583,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "labs-devstral-small-2512": { id: "labs-devstral-small-2512", name: "Devstral Small 2", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4600,13 +4600,13 @@ export const MODELS = { }, contextWindow: 256000, maxTokens: 256000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "magistral-medium-latest": { id: "magistral-medium-latest", name: "Magistral Medium", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: true, input: ["text"], cost: { @@ -4617,13 +4617,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16384, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "magistral-small": { id: "magistral-small", name: "Magistral Small", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: true, input: ["text"], cost: { @@ -4634,13 +4634,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "ministral-3b-latest": { id: "ministral-3b-latest", name: "Ministral 3B", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4651,13 +4651,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "ministral-8b-latest": { id: "ministral-8b-latest", name: "Ministral 8B", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4668,13 +4668,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-large-2411": { id: "mistral-large-2411", name: "Mistral Large 2.1", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4685,13 +4685,13 @@ export const MODELS = { }, contextWindow: 131072, maxTokens: 16384, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-large-2512": { id: "mistral-large-2512", name: "Mistral Large 3", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4702,13 +4702,13 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 262144, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-large-latest": { id: "mistral-large-latest", name: "Mistral Large", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4719,13 +4719,13 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 262144, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-medium-2505": { id: "mistral-medium-2505", name: "Mistral Medium 3", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4736,13 +4736,13 @@ export const MODELS = { }, contextWindow: 131072, maxTokens: 131072, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-medium-2508": { id: "mistral-medium-2508", name: "Mistral Medium 3.1", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4753,13 +4753,13 @@ export const MODELS = { }, contextWindow: 262144, maxTokens: 262144, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-medium-latest": { id: "mistral-medium-latest", name: "Mistral Medium", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4770,13 +4770,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16384, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-nemo": { id: "mistral-nemo", name: "Mistral Nemo", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4787,13 +4787,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-small-2506": { id: "mistral-small-2506", name: "Mistral Small 3.2", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4804,13 +4804,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16384, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "mistral-small-latest": { id: "mistral-small-latest", name: "Mistral Small", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4821,13 +4821,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 16384, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "open-mistral-7b": { id: "open-mistral-7b", name: "Mistral 7B", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4838,13 +4838,13 @@ export const MODELS = { }, contextWindow: 8000, maxTokens: 8000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "open-mixtral-8x22b": { id: "open-mixtral-8x22b", name: "Mixtral 8x22B", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4855,13 +4855,13 @@ export const MODELS = { }, contextWindow: 64000, maxTokens: 64000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "open-mixtral-8x7b": { id: "open-mixtral-8x7b", name: "Mixtral 8x7B", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text"], cost: { @@ -4872,13 +4872,13 @@ export const MODELS = { }, contextWindow: 32000, maxTokens: 32000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "pixtral-12b": { id: "pixtral-12b", name: "Pixtral 12B", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4889,13 +4889,13 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, "pixtral-large-latest": { id: "pixtral-large-latest", name: "Pixtral Large", - api: "openai-completions", + api: "mistral-conversations", provider: "mistral", - baseUrl: "https://api.mistral.ai/v1", + baseUrl: "https://api.mistral.ai", reasoning: false, input: ["text", "image"], cost: { @@ -4906,7 +4906,7 @@ export const MODELS = { }, contextWindow: 128000, maxTokens: 128000, - } satisfies Model<"openai-completions">, + } satisfies Model<"mistral-conversations">, }, "openai": { "codex-mini-latest": { diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts new file mode 100644 index 00000000..0aff391f --- /dev/null +++ b/packages/ai/src/providers/mistral.ts @@ -0,0 +1,572 @@ +import { createHash } from "node:crypto"; +import { Mistral } from "@mistralai/mistralai"; +import type { RequestOptions } from "@mistralai/mistralai/lib/sdks.js"; +import type { + ChatCompletionStreamRequest, + ChatCompletionStreamRequestMessages, + CompletionEvent, + ContentChunk, + FunctionTool, +} from "@mistralai/mistralai/models/components/index.js"; +import { getEnvApiKey } from "../env-api-keys.js"; +import { calculateCost } from "../models.js"; +import type { + AssistantMessage, + Context, + Message, + Model, + SimpleStreamOptions, + StopReason, + StreamFunction, + StreamOptions, + TextContent, + ThinkingContent, + Tool, + ToolCall, +} from "../types.js"; +import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { parseStreamingJson } from "../utils/json-parse.js"; +import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; +import { buildBaseOptions, clampReasoning } from "./simple-options.js"; +import { transformMessages } from "./transform-messages.js"; + +const MISTRAL_TOOL_CALL_ID_LENGTH = 9; +const MAX_MISTRAL_ERROR_BODY_CHARS = 4000; + +/** + * Provider-specific options for the Mistral API. + */ +export interface MistralOptions extends StreamOptions { + toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } }; + promptMode?: "reasoning"; +} + +/** + * Stream responses from Mistral using `chat.stream`. + */ +export const streamMistral: StreamFunction<"mistral-conversations", MistralOptions> = ( + model: Model<"mistral-conversations">, + context: Context, + options?: MistralOptions, +): AssistantMessageEventStream => { + const stream = new AssistantMessageEventStream(); + + (async () => { + const output = createOutput(model); + + try { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + // Intentionally per-request: avoids shared SDK mutable state across concurrent consumers. + const mistral = new Mistral({ + apiKey, + serverURL: model.baseUrl, + }); + + const normalizeMistralToolCallId = createMistralToolCallIdNormalizer(); + const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id)); + + const payload = buildChatPayload(model, context, transformedMessages, options); + options?.onPayload?.(payload); + const mistralStream = await mistral.chat.stream(payload, buildRequestOptions(model, options)); + stream.push({ type: "start", partial: output }); + await consumeChatStream(model, output, stream, mistralStream); + + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + if (output.stopReason === "aborted" || output.stopReason === "error") { + throw new Error("An unknown error occurred"); + } + + stream.push({ type: "done", reason: output.stopReason, message: output }); + stream.end(); + } catch (error) { + output.stopReason = options?.signal?.aborted ? "aborted" : "error"; + output.errorMessage = formatMistralError(error); + stream.push({ type: "error", reason: output.stopReason, error: output }); + stream.end(); + } + })(); + + return stream; +}; + +/** + * Maps provider-agnostic `SimpleStreamOptions` to Mistral options. + */ +export const streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions> = ( + model: Model<"mistral-conversations">, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStream => { + const apiKey = options?.apiKey || getEnvApiKey(model.provider); + if (!apiKey) { + throw new Error(`No API key for provider: ${model.provider}`); + } + + const base = buildBaseOptions(model, options, apiKey); + const reasoning = clampReasoning(options?.reasoning); + + return streamMistral(model, context, { + ...base, + promptMode: model.reasoning && reasoning ? "reasoning" : undefined, + } satisfies MistralOptions); +}; + +function createOutput(model: Model<"mistral-conversations">): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +function createMistralToolCallIdNormalizer(): (id: string) => string { + const idMap = new Map(); + const reverseMap = new Map(); + + return (id: string): string => { + const existing = idMap.get(id); + if (existing) return existing; + + let attempt = 0; + while (true) { + const candidate = deriveMistralToolCallId(id, attempt); + const owner = reverseMap.get(candidate); + if (!owner || owner === id) { + idMap.set(id, candidate); + reverseMap.set(candidate, id); + return candidate; + } + attempt++; + } + }; +} + +function deriveMistralToolCallId(id: string, attempt: number): string { + const normalized = id.replace(/[^a-zA-Z0-9]/g, ""); + if (attempt === 0 && normalized.length === MISTRAL_TOOL_CALL_ID_LENGTH) return normalized; + const seedBase = normalized || id; + const seed = attempt === 0 ? seedBase : `${seedBase}:${attempt}`; + return createHash("sha256").update(seed).digest("hex").slice(0, MISTRAL_TOOL_CALL_ID_LENGTH); +} + +function formatMistralError(error: unknown): string { + if (error instanceof Error) { + const sdkError = error as Error & { statusCode?: unknown; body?: unknown }; + const statusCode = typeof sdkError.statusCode === "number" ? sdkError.statusCode : undefined; + const bodyText = typeof sdkError.body === "string" ? sdkError.body.trim() : undefined; + if (statusCode !== undefined && bodyText) { + return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`; + } + if (statusCode !== undefined) return `Mistral API error (${statusCode}): ${error.message}`; + return error.message; + } + return safeJsonStringify(error); +} + +function truncateErrorText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`; +} + +function safeJsonStringify(value: unknown): string { + try { + const serialized = JSON.stringify(value); + return serialized === undefined ? String(value) : serialized; + } catch { + return String(value); + } +} + +function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions): RequestOptions { + const requestOptions: RequestOptions = {}; + if (options?.signal) requestOptions.signal = options.signal; + requestOptions.retries = { strategy: "none" }; + + const headers: Record = {}; + if (model.headers) Object.assign(headers, model.headers); + if (options?.headers) Object.assign(headers, options.headers); + + // Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching). + // Respect explicit caller-provided header values. + if (options?.sessionId && !headers["x-affinity"]) { + headers["x-affinity"] = options.sessionId; + } + + if (Object.keys(headers).length > 0) { + requestOptions.headers = headers; + } + + return requestOptions; +} + +function buildChatPayload( + model: Model<"mistral-conversations">, + context: Context, + messages: Message[], + options?: MistralOptions, +): ChatCompletionStreamRequest { + const payload: ChatCompletionStreamRequest = { + model: model.id, + stream: true, + messages: toChatMessages(messages, model.input.includes("image")), + }; + + if (context.tools?.length) payload.tools = toFunctionTools(context.tools); + if (options?.temperature !== undefined) payload.temperature = options.temperature; + if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens; + if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice); + if (options?.promptMode) payload.promptMode = options.promptMode as any; + + if (context.systemPrompt) { + payload.messages.unshift({ + role: "system", + content: sanitizeSurrogates(context.systemPrompt), + }); + } + + return payload; +} + +async function consumeChatStream( + model: Model<"mistral-conversations">, + output: AssistantMessage, + stream: AssistantMessageEventStream, + mistralStream: AsyncIterable, +): Promise { + let currentBlock: TextContent | ThinkingContent | null = null; + const blocks = output.content; + const blockIndex = () => blocks.length - 1; + const toolBlocksByKey = new Map(); + + const finishCurrentBlock = (block?: typeof currentBlock) => { + if (!block) return; + if (block.type === "text") { + stream.push({ + type: "text_end", + contentIndex: blockIndex(), + content: block.text, + partial: output, + }); + return; + } + if (block.type === "thinking") { + stream.push({ + type: "thinking_end", + contentIndex: blockIndex(), + content: block.thinking, + partial: output, + }); + } + }; + + for await (const event of mistralStream) { + const chunk = event.data; + + if (chunk.usage) { + output.usage.input = chunk.usage.promptTokens || 0; + output.usage.output = chunk.usage.completionTokens || 0; + output.usage.cacheRead = 0; + output.usage.cacheWrite = 0; + output.usage.totalTokens = chunk.usage.totalTokens || output.usage.input + output.usage.output; + calculateCost(model, output.usage); + } + + const choice = chunk.choices[0]; + if (!choice) continue; + + if (choice.finishReason) { + output.stopReason = mapChatStopReason(choice.finishReason); + } + + const delta = choice.delta; + if (delta.content !== null && delta.content !== undefined) { + const contentItems = typeof delta.content === "string" ? [delta.content] : delta.content; + for (const item of contentItems) { + if (typeof item === "string") { + const textDelta = sanitizeSurrogates(item); + if (!currentBlock || currentBlock.type !== "text") { + finishCurrentBlock(currentBlock); + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.text += textDelta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: textDelta, + partial: output, + }); + continue; + } + + if (item.type === "thinking") { + const deltaText = item.thinking + .map((part) => ("text" in part ? part.text : "")) + .filter((text) => text.length > 0) + .join(""); + const thinkingDelta = sanitizeSurrogates(deltaText); + if (!thinkingDelta) continue; + if (!currentBlock || currentBlock.type !== "thinking") { + finishCurrentBlock(currentBlock); + currentBlock = { type: "thinking", thinking: "" }; + output.content.push(currentBlock); + stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.thinking += thinkingDelta; + stream.push({ + type: "thinking_delta", + contentIndex: blockIndex(), + delta: thinkingDelta, + partial: output, + }); + continue; + } + + if (item.type === "text") { + const textDelta = sanitizeSurrogates(item.text); + if (!currentBlock || currentBlock.type !== "text") { + finishCurrentBlock(currentBlock); + currentBlock = { type: "text", text: "" }; + output.content.push(currentBlock); + stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output }); + } + currentBlock.text += textDelta; + stream.push({ + type: "text_delta", + contentIndex: blockIndex(), + delta: textDelta, + partial: output, + }); + } + } + } + + const toolCalls = delta.toolCalls || []; + for (const toolCall of toolCalls) { + if (currentBlock) { + finishCurrentBlock(currentBlock); + currentBlock = null; + } + const callId = + toolCall.id && toolCall.id !== "null" + ? toolCall.id + : deriveMistralToolCallId(`toolcall:${toolCall.index ?? 0}`, 0); + const key = `${callId}:${toolCall.index || 0}`; + const existingIndex = toolBlocksByKey.get(key); + let block: (ToolCall & { partialArgs?: string }) | undefined; + + if (existingIndex !== undefined) { + const existing = output.content[existingIndex]; + if (existing?.type === "toolCall") { + block = existing as ToolCall & { partialArgs?: string }; + } + } + + if (!block) { + block = { + type: "toolCall", + id: callId, + name: toolCall.function.name, + arguments: {}, + partialArgs: "", + }; + output.content.push(block); + toolBlocksByKey.set(key, output.content.length - 1); + stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output }); + } + + const argsDelta = + typeof toolCall.function.arguments === "string" + ? toolCall.function.arguments + : JSON.stringify(toolCall.function.arguments || {}); + block.partialArgs = (block.partialArgs || "") + argsDelta; + block.arguments = parseStreamingJson>(block.partialArgs); + stream.push({ + type: "toolcall_delta", + contentIndex: toolBlocksByKey.get(key)!, + delta: argsDelta, + partial: output, + }); + } + } + + finishCurrentBlock(currentBlock); + for (const index of toolBlocksByKey.values()) { + const block = output.content[index]; + if (block.type !== "toolCall") continue; + const toolBlock = block as ToolCall & { partialArgs?: string }; + toolBlock.arguments = parseStreamingJson>(toolBlock.partialArgs); + delete toolBlock.partialArgs; + stream.push({ + type: "toolcall_end", + contentIndex: index, + toolCall: toolBlock, + partial: output, + }); + } +} + +function toFunctionTools(tools: Tool[]): Array { + return tools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters as unknown as Record, + strict: false, + }, + })); +} + +function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessages[] { + const result: ChatCompletionStreamRequestMessages[] = []; + + for (const msg of messages) { + if (msg.role === "user") { + if (typeof msg.content === "string") { + result.push({ role: "user", content: sanitizeSurrogates(msg.content) }); + continue; + } + const hadImages = msg.content.some((item) => item.type === "image"); + const content: ContentChunk[] = msg.content + .filter((item) => item.type === "text" || supportsImages) + .map((item) => { + if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) }; + return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` }; + }); + if (content.length > 0) { + result.push({ role: "user", content }); + continue; + } + if (hadImages && !supportsImages) { + result.push({ role: "user", content: "(image omitted: model does not support images)" }); + } + continue; + } + + if (msg.role === "assistant") { + const textParts: Array<{ type: "text"; text: string }> = []; + const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = []; + + for (const block of msg.content) { + if (block.type === "text") { + if (block.text.trim().length > 0) textParts.push({ type: "text", text: sanitizeSurrogates(block.text) }); + continue; + } + if (block.type === "thinking") { + if (block.thinking.trim().length > 0) { + textParts.push({ type: "text", text: sanitizeSurrogates(block.thinking) }); + } + continue; + } + toolCalls.push({ + id: block.id, + type: "function", + function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) }, + }); + } + + const assistantMessage: ChatCompletionStreamRequestMessages = { role: "assistant" }; + if (textParts.length > 0) assistantMessage.content = textParts; + if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls; + if (textParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage); + continue; + } + + const toolContent: ContentChunk[] = []; + const textResult = msg.content + .filter((part) => part.type === "text") + .map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : "")) + .join("\n"); + const hasImages = msg.content.some((part) => part.type === "image"); + const toolText = buildToolResultText(textResult, hasImages, supportsImages, msg.isError); + toolContent.push({ type: "text", text: toolText }); + for (const part of msg.content) { + if (!supportsImages) continue; + if (part.type !== "image") continue; + toolContent.push({ + type: "image_url", + imageUrl: `data:${part.mimeType};base64,${part.data}`, + }); + } + result.push({ + role: "tool", + toolCallId: msg.toolCallId, + name: msg.toolName, + content: toolContent, + }); + } + + return result; +} + +function buildToolResultText(text: string, hasImages: boolean, supportsImages: boolean, isError: boolean): string { + const trimmed = text.trim(); + const errorPrefix = isError ? "[tool error] " : ""; + + if (trimmed.length > 0) { + const imageSuffix = hasImages && !supportsImages ? "\n[tool image omitted: model does not support images]" : ""; + return `${errorPrefix}${trimmed}${imageSuffix}`; + } + + if (hasImages) { + if (supportsImages) { + return isError ? "[tool error] (see attached image)" : "(see attached image)"; + } + return isError + ? "[tool error] (image omitted: model does not support images)" + : "(image omitted: model does not support images)"; + } + + return isError ? "[tool error] (no tool output)" : "(no tool output)"; +} + +function mapToolChoice( + choice: MistralOptions["toolChoice"], +): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined { + if (!choice) return undefined; + if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") { + return choice as any; + } + return { + type: "function", + function: { name: choice.function.name }, + }; +} + +function mapChatStopReason(reason: string | null): StopReason { + if (reason === null) return "stop"; + switch (reason) { + case "stop": + return "stop"; + case "length": + case "model_length": + return "length"; + case "tool_calls": + return "toolUse"; + case "error": + return "error"; + default: + return "stop"; + } +} diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 3cd370cd..58ac9b36 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -33,24 +33,6 @@ import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copi import { buildBaseOptions, clampReasoning } from "./simple-options.js"; import { transformMessages } from "./transform-messages.js"; -/** - * Normalize tool call ID for Mistral. - * Mistral requires tool IDs to be exactly 9 alphanumeric characters (a-z, A-Z, 0-9). - */ -function normalizeMistralToolId(id: string): string { - // Remove non-alphanumeric characters - let normalized = id.replace(/[^a-zA-Z0-9]/g, ""); - // Mistral requires exactly 9 characters - if (normalized.length < 9) { - // Pad with deterministic characters based on original ID to ensure matching - const padding = "ABCDEFGHI"; - normalized = normalized + padding.slice(0, 9 - normalized.length); - } else if (normalized.length > 9) { - normalized = normalized.slice(0, 9); - } - return normalized; -} - /** * Check if conversation messages contain tool calls or tool results. * This is needed because Anthropic (via proxy) requires the tools param @@ -296,7 +278,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA } finishCurrentBlock(currentBlock); - if (options?.signal?.aborted) { throw new Error("Request was aborted"); } @@ -498,8 +479,6 @@ export function convertMessages( const params: ChatCompletionMessageParam[] = []; const normalizeToolCallId = (id: string): string => { - if (compat.requiresMistralToolIds) return normalizeMistralToolId(id); - // Handle pipe-separated IDs from OpenAI Responses API // Format: {call_id}|{id} where {id} can be 400+ chars with special chars (+, /, =) // These come from providers like github-copilot, openai-codex, opencode @@ -526,7 +505,7 @@ export function convertMessages( for (let i = 0; i < transformedMessages.length; i++) { const msg = transformedMessages[i]; - // Some providers (e.g. Mistral/Devstral) don't allow user messages directly after tool results + // Some providers don't allow user messages directly after tool results // Insert a synthetic assistant message to bridge the gap if (compat.requiresAssistantAfterToolResult && lastRole === "toolResult" && msg.role === "user") { params.push({ @@ -567,7 +546,7 @@ export function convertMessages( }); } } else if (msg.role === "assistant") { - // Some providers (e.g. Mistral) don't accept null content, use empty string instead + // Some providers don't accept null content, use empty string instead const assistantMsg: ChatCompletionAssistantMessageParam = { role: "assistant", content: compat.requiresAssistantAfterToolResult ? "" : null, @@ -636,7 +615,7 @@ export function convertMessages( } } // Skip assistant messages that have no content and no tool calls. - // Mistral explicitly requires "either content or tool_calls, but not none". + // Some providers require "either content or tool_calls, but not none". // Other providers also don't accept empty assistant messages. // This handles aborted assistant responses that got no content. const content = assistantMsg.content; @@ -664,7 +643,7 @@ export function convertMessages( // Always send tool result with text (or placeholder if only images) const hasText = textResult.length > 0; - // Some providers (e.g. Mistral) require the 'name' field in tool results + // Some providers require the 'name' field in tool results const toolResultMsg: ChatCompletionToolMessageParam = { role: "tool", content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"), @@ -773,21 +752,17 @@ function detectCompat(model: Model<"openai-completions">): Required): Required): Required): Required delimiters. Default: auto-detected from URL. */ requiresThinkingAsText?: boolean; - /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */ - requiresMistralToolIds?: boolean; /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses enable_thinking: boolean. Default: "openai". */ thinkingFormat?: "openai" | "zai" | "qwen"; /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index 507a9a7c..6ba1a162 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -20,7 +20,7 @@ import type { AssistantMessage } from "../types.js"; * - MiniMax: "invalid params, context window exceeds limit" * - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)" * - Cerebras: Returns "400/413 status code (no body)" - handled separately below - * - Mistral: Returns "400/413 status code (no body)" - handled separately below + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" * - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow * - Ollama: Silently truncates input - not detectable via error message */ @@ -37,6 +37,7 @@ const OVERFLOW_PATTERNS = [ /greater than the context length/i, // LM Studio /context window exceeds limit/i, // MiniMax /exceeded model token limit/i, // Kimi For Coding + /too large for model with \d+ maximum context length/i, // Mistral /context[_ ]length[_ ]exceeded/i, // Generic fallback /too many tokens/i, // Generic fallback /token limit exceeded/i, // Generic fallback @@ -60,7 +61,7 @@ const OVERFLOW_PATTERNS = [ * - xAI (Grok): "maximum prompt length is X but request contains Y" * - Groq: "reduce the length of the messages" * - Cerebras: 400/413 status code (no body) - * - Mistral: 400/413 status code (no body) + * - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length" * - OpenRouter (all backends): "maximum context length is X tokens" * - llama.cpp: "exceeds the available context size" * - LM Studio: "greater than the context length" @@ -95,7 +96,7 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num return true; } - // Cerebras and Mistral return 400/413 with no body for context overflow + // Cerebras returns 400/413 with no body for context overflow // Note: 429 is rate limiting (requests/tokens per time), NOT context overflow if (/^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message.errorMessage)) { return true; diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 3cf5da1e..cca56f11 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -413,7 +413,6 @@ describe("Context overflow error handling", () => { // ============================================================================= // Mistral - // Expected pattern: TBD - need to test actual error message // ============================================================================= describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral", () => { @@ -423,6 +422,7 @@ describe("Context overflow error handling", () => { logResult(result); expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toMatch(/too large for model with \d+ maximum context length/i); expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); }, 120000); }); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index 124674f9..dfb1215c 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -291,11 +291,11 @@ describe("Tool Results with Images", () => { describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider (pixtral-12b)", () => { const llm = getModel("mistral", "pixtral-12b"); - it("should handle tool result with only image", { retry: 3, timeout: 30000 }, async () => { + it("should handle tool result with only image", { retry: 5, timeout: 30000 }, async () => { await handleToolWithImageResult(llm); }); - it("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { + it("should handle tool result with text and image", { retry: 5, timeout: 30000 }, async () => { await handleToolWithTextAndImageResult(llm); }); }); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 1e06746a..5161a139 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -29,7 +29,6 @@ const compat: Required = { requiresToolResultName: false, requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, - requiresMistralToolIds: false, thinkingFormat: "openai", openRouterRouting: {}, vercelGatewayRouting: {}, diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index eea71ba4..af00ffd7 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -765,34 +765,30 @@ describe("Generate E2E Tests", () => { }); }); - describe.skipIf(!process.env.MISTRAL_API_KEY)( - "Mistral Provider (devstral-medium-latest via OpenAI Completions)", - () => { - const llm = getModel("mistral", "devstral-medium-latest"); + describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider (devstral-medium-latest)", () => { + const llm = getModel("mistral", "devstral-medium-latest"); - it("should complete basic text generation", { retry: 3 }, async () => { - await basicTextGeneration(llm); - }); + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); - it("should handle tool calling", { retry: 3 }, async () => { - await handleToolCall(llm); - }); + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); - it("should handle streaming", { retry: 3 }, async () => { - await handleStreaming(llm); - }); + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); - it("should handle thinking mode", { retry: 3 }, async () => { - // FIXME Skip for now, getting a 422 status code, need to test with official SDK - // const llm = getModel("mistral", "magistral-medium-latest"); - // await handleThinking(llm, { reasoningEffort: "medium" }); - }); + it("should handle thinking mode", { retry: 3 }, async () => { + const llm = getModel("mistral", "magistral-medium-latest"); + await handleThinking(llm, { reasoningEffort: "medium" }); + }); - it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - await multiTurn(llm, { reasoningEffort: "medium" }); - }); - }, - ); + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { reasoningEffort: "medium" }); + }); + }); describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider (pixtral-12b with image support)", () => { const llm = getModel("mistral", "pixtral-12b"); diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 2c3ea6eb..27dccaf1 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -55,6 +55,7 @@ async function testTokensOnAbort(llm: Model, options: St // MiniMax reports input tokens but not output tokens when aborted. if ( llm.api === "openai-completions" || + llm.api === "mistral-conversations" || llm.api === "openai-responses" || llm.api === "azure-openai-responses" || llm.api === "openai-codex-responses" || diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 8556e96e..f6a5b7c4 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -159,6 +159,7 @@ The `api` field determines which streaming implementation is used: | `openai-responses` | OpenAI Responses API | | `azure-openai-responses` | Azure OpenAI Responses API | | `openai-codex-responses` | OpenAI Codex Responses API | +| `mistral-conversations` | Mistral SDK Conversations/Chat streaming | | `google-generative-ai` | Google Generative AI API | | `google-gemini-cli` | Google Cloud Code Assist API | | `google-vertex` | Google Vertex AI API | @@ -180,14 +181,17 @@ models: [{ high: "default", xhigh: "default" }, - maxTokensField: "max_tokens", // instead of "max_completion_tokens" - requiresToolResultName: true, // tool results need name field - requiresMistralToolIds: true, - thinkingFormat: "qwen" // uses enable_thinking: true - } -}] + maxTokensField: "max_tokens", // instead of "max_completion_tokens" + requiresToolResultName: true, // tool results need name field + thinkingFormat: "qwen" // uses enable_thinking: true + } + }] ``` +> Migration note: Mistral moved from `openai-completions` to `mistral-conversations`. +> Use `mistral-conversations` for native Mistral models. +> If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed. + ### Auth Header If your provider expects `Authorization: Bearer ` but doesn't use a standard API, set `authHeader: true`: @@ -301,6 +305,7 @@ For providers with non-standard APIs, implement `streamSimple`. Study the existi **Reference implementations:** - [anthropic.ts](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts) - Anthropic Messages API +- [mistral.ts](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/mistral.ts) - Mistral Conversations API - [openai-completions.ts](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/openai-completions.ts) - OpenAI Chat Completions - [openai-responses.ts](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/openai-responses.ts) - OpenAI Responses API - [google.ts](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/google.ts) - Google Generative AI @@ -581,7 +586,6 @@ interface ProviderModelConfig { requiresToolResultName?: boolean; requiresAssistantAfterToolResult?: boolean; requiresThinkingAsText?: boolean; - requiresMistralToolIds?: boolean; thinkingFormat?: "openai" | "zai" | "qwen"; }; }