mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(ai): replace Mistral SDK with native transport
This commit is contained in:
parent
e47b8e37a6
commit
9dd90a4971
12 changed files with 785 additions and 257 deletions
39
package-lock.json
generated
39
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
427
packages/ai/test/mistral-http-transport.test.ts
Normal file
427
packages/ai/test/mistral-http-transport.test.ts
Normal 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"}');
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
48
packages/coding-agent/install-lock/package-lock.json
generated
48
packages/coding-agent/install-lock/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
48
packages/coding-agent/npm-shrinkwrap.json
generated
48
packages/coding-agent/npm-shrinkwrap.json
generated
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue