UNPKG

ai-sdk-ollama

Version:

Vercel AI SDK Provider for Ollama using official ollama-js library

640 lines (634 loc) 19.5 kB
// src/provider.ts import { NoSuchModelError } from "@ai-sdk/provider"; import { Ollama } from "ollama"; // src/utils/convert-to-ollama-messages.ts function convertToOllamaChatMessages(prompt) { const messages = []; for (const message of prompt) { switch (message.role) { case "system": { messages.push({ role: "system", content: message.content }); break; } case "user": { if (typeof message.content === "string") { messages.push({ role: "user", content: message.content }); } else { const textParts = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n"); const imageParts = message.content.filter( (part) => part.type === "file" ).filter((part) => { return part.mediaType?.startsWith("image/") || false; }).map((part) => { const imageData = part.data; if (imageData instanceof URL) { if (imageData.protocol === "data:") { const base64Match = imageData.href.match( /data:[^;]+;base64,(.+)/ ); if (base64Match) { return base64Match[1]; } return imageData.href; } return imageData.href; } else if (typeof imageData === "string") { if (imageData.startsWith("data:")) { const base64Match = imageData.match(/data:[^;]+;base64,(.+)/); if (base64Match) { return base64Match[1]; } } return imageData; } else if (imageData instanceof Uint8Array) { return Buffer.from(imageData).toString("base64"); } else { console.warn( `Unsupported image data type: ${typeof imageData}` ); return null; } }).filter((img) => img !== null); messages.push({ role: "user", content: textParts || "", // Ensure content is never undefined images: imageParts.length > 0 ? imageParts : void 0 }); } break; } case "assistant": { let content = ""; if (typeof message.content === "string") { content = message.content; } else { const textParts = message.content.filter((part) => part.type === "text").map((part) => part.text).join(""); const reasoningParts = message.content.filter((part) => part.type === "reasoning").map((part) => part.text).join("\n"); content = [textParts, reasoningParts].filter(Boolean).join("\n"); const toolCalls = message.content.filter( (part) => part.type === "tool-call" ); if (toolCalls.length > 0) { const toolCallText = toolCalls.map((tc) => `[Tool Call: ${tc.toolName}]`).join("\n"); if (toolCallText) { content = content ? `${content} ${toolCallText}` : toolCallText; } } } messages.push({ role: "assistant", content: content || "" // Ensure content is never undefined }); break; } case "tool": { if (typeof message.content === "string") { messages.push({ role: "user", // Ollama doesn't have native tool role, so we use user content: `[Tool Result]: ${message.content}` }); } else { const toolResultParts = message.content.filter((part) => part.type === "tool-result").map((part) => { if (part.output.type === "text") { return part.output.value; } else if (part.output.type === "json") { return JSON.stringify(part.output.value); } return String(part.output.value); }).join("\n"); messages.push({ role: "user", content: `[Tool Result]: ${toolResultParts || ""}` }); } break; } default: { const role = message.role; throw new Error( `Unsupported message role: ${role}. Supported roles are: system, user, assistant, tool` ); } } } return messages; } // src/utils/map-ollama-finish-reason.ts function mapOllamaFinishReason(reason) { if (!reason) return "unknown"; switch (reason) { case "stop": { return "stop"; } case "length": { return "length"; } default: { return "unknown"; } } } // src/utils/ollama-error.ts var OllamaError = class _OllamaError extends Error { cause; data; constructor({ message, cause, data }) { super(message); this.name = "OllamaError"; this.cause = cause; this.data = data; } static isOllamaError(error) { return error instanceof _OllamaError; } }; // src/models/chat-language-model.ts var OllamaChatLanguageModel = class { constructor(modelId, settings, config) { this.modelId = modelId; this.settings = settings; this.config = config; } specificationVersion = "v2"; defaultObjectGenerationMode = "json"; supportsImages = true; // ✅ Ollama supports images (URLs, files, base64) supportsVideoURLs = false; // ❌ Not supported by Ollama API supportsAudioURLs = false; // ❌ Not supported by Ollama API supportsVideoFile = false; // ❌ Not supported by Ollama API supportsAudioFile = false; // ❌ Not supported by Ollama API supportsImageFile = true; // ✅ Already correct supportedUrls = { // Support common image URL patterns image: [ /^https?:\/\/.*\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i, /^data:image\/[^;]+;base64,/i // Data URLs ] }; get provider() { return this.config.provider; } get supportsStructuredOutputs() { return this.settings.structuredOutputs ?? false; } /** * Check if structured outputs should be enabled based on the call options * This is used internally to auto-detect when structured outputs are needed */ shouldEnableStructuredOutputs(options) { if (options.responseFormat?.type === "json" && options.responseFormat.schema) { if (this.settings.structuredOutputs === false) { console.warn( "Ollama: structuredOutputs was set to false but auto-enabled for object generation. This ensures generateObject and streamObject work correctly." ); } return true; } if (this.settings.structuredOutputs !== void 0) { return this.settings.structuredOutputs; } return false; } getCallOptions(options) { const { prompt, temperature, maxOutputTokens, topP, topK, frequencyPenalty, presencePenalty, stopSequences, seed, responseFormat, tools } = options; const warnings = []; const needsStructuredOutputs = this.shouldEnableStructuredOutputs(options); if (responseFormat?.type === "json" && responseFormat.schema && !needsStructuredOutputs) { throw new Error( "JSON schema is only supported when structuredOutputs is enabled" ); } const ollamaTools = tools ? tools.map((tool) => { if (tool.type === "function") { let jsonSchema; if (tool.inputSchema && typeof tool.inputSchema === "object") { if ("parse" in tool.inputSchema && typeof tool.inputSchema.parse === "function") { console.warn( `Tool ${tool.name} is using a Zod schema directly. Schema conversion may not work properly due to Zod version mismatch.` ); jsonSchema = { type: "object", properties: {}, additionalProperties: false }; } else if ("properties" in tool.inputSchema || "type" in tool.inputSchema) { jsonSchema = tool.inputSchema; } else { jsonSchema = { type: "object", properties: {}, additionalProperties: false }; } } else { jsonSchema = { type: "object", properties: {}, additionalProperties: false }; } return { type: "function", function: { name: tool.name, description: tool.description, parameters: jsonSchema } }; } throw new Error( `Provider-defined tools are not supported by Ollama. Use function tools instead.` ); }) : void 0; const ollamaOptions = { // Start with AI SDK parameters mapped to Ollama names ...temperature !== void 0 && { temperature }, ...maxOutputTokens !== void 0 && { num_predict: maxOutputTokens }, ...topP !== void 0 && { top_p: topP }, ...topK !== void 0 && { top_k: topK }, ...frequencyPenalty !== void 0 && { frequency_penalty: frequencyPenalty }, ...presencePenalty !== void 0 && { presence_penalty: presencePenalty }, ...stopSequences !== void 0 && { stop: stopSequences }, ...seed !== void 0 && { seed }, // Ollama model options override AI SDK parameters ...this.settings.options }; for (const key of Object.keys(ollamaOptions)) { if (ollamaOptions[key] === void 0) { delete ollamaOptions[key]; } } let format; if (responseFormat?.type === "json") { format = responseFormat.schema && needsStructuredOutputs ? responseFormat.schema : "json"; } const messages = convertToOllamaChatMessages(prompt); return { messages, options: ollamaOptions, format, tools: ollamaTools, warnings }; } async doGenerate(options) { const { messages, options: ollamaOptions, format, tools, warnings } = this.getCallOptions(options); try { const response = await this.config.client.chat({ model: this.modelId, messages, options: ollamaOptions, format, tools, stream: false }); const text = response.message.content; const toolCalls = response.message.tool_calls; const thinking = response.message.thinking; const content = []; if (thinking && this.settings.reasoning) { content.push({ type: "reasoning", text: thinking }); } if (text) { content.push({ type: "text", text }); } if (toolCalls && toolCalls.length > 0) { for (const toolCall of toolCalls) { const toolInput = toolCall.function.arguments || {}; content.push({ type: "tool-call", toolCallId: crypto.randomUUID(), // Ollama doesn't provide IDs toolName: toolCall.function.name, input: JSON.stringify(toolInput) }); } } return { content, finishReason: mapOllamaFinishReason( response.done_reason ), usage: { inputTokens: response.prompt_eval_count || 0, outputTokens: response.eval_count || 0, totalTokens: (response.prompt_eval_count || 0) + (response.eval_count || 0) }, providerMetadata: { ollama: { model: response.model, created_at: response.created_at ? new Date(response.created_at).toISOString() : void 0, total_duration: response.total_duration, load_duration: response.load_duration, eval_duration: response.eval_duration } }, request: { body: JSON.stringify({ model: this.modelId, messages, options: ollamaOptions, format, tools }) }, response: { timestamp: /* @__PURE__ */ new Date(), modelId: this.modelId }, warnings }; } catch (error) { throw new OllamaError({ message: error instanceof Error ? error.message : String(error), cause: error }); } } async doStream(options) { const { messages, options: ollamaOptions, format, tools, warnings } = this.getCallOptions(options); try { const stream = await this.config.client.chat({ model: this.modelId, messages, options: ollamaOptions, format, tools, stream: true }); let usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; let finishReason = "unknown"; const reasoningEnabled = this.settings.reasoning; const transformStream = new TransformStream({ async transform(chunk, controller) { if (!chunk || typeof chunk !== "object") { return; } if (chunk.done) { if (chunk.message && typeof chunk.message.content === "string" && chunk.message.content.length > 0) { controller.enqueue({ type: "text-delta", id: crypto.randomUUID(), delta: chunk.message.content }); } usage = { inputTokens: chunk.prompt_eval_count || 0, outputTokens: chunk.eval_count || 0, totalTokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0) }; finishReason = mapOllamaFinishReason( chunk.done_reason ); controller.enqueue({ type: "finish", finishReason, usage }); } else { if (chunk.message.thinking && reasoningEnabled) { controller.enqueue({ type: "reasoning-start", id: crypto.randomUUID() }); controller.enqueue({ type: "reasoning-delta", id: crypto.randomUUID(), delta: chunk.message.thinking }); controller.enqueue({ type: "reasoning-end", id: crypto.randomUUID() }); } if (chunk.message.tool_calls && chunk.message.tool_calls.length > 0) { for (const toolCall of chunk.message.tool_calls) { const toolInput = toolCall.function.arguments || {}; controller.enqueue({ type: "tool-call", toolCallId: crypto.randomUUID(), // Ollama doesn't provide IDs toolName: toolCall.function.name, input: JSON.stringify(toolInput) }); } } if (chunk.message.content && typeof chunk.message.content === "string" && chunk.message.content.length > 0) { controller.enqueue({ type: "text-delta", id: crypto.randomUUID(), // Generate unique ID for each text chunk delta: chunk.message.content }); } } } }); const readableStream = new ReadableStream({ async start(controller) { try { for await (const chunk of stream) { if (chunk && typeof chunk === "object") { controller.enqueue(chunk); } } controller.close(); } catch (error) { controller.error(error); } } }); return { stream: readableStream.pipeThrough(transformStream), rawCall: { rawPrompt: messages, rawSettings: { model: this.modelId, options: ollamaOptions, format, tools } }, warnings: warnings.length > 0 ? warnings : void 0 }; } catch (error) { throw new OllamaError({ message: error instanceof Error ? error.message : String(error), cause: error }); } } }; // src/models/embedding-model.ts var OllamaEmbeddingModel = class { constructor(modelId, settings, config) { this.settings = settings; this.config = config; this.modelId = modelId; } specificationVersion = "v2"; modelId; maxEmbeddingsPerCall = 2048; supportsParallelCalls = true; get provider() { return this.config.provider; } async doEmbed(params) { const { values, abortSignal } = params; if (values.length > this.maxEmbeddingsPerCall) { throw new OllamaError({ message: `Too many values to embed. Maximum: ${this.maxEmbeddingsPerCall}, Received: ${values.length}` }); } if (values.length === 0) { return { embeddings: [] }; } try { const embeddings = []; for (const value of values) { if (value === void 0 || value === null) { continue; } const response = await this.config.client.embed({ model: this.modelId, input: value, options: this.settings.options }); if (!response.embeddings) { throw new OllamaError({ message: `No embeddings field in response` }); } if (response.embeddings.length === 0) { throw new OllamaError({ message: `Empty embeddings array returned` }); } embeddings.push(response.embeddings[0]); if (abortSignal?.aborted) { throw new Error("Embedding generation aborted"); } } if (embeddings.length === 0) { throw new OllamaError({ message: `No valid values provided for embedding (all were undefined/null)` }); } return { embeddings }; } catch (error) { if (error instanceof OllamaError) { throw error; } throw new OllamaError({ message: error instanceof Error ? error.message : String(error), cause: error }); } } }; // src/provider.ts function createOllama(options = {}) { const client = new Ollama({ host: options.baseURL, fetch: options.fetch, headers: options.headers }); const createChatModel = (modelId, settings = {}) => { return new OllamaChatLanguageModel(modelId, settings, { client, provider: "ollama" }); }; const createEmbeddingModel = (modelId, settings = {}) => { return new OllamaEmbeddingModel(modelId, settings, { client, provider: "ollama" }); }; const provider = function(modelId, settings) { if (new.target) { throw new Error( "The Ollama provider cannot be called with the new keyword." ); } return createChatModel(modelId, settings); }; provider.chat = createChatModel; provider.languageModel = createChatModel; provider.embedding = createEmbeddingModel; provider.textEmbedding = createEmbeddingModel; provider.textEmbeddingModel = createEmbeddingModel; provider.imageModel = (modelId) => { throw new NoSuchModelError({ modelId, modelType: "imageModel", message: "Image generation is not supported by Ollama" }); }; return provider; } var ollama = createOllama(); export { OllamaChatLanguageModel, OllamaEmbeddingModel, OllamaError, createOllama, ollama }; //# sourceMappingURL=index.js.map