diff --git a/package-lock.json b/package-lock.json index 813d59615..b0f2e6291 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index f984fdf53..638507227 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/package.json b/packages/ai/package.json index 1b23d01d5..552876250 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -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", diff --git a/packages/ai/src/api/mistral-conversations.ts b/packages/ai/src/api/mistral-conversations.ts index 9bbb96b79..e29d1a13a 100644 --- a/packages/ai/src/api/mistral-conversations.ts +++ b/packages/ai/src/api/mistral-conversations.ts @@ -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; + strict: boolean; + }; +}; + +type MistralChatPayload = { + [key: string]: unknown; + model: string; + stream: boolean; + messages: MistralChatMessage[]; + tools?: MistralFunctionTool[]; + temperature?: number; + maxTokens?: number; + toolChoice?: Exclude; + 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; + }; +}; + +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; - } = { - retries: { strategy: "none" }, +async function requestMistralStream( + model: Model<"mistral-conversations">, + payload: MistralChatPayload, + apiKey: string, + options?: MistralOptions, +): Promise> { + 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): 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 | undefined, target: string): boolean { + return !!overrides && Object.keys(overrides).some((name) => name.toLowerCase() === target); +} + +function toMistralWirePayload(payload: MistralChatPayload): Record { + const wirePayload: Record = { ...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 { + const wireMessage: Record = { ...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 { + const wireChunk: Record = { ...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, source: string, target: string): void { + if (!(source in record)) return; + record[target] = record[source]; + delete record[source]; +} + +function isMistralRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const MISTRAL_STREAM_DONE = Symbol("mistral-stream-done"); + +async function* readMistralEvents( + body: ReadableStream, + signal: AbortSignal, +): AsyncGenerator { + 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 = {}; - 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, + mistralStream: AsyncIterable, ): Promise { 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 { +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", diff --git a/packages/ai/test/lazy-module-load.test.ts b/packages/ai/test/lazy-module-load.test.ts index dd5169625..cb0875c9e 100644 --- a/packages/ai/test/lazy-module-load.test.ts +++ b/packages/ai/test/lazy-module-load.test.ts @@ -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[]; diff --git a/packages/ai/test/mistral-http-transport.test.ts b/packages/ai/test/mistral-http-transport.test.ts new file mode 100644 index 000000000..dde2d111e --- /dev/null +++ b/packages/ai/test/mistral-http-transport.test.ts @@ -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): 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 | 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; + 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; + 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"}'); + }); +}); diff --git a/packages/ai/test/mistral-raw-stop-reason.test.ts b/packages/ai/test/mistral-raw-stop-reason.test.ts index 782e057a2..512d92c24 100644 --- a/packages/ai/test/mistral-raw-stop-reason.test.ts +++ b/packages/ai/test/mistral-raw-stop-reason.test.ts @@ -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"); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index cbfb55606..d3e7a0211 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -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" }); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1bf3b77b0..12e4349d4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index 027aa6115..858b992ef 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -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 | diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 321dd5ec3..d55a533d6 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -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" - } } } } diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index bd313840e..bb64c682d 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -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" - } } } }