fix(ai): replace Mistral SDK with native transport

This commit is contained in:
Mario Zechner 2026-08-08 09:31:50 +02:00
parent e47b8e37a6
commit 9dd90a4971
12 changed files with 785 additions and 257 deletions

39
package-lock.json generated
View file

@ -1495,26 +1495,6 @@
"node": ">= 10"
}
},
"node_modules/@mistralai/mistralai": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz",
"integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.40.0",
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@ -1593,15 +1573,6 @@
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
@ -5415,15 +5386,6 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
},
"packages/agent": {
"name": "@earendil-works/pi-agent-core",
"version": "0.84.1",
@ -5479,7 +5441,6 @@
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.1",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",

View file

@ -5,6 +5,7 @@
### Changed
- Changed OpenAI Responses deferred tool loading to prefer message-anchored `additional_tools` where supported while retaining tool-search and top-level fallbacks ([#7709](https://github.com/earendil-works/pi/issues/7709)).
- Replaced the Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead.
### Fixed

View file

@ -64,7 +64,6 @@
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.1",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",

View file

@ -1,11 +1,3 @@
import { HTTPClient, Mistral } from "@mistralai/mistralai";
import type {
ChatCompletionStreamRequest,
ChatCompletionStreamRequestMessage,
CompletionEvent,
ContentChunk,
FunctionTool,
} from "@mistralai/mistralai/models/components";
import { calculateCost, clampThinkingLevel } from "../models.ts";
import type {
AssistantMessage,
@ -23,6 +15,7 @@ import type {
} from "../types.ts";
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
import { shortHash } from "../utils/hash.ts";
import { headersToRecord } from "../utils/headers.ts";
import { parseStreamingJson } from "../utils/json-parse.ts";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
import { resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
@ -43,8 +36,87 @@ export interface MistralOptions extends StreamOptions {
reasoningEffort?: MistralReasoningEffort;
}
type MistralContentChunk =
| { type: "text"; text: string }
| { type: "image_url"; imageUrl: string }
| { type: "thinking"; thinking: Array<{ type: "text"; text: string }> };
type MistralRequestToolCall = {
id: string;
type: "function";
function: { name: string; arguments: string };
index: number;
};
type MistralChatMessage = {
role: "system" | "user" | "assistant" | "tool";
content?: string | MistralContentChunk[];
toolCalls?: MistralRequestToolCall[];
toolCallId?: string;
name?: string;
prefix?: boolean;
};
type MistralFunctionTool = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
strict: boolean;
};
};
type MistralChatPayload = {
[key: string]: unknown;
model: string;
stream: boolean;
messages: MistralChatMessage[];
tools?: MistralFunctionTool[];
temperature?: number;
maxTokens?: number;
toolChoice?: Exclude<MistralOptions["toolChoice"], undefined>;
promptMode?: "reasoning";
reasoningEffort?: MistralReasoningEffort;
promptCacheKey?: string;
};
type MistralStreamContentChunk = {
type: string;
text?: string;
thinking?: Array<{ text?: string }>;
};
type MistralStreamToolCall = {
id?: string;
index?: number;
function: {
name: string;
arguments: string | Record<string, unknown>;
};
};
type MistralCompletionEvent = {
data: {
id?: string;
usage?: {
[key: string]: unknown;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
choices: Array<{
finish_reason?: string | null;
delta: {
content?: string | MistralStreamContentChunk[] | null;
tool_calls?: MistralStreamToolCall[] | null;
};
}>;
};
};
/**
* Stream responses from Mistral using `chat.stream`.
* Stream responses from the native Mistral Chat Completions endpoint.
*/
export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
model: Model<"mistral-conversations">,
@ -62,22 +134,15 @@ export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
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,
...(options?.fetch ? { httpClient: new HTTPClient({ fetcher: options.fetch }) } : {}),
});
const normalizeMistralToolCallId = createMistralToolCallIdNormalizer();
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id));
let payload = buildChatPayload(model, context, transformedMessages, options);
const nextPayload = await options?.onPayload?.(payload, model);
if (nextPayload !== undefined) {
payload = nextPayload as ChatCompletionStreamRequest;
payload = nextPayload as MistralChatPayload;
}
const mistralStream = await mistral.chat.stream(payload, buildRequestOptions(model, options));
const mistralStream = await requestMistralStream(model, payload, apiKey, options);
stream.push({ type: "start", partial: output });
await consumeChatStream(model, output, stream, mistralStream);
@ -189,9 +254,9 @@ function deriveMistralToolCallId(id: string, attempt: number): string {
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;
const httpError = error as Error & { statusCode?: unknown; body?: unknown };
const statusCode = typeof httpError.statusCode === "number" ? httpError.statusCode : undefined;
const bodyText = typeof httpError.body === "string" ? httpError.body.trim() : undefined;
if (statusCode !== undefined && bodyText) {
return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`;
}
@ -215,31 +280,219 @@ function safeJsonStringify(value: unknown): string {
}
}
function buildRequestOptions(model: Model<"mistral-conversations">, options?: MistralOptions) {
const requestOptions: {
signal?: AbortSignal;
retries: { strategy: "none" };
headers?: Record<string, string>;
} = {
retries: { strategy: "none" },
async function requestMistralStream(
model: Model<"mistral-conversations">,
payload: MistralChatPayload,
apiKey: string,
options?: MistralOptions,
): Promise<AsyncIterable<MistralCompletionEvent>> {
const baseUrl = new URL(model.baseUrl);
baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/u, "")}/`;
const url = new URL("v1/chat/completions", baseUrl);
const headers = buildMistralHeaders(model, apiKey, options);
const timeoutSignal = AbortSignal.timeout(options?.timeoutMs ?? 60_000);
const signal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
const response = await (options?.fetch ?? globalThis.fetch)(url, {
method: "POST",
headers,
body: JSON.stringify(toMistralWirePayload(payload)),
signal,
});
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
if (!response.ok) {
const body = await response.text();
throw new MistralHttpError(response.status, body, response.statusText);
}
if (!response.body) {
throw new Error("Mistral response has no body");
}
return readMistralEvents(response.body, signal);
}
class MistralHttpError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, body: string, statusText: string) {
super(statusText || `Request failed with status ${statusCode}`);
this.name = "MistralHttpError";
this.statusCode = statusCode;
this.body = body;
}
}
function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers {
const headers = new Headers({
accept: "text/event-stream",
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
});
applyMistralHeaderOverrides(headers, model.headers);
applyMistralHeaderOverrides(headers, options?.headers);
const hasExplicitAffinity =
hasMistralHeaderOverride(model.headers, "x-affinity") || hasMistralHeaderOverride(options?.headers, "x-affinity");
if (shouldUsePromptCaching(options) && !hasExplicitAffinity) {
headers.set("x-affinity", options.sessionId);
}
return headers;
}
function applyMistralHeaderOverrides(headers: Headers, overrides?: Record<string, string | null>): void {
if (!overrides) return;
for (const [name, value] of Object.entries(overrides)) {
if (value === null) headers.delete(name);
else headers.set(name, value);
}
}
function hasMistralHeaderOverride(overrides: Record<string, string | null> | undefined, target: string): boolean {
return !!overrides && Object.keys(overrides).some((name) => name.toLowerCase() === target);
}
function toMistralWirePayload(payload: MistralChatPayload): Record<string, unknown> {
const wirePayload: Record<string, unknown> = { ...payload };
for (const [source, target] of [
["topP", "top_p"],
["maxTokens", "max_tokens"],
["randomSeed", "random_seed"],
["responseFormat", "response_format"],
["toolChoice", "tool_choice"],
["presencePenalty", "presence_penalty"],
["frequencyPenalty", "frequency_penalty"],
["parallelToolCalls", "parallel_tool_calls"],
["reasoningEffort", "reasoning_effort"],
["promptMode", "prompt_mode"],
["promptCacheKey", "prompt_cache_key"],
["safePrompt", "safe_prompt"],
] as const) {
remapMistralProperty(wirePayload, source, target);
}
wirePayload.messages = payload.messages.map((message) => toMistralWireMessage(message));
const responseFormat = wirePayload.response_format;
if (isMistralRecord(responseFormat)) {
const wireResponseFormat = { ...responseFormat };
remapMistralProperty(wireResponseFormat, "jsonSchema", "json_schema");
const jsonSchema = wireResponseFormat.json_schema;
if (isMistralRecord(jsonSchema)) {
const wireJsonSchema = { ...jsonSchema };
remapMistralProperty(wireJsonSchema, "schemaDefinition", "schema");
wireResponseFormat.json_schema = wireJsonSchema;
}
wirePayload.response_format = wireResponseFormat;
}
return wirePayload;
}
function toMistralWireMessage(message: MistralChatMessage): Record<string, unknown> {
const wireMessage: Record<string, unknown> = { ...message };
remapMistralProperty(wireMessage, "toolCalls", "tool_calls");
remapMistralProperty(wireMessage, "toolCallId", "tool_call_id");
if (Array.isArray(message.content)) {
wireMessage.content = message.content.map((chunk) => toMistralWireContentChunk(chunk));
}
return wireMessage;
}
function toMistralWireContentChunk(chunk: MistralContentChunk): Record<string, unknown> {
const wireChunk: Record<string, unknown> = { ...chunk };
for (const [source, target] of [
["imageUrl", "image_url"],
["documentUrl", "document_url"],
["documentName", "document_name"],
["fileId", "file_id"],
["referenceIds", "reference_ids"],
["inputAudio", "input_audio"],
] as const) {
remapMistralProperty(wireChunk, source, target);
}
return wireChunk;
}
function remapMistralProperty(record: Record<string, unknown>, source: string, target: string): void {
if (!(source in record)) return;
record[target] = record[source];
delete record[source];
}
function isMistralRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const MISTRAL_STREAM_DONE = Symbol("mistral-stream-done");
async function* readMistralEvents(
body: ReadableStream<Uint8Array>,
signal: AbortSignal,
): AsyncGenerator<MistralCompletionEvent> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const onAbort = () => {
void reader.cancel().catch(() => {});
};
if (options?.signal) requestOptions.signal = options.signal;
signal.addEventListener("abort", onAbort, { once: true });
const headers: Record<string, string> = {};
if (model.headers) Object.assign(headers, model.headers);
if (options?.headers) Object.assign(headers, options.headers);
try {
while (true) {
if (signal.aborted) throw signal.reason;
const { done, value } = await reader.read();
if (signal.aborted) throw signal.reason;
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
// Mistral infrastructure uses `x-affinity` for KV-cache reuse (prefix caching).
// Respect explicit caller-provided header values.
if (shouldUsePromptCaching(options) && !headers["x-affinity"]) {
headers["x-affinity"] = options.sessionId;
let boundary = findMistralEventBoundary(buffer);
while (boundary) {
const event = parseMistralEvent(buffer.slice(0, boundary.index));
buffer = buffer.slice(boundary.index + boundary.length);
if (event === MISTRAL_STREAM_DONE) return;
if (event) yield event;
boundary = findMistralEventBoundary(buffer);
}
if (done) break;
}
if (buffer.trim()) {
const event = parseMistralEvent(buffer);
if (event !== MISTRAL_STREAM_DONE && event) yield event;
}
} finally {
signal.removeEventListener("abort", onAbort);
try {
await reader.cancel();
} catch {}
try {
reader.releaseLock();
} catch {}
}
}
if (Object.keys(headers).length > 0) {
requestOptions.headers = headers;
function findMistralEventBoundary(buffer: string): { index: number; length: number } | undefined {
const match = /\r\n\r\n|\r\n\r|\r\n\n|\r\r\n|\n\r\n|\r\r|\n\r|\n\n/u.exec(buffer);
return match?.index === undefined ? undefined : { index: match.index, length: match[0].length };
}
function parseMistralEvent(raw: string): MistralCompletionEvent | typeof MISTRAL_STREAM_DONE | undefined {
const data = raw
.split(/\r\n|\r|\n/u)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n")
.trim();
if (!data) return undefined;
if (data === "[DONE]") return MISTRAL_STREAM_DONE;
const parsed: unknown = JSON.parse(data);
if (!isMistralRecord(parsed) || !Array.isArray(parsed.choices)) {
throw new Error("Invalid Mistral streaming event");
}
return requestOptions;
return { data: parsed as MistralCompletionEvent["data"] };
}
function buildChatPayload(
@ -247,8 +500,8 @@ function buildChatPayload(
context: Context,
messages: Message[],
options?: MistralOptions,
): ChatCompletionStreamRequest {
const payload: ChatCompletionStreamRequest = {
): MistralChatPayload {
const payload: MistralChatPayload = {
model: model.id,
stream: true,
messages: toChatMessages(messages, model.input.includes("image")),
@ -301,7 +554,7 @@ async function consumeChatStream(
model: Model<"mistral-conversations">,
output: AssistantMessage,
stream: AssistantMessageEventStream,
mistralStream: AsyncIterable<CompletionEvent>,
mistralStream: AsyncIterable<MistralCompletionEvent>,
): Promise<void> {
let currentBlock: TextContent | ThinkingContent | null = null;
const blocks = output.content;
@ -336,15 +589,15 @@ async function consumeChatStream(
output.responseId ||= chunk.id;
if (chunk.usage) {
const promptTokens = chunk.usage.promptTokens || 0;
const promptTokens = chunk.usage.prompt_tokens || 0;
const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens);
output.usage.input = Math.max(0, promptTokens - cachedPromptTokens);
output.usage.output = chunk.usage.completionTokens || 0;
output.usage.output = chunk.usage.completion_tokens || 0;
output.usage.cacheRead = cachedPromptTokens;
output.usage.cacheWrite = 0;
output.usage.totalTokens =
chunk.usage.totalTokens ||
chunk.usage.total_tokens ||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
calculateCost(model, output.usage);
}
@ -352,9 +605,9 @@ async function consumeChatStream(
const choice = chunk.choices[0];
if (!choice) continue;
if (choice.finishReason) {
output.rawStopReason = choice.finishReason;
const stopReasonResult = mapChatStopReason(choice.finishReason);
if (choice.finish_reason) {
output.rawStopReason = choice.finish_reason;
const stopReasonResult = mapChatStopReason(choice.finish_reason);
output.stopReason = stopReasonResult.stopReason;
if (stopReasonResult.errorMessage) {
output.errorMessage = stopReasonResult.errorMessage;
@ -384,8 +637,8 @@ async function consumeChatStream(
}
if (item.type === "thinking") {
const deltaText = item.thinking
.map((part) => ("text" in part ? part.text : ""))
const deltaText = (item.thinking ?? [])
.map((part) => part.text ?? "")
.filter((text) => text.length > 0)
.join("");
const thinkingDelta = sanitizeSurrogates(deltaText);
@ -407,7 +660,7 @@ async function consumeChatStream(
}
if (item.type === "text") {
const textDelta = sanitizeSurrogates(item.text);
const textDelta = sanitizeSurrogates(item.text ?? "");
if (!currentBlock || currentBlock.type !== "text") {
finishCurrentBlock(currentBlock);
currentBlock = { type: "text", text: "" };
@ -425,7 +678,7 @@ async function consumeChatStream(
}
}
const toolCalls = delta.toolCalls || [];
const toolCalls = delta.tool_calls || [];
for (const toolCall of toolCalls) {
if (currentBlock) {
finishCurrentBlock(currentBlock);
@ -492,7 +745,7 @@ async function consumeChatStream(
}
}
function toFunctionTools(tools: Tool[]): Array<FunctionTool & { type: "function" }> {
function toFunctionTools(tools: Tool[]): MistralFunctionTool[] {
return tools.map((tool) => {
const strict = resolveJsonSchemaStrictSampling(tool, true);
return {
@ -523,8 +776,8 @@ function stripSymbolKeys(value: unknown): unknown {
return value;
}
function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompletionStreamRequestMessage[] {
const result: ChatCompletionStreamRequestMessage[] = [];
function toChatMessages(messages: Message[], supportsImages: boolean): MistralChatMessage[] {
const result: MistralChatMessage[] = [];
for (const msg of messages) {
if (msg.role === "user") {
@ -533,7 +786,7 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl
continue;
}
const hadImages = msg.content.some((item) => item.type === "image");
const content: ContentChunk[] = msg.content
const content: MistralContentChunk[] = msg.content
.filter((item) => item.type === "text" || supportsImages)
.map((item) => {
if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) };
@ -550,8 +803,8 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl
}
if (msg.role === "assistant") {
const contentParts: ContentChunk[] = [];
const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = [];
const contentParts: MistralContentChunk[] = [];
const toolCalls: MistralRequestToolCall[] = [];
for (const block of msg.content) {
if (block.type === "text") {
@ -573,17 +826,18 @@ function toChatMessages(messages: Message[], supportsImages: boolean): ChatCompl
id: block.id,
type: "function",
function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) },
index: 0,
});
}
const assistantMessage: ChatCompletionStreamRequestMessage = { role: "assistant" };
const assistantMessage: MistralChatMessage = { role: "assistant", prefix: false };
if (contentParts.length > 0) assistantMessage.content = contentParts;
if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls;
if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage);
continue;
}
const toolContent: ContentChunk[] = [];
const toolContent: MistralContentChunk[] = [];
const textResult = msg.content
.filter((part) => part.type === "text")
.map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : ""))
@ -651,7 +905,7 @@ function mapToolChoice(
): "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 choice;
}
return {
type: "function",

View file

@ -8,13 +8,7 @@ const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href;
const compatEntryUrl = new URL("../src/compat.ts", import.meta.url).href;
const providersAllUrl = new URL("../src/providers/all.ts", import.meta.url).href;
const SDK_SPECIFIERS = [
"@anthropic-ai/sdk",
"openai",
"@google/genai",
"@mistralai/mistralai",
"@aws-sdk/client-bedrock-runtime",
] as const;
const SDK_SPECIFIERS = ["@anthropic-ai/sdk", "openai", "@google/genai", "@aws-sdk/client-bedrock-runtime"] as const;
type ProbeResult = {
loadedSpecifiers: string[];

View file

@ -0,0 +1,427 @@
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
import { getModel } from "../src/compat.ts";
import type { Context, FetchFunction, ProviderResponse } from "../src/types.ts";
function createSseResponse(events: unknown[], headers?: Record<string, string>): Response {
const body = `${events.map((event) => `data: ${JSON.stringify(event)}`).join("\r\n\r\n")}\r\n\r\ndata: [DONE]\r\n\r\n`;
return new Response(body, {
headers: { "content-type": "text/event-stream", ...headers },
});
}
function createBytewiseSseResponse(event: unknown): Response {
const bytes = new TextEncoder().encode(`data: ${JSON.stringify(event)}\r\n\r\ndata: [DONE]\r\n\r\n`);
return new Response(
new ReadableStream({
start(controller) {
for (const byte of bytes) controller.enqueue(Uint8Array.of(byte));
controller.close();
},
}),
{ headers: { "content-type": "text/event-stream" } },
);
}
function createTerminalEvent(finishReason = "stop") {
return {
id: "mistral-response-id",
model: "mistral-large-latest",
choices: [{ index: 0, finish_reason: finishReason, delta: {} }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
};
}
describe("Mistral HTTP transport", () => {
it("serializes SDK-style payloads to the Mistral wire format", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
systemPrompt: "Be precise",
messages: [
{
role: "user",
content: [
{ type: "text", text: "describe" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
timestamp: 1,
},
],
tools: [
{
name: "lookup",
description: "Look something up",
parameters: Type.Object({ query: Type.String() }),
},
],
};
let requestUrl: string | undefined;
let requestInit: RequestInit | undefined;
let callbackPayload: Record<string, unknown> | undefined;
let callbackResponse: ProviderResponse | undefined;
const fetch: FetchFunction = async (input, init) => {
requestUrl = String(input);
requestInit = init;
return createSseResponse([createTerminalEvent()], { "x-request-id": "request-1" });
};
const message = await streamMistral(model, context, {
apiKey: "secret",
fetch,
headers: { "x-custom": "value" },
maxTokens: 123,
promptMode: "reasoning",
reasoningEffort: "high",
toolChoice: { type: "function", function: { name: "lookup" } },
sessionId: "session-1",
onPayload: (payload) => {
callbackPayload = payload as Record<string, unknown>;
return {
...callbackPayload,
topP: 0.9,
randomSeed: 42,
responseFormat: {
type: "json_schema",
jsonSchema: {
name: "result",
schemaDefinition: {
type: "object",
properties: { maxTokens: { type: "number" } },
},
},
},
presencePenalty: 0.1,
frequencyPenalty: 0.2,
parallelToolCalls: true,
safePrompt: true,
};
},
onResponse: (response) => {
callbackResponse = response;
},
}).result();
expect(message.stopReason).toBe("stop");
expect(requestUrl).toBe("https://api.mistral.ai/v1/chat/completions");
const headers = new Headers(requestInit?.headers);
expect(headers.get("authorization")).toBe("Bearer secret");
expect(headers.get("accept")).toBe("text/event-stream");
expect(headers.get("x-affinity")).toBe("session-1");
expect(headers.get("x-custom")).toBe("value");
expect(callbackPayload?.maxTokens).toBe(123);
expect(callbackPayload?.promptMode).toBe("reasoning");
expect(callbackPayload?.promptCacheKey).toBe("session-1");
expect(callbackResponse).toEqual({
status: 200,
headers: { "content-type": "text/event-stream", "x-request-id": "request-1" },
});
const wirePayload = JSON.parse(String(requestInit?.body)) as Record<string, unknown>;
expect(wirePayload.max_tokens).toBe(123);
expect(wirePayload.prompt_mode).toBe("reasoning");
expect(wirePayload.reasoning_effort).toBe("high");
expect(wirePayload.tool_choice).toEqual({ type: "function", function: { name: "lookup" } });
expect(wirePayload.prompt_cache_key).toBe("session-1");
expect(wirePayload.top_p).toBe(0.9);
expect(wirePayload.random_seed).toBe(42);
expect(wirePayload.presence_penalty).toBe(0.1);
expect(wirePayload.frequency_penalty).toBe(0.2);
expect(wirePayload.parallel_tool_calls).toBe(true);
expect(wirePayload.safe_prompt).toBe(true);
expect(wirePayload.response_format).toEqual({
type: "json_schema",
json_schema: {
name: "result",
schema: {
type: "object",
properties: { maxTokens: { type: "number" } },
},
},
});
expect(wirePayload).not.toHaveProperty("maxTokens");
expect(wirePayload).not.toHaveProperty("promptMode");
expect(wirePayload).not.toHaveProperty("promptCacheKey");
expect(wirePayload.messages).toEqual([
{ role: "system", content: "Be precise" },
{
role: "user",
content: [
{ type: "text", text: "describe" },
{ type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" },
],
},
]);
});
it("serializes assistant thinking, tool calls, and tool results for replay", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [
{
role: "assistant",
api: "mistral-conversations",
provider: "mistral",
model: model.id,
content: [
{ type: "thinking", thinking: "reason" },
{ type: "text", text: "answer" },
{ type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } },
],
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: 1,
},
{
role: "toolResult",
toolCallId: "abc123456",
toolName: "lookup",
content: [
{ type: "text", text: "found" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
isError: false,
timestamp: 2,
},
],
};
let requestInit: RequestInit | undefined;
const fetch: FetchFunction = async (_input, init) => {
requestInit = init;
return createSseResponse([createTerminalEvent()]);
};
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("stop");
const wirePayload = JSON.parse(String(requestInit?.body)) as { messages: unknown[] };
expect(wirePayload.messages).toEqual([
{
role: "assistant",
prefix: false,
content: [
{ type: "thinking", thinking: [{ type: "text", text: "reason" }] },
{ type: "text", text: "answer" },
],
tool_calls: [
{
id: "abc123456",
type: "function",
function: { name: "lookup", arguments: '{"query":"pi"}' },
index: 0,
},
],
},
{
role: "tool",
tool_call_id: "abc123456",
name: "lookup",
content: [
{ type: "text", text: "found" },
{ type: "image_url", image_url: "data:image/png;base64,aGVsbG8=" },
],
},
]);
});
it("parses native thinking, text, tool calls, and cached-token usage", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const events = [
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: null,
delta: { content: [{ type: "thinking", thinking: [{ type: "text", text: "reason" }] }] },
},
],
},
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: null,
delta: { content: [{ type: "text", text: "answer" }] },
},
],
},
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: null,
delta: {
tool_calls: [
{
id: "abc123456",
index: 0,
function: { name: "lookup", arguments: '{"query":' },
},
],
},
},
],
},
{
id: "response-1",
model: model.id,
choices: [
{
index: 0,
finish_reason: "tool_calls",
delta: {
tool_calls: [
{
id: "abc123456",
index: 0,
function: { name: "lookup", arguments: '"pi"}' },
},
],
},
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 4,
total_tokens: 14,
prompt_tokens_details: { cached_tokens: 3 },
},
},
];
const fetch: FetchFunction = async () => createSseResponse(events);
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("toolUse");
expect(message.rawStopReason).toBe("tool_calls");
expect(message.responseId).toBe("response-1");
expect(message.content).toEqual([
{ type: "thinking", thinking: "reason" },
{ type: "text", text: "answer" },
{ type: "toolCall", id: "abc123456", name: "lookup", arguments: { query: "pi" } },
]);
expect(message.usage).toMatchObject({ input: 7, output: 4, cacheRead: 3, cacheWrite: 0, totalTokens: 14 });
});
it("parses SSE and UTF-8 sequences split across transport chunks", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const fetch: FetchFunction = async () =>
createBytewiseSseResponse({
id: "response-bytewise",
model: model.id,
choices: [{ index: 0, finish_reason: "stop", delta: { content: "héllo 🌍" } }],
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
});
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("stop");
expect(message.content).toEqual([{ type: "text", text: "héllo 🌍" }]);
});
it("honors case-insensitive header overrides and explicit affinity suppression", async () => {
const model = {
...getModel("mistral", "mistral-large-latest"),
headers: { Authorization: "Bearer model-key", "X-Affinity": "model-affinity" },
};
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
let requestHeaders: Headers | undefined;
const fetch: FetchFunction = async (_input, init) => {
requestHeaders = new Headers(init?.headers);
return createSseResponse([createTerminalEvent()]);
};
await streamMistral(model, context, {
apiKey: "request-key",
fetch,
sessionId: "automatic-affinity",
headers: { authorization: null, "x-affinity": null },
}).result();
expect(requestHeaders?.has("authorization")).toBe(false);
expect(requestHeaders?.has("x-affinity")).toBe(false);
});
it("aborts while waiting for an SSE chunk", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const controller = new AbortController();
const fetch: FetchFunction = async () =>
new Response(
new ReadableStream({
start() {},
}),
{ headers: { "content-type": "text/event-stream" } },
);
const result = streamMistral(model, context, {
apiKey: "test",
fetch,
signal: controller.signal,
}).result();
controller.abort();
const message = await result;
expect(message.stopReason).toBe("aborted");
});
it("applies the request timeout while waiting for an SSE chunk", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const fetch: FetchFunction = async () =>
new Response(
new ReadableStream({
start() {},
}),
{ headers: { "content-type": "text/event-stream" } },
);
const message = await streamMistral(model, context, {
apiKey: "test",
fetch,
timeoutMs: 5,
}).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toMatch(/timeout/i);
});
it("preserves HTTP status and response bodies in errors", async () => {
const model = getModel("mistral", "mistral-large-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: 1 }],
};
const fetch: FetchFunction = async () =>
new Response('{"message":"blocked by gateway"}', { status: 403, statusText: "Forbidden" });
const message = await streamMistral(model, context, { apiKey: "test", fetch }).result();
expect(message.stopReason).toBe("error");
expect(message.errorMessage).toBe('Mistral API error (403): {"message":"blocked by gateway"}');
});
});

View file

@ -1,54 +1,39 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mistralMock = vi.hoisted(() => ({
finishReason: "stop" as string,
}));
vi.mock("@mistralai/mistralai", () => {
class HTTPClient {}
class Mistral {
chat = {
stream: async function* () {
yield {
data: {
id: "mistral-response-id",
choices: [
{
finishReason: mistralMock.finishReason,
delta: {},
},
],
usage: {
promptTokens: 1,
completionTokens: 0,
totalTokens: 1,
},
},
};
},
};
}
return { HTTPClient, Mistral };
});
import { describe, expect, it } from "vitest";
import { stream as streamMistral } from "../src/api/mistral-conversations.ts";
import { getModel } from "../src/compat.ts";
import type { Context } from "../src/types.ts";
import type { Context, FetchFunction } from "../src/types.ts";
const model = getModel("mistral", "devstral-medium-latest");
const context: Context = {
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
};
describe("Mistral raw stop reasons", () => {
beforeEach(() => {
mistralMock.finishReason = "stop";
});
function createFetch(finishReason: string): FetchFunction {
return async () =>
new Response(
`data: ${JSON.stringify({
id: "mistral-response-id",
model: model.id,
choices: [
{
index: 0,
finish_reason: finishReason,
delta: {},
},
],
usage: {
prompt_tokens: 1,
completion_tokens: 0,
total_tokens: 1,
},
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
);
}
describe("Mistral raw stop reasons", () => {
it("preserves raw Mistral finish reasons for successful stops", async () => {
const message = await streamMistral(model, context, { apiKey: "test" }).result();
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("stop") }).result();
expect(message.stopReason).toBe("stop");
expect(message.rawStopReason).toBe("stop");
@ -56,9 +41,7 @@ describe("Mistral raw stop reasons", () => {
});
it("preserves raw Mistral finish reasons for provider error stops", async () => {
mistralMock.finishReason = "error";
const message = await streamMistral(model, context, { apiKey: "test" }).result();
const message = await streamMistral(model, context, { apiKey: "test", fetch: createFetch("error") }).result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("error");
@ -66,9 +49,10 @@ describe("Mistral raw stop reasons", () => {
});
it("treats unknown Mistral finish reasons as provider error stops", async () => {
mistralMock.finishReason = "unmapped_error";
const message = await streamMistral(model, context, { apiKey: "test" }).result();
const message = await streamMistral(model, context, {
apiKey: "test",
fetch: createFetch("unmapped_error"),
}).result();
expect(message.stopReason).toBe("error");
expect(message.rawStopReason).toBe("unmapped_error");

View file

@ -988,13 +988,13 @@ describe("Generate E2E Tests", () => {
});
it("should handle thinking mode", { retry: 3 }, async () => {
const llm = getModel("mistral", "magistral-medium-latest");
await handleThinking(llm, { promptMode: "reasoning" });
const llm = getModel("mistral", "mistral-small-2603");
await handleThinking(llm, { reasoningEffort: "high" });
});
it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => {
const llm = getModel("mistral", "magistral-medium-latest");
await multiTurn(llm, { promptMode: "reasoning" });
const llm = getModel("mistral", "mistral-small-2603");
await multiTurn(llm, { reasoningEffort: "high" });
});
});

View file

@ -6,6 +6,10 @@
- Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint.
### Changed
- Replaced the inherited Mistral SDK transport with a native Chat Completions HTTP stream, eliminating its generated client and schema runtime overhead.
## [0.84.1] - 2026-08-07
### New Features

View file

@ -227,7 +227,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 |
| `mistral-conversations` | Native Mistral Chat Completions streaming |
| `google-generative-ai` | Google Generative AI API |
| `google-vertex` | Google Vertex AI API |
| `bedrock-converse-stream` | Amazon Bedrock Converse API |

View file

@ -474,7 +474,6 @@
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.1",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",
@ -772,26 +771,6 @@
],
"optional": true
},
"node_modules/@mistralai/mistralai": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz",
"integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.40.0",
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
@ -813,15 +792,6 @@
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@ -1853,24 +1823,6 @@
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"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/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
}
}
}

View file

@ -501,7 +501,6 @@
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
"@earendil-works/pi-telemetry": "^0.84.1",
"@google/genai": "1.52.0",
"@mistralai/mistralai": "2.2.6",
"@opentelemetry/api": "1.9.0",
"@smithy/node-http-handler": "4.7.3",
"http-proxy-agent": "7.0.2",
@ -762,26 +761,6 @@
],
"optional": true
},
"node_modules/@mistralai/mistralai": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz",
"integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.40.0",
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
@ -803,15 +782,6 @@
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@ -1843,24 +1813,6 @@
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"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/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
}
}
}