From 831f6340edb2a59c4c59c5fceec89d36e618e3ae Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Thu, 11 Sep 2025 15:31:49 +0300 Subject: [PATCH 01/23] feat(js/plugins/ollama): migrate ollama plugin to v2 plugin API --- js/plugins/ollama/src/index.ts | 396 ++++++++++++++++---------- js/plugins/ollama/tests/model_test.ts | 22 +- 2 files changed, 248 insertions(+), 170 deletions(-) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 9c823ab276..a453d4525b 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -20,7 +20,6 @@ import { z, type ActionMetadata, type EmbedderReference, - type Genkit, type ModelReference, type ToolRequest, type ToolRequestPart, @@ -38,11 +37,16 @@ import { type ModelInfo, type ToolDefinition, } from 'genkit/model'; -import { genkitPlugin, type GenkitPlugin } from 'genkit/plugin'; -import type { ActionType } from 'genkit/registry'; -import { defineOllamaEmbedder } from './embeddings.js'; +import { + embedder, + genkitPluginV2, + model, + type GenkitPluginV2, + type ResolvableAction, +} from 'genkit/plugin'; import type { ApiType, + EmbeddingModelDefinition, ListLocalModelsResponse, LocalModel, Message, @@ -56,7 +60,7 @@ import type { export type { OllamaPluginParams }; export type OllamaPlugin = { - (params?: OllamaPluginParams): GenkitPlugin; + (params?: OllamaPluginParams): GenkitPluginV2; model( name: string, @@ -82,159 +86,23 @@ const GENERIC_MODEL_INFO = { const DEFAULT_OLLAMA_SERVER_ADDRESS = 'http://localhost:11434'; -async function initializer( - ai: Genkit, - serverAddress: string, - params?: OllamaPluginParams -) { - params?.models?.map((model) => - defineOllamaModel(ai, model, serverAddress, params?.requestHeaders) - ); - params?.embedders?.map((model) => - defineOllamaEmbedder(ai, { - name: model.name, - modelName: model.name, - dimensions: model.dimensions, - options: params!, - }) - ); -} - -function resolveAction( - ai: Genkit, - actionType: ActionType, - actionName: string, - serverAddress: string, - requestHeaders?: RequestHeaders -) { - // We can only dynamically resolve models, for embedders user must provide dimensions. - if (actionType === 'model') { - defineOllamaModel( - ai, - { - name: actionName, - }, - serverAddress, - requestHeaders - ); - } -} - -async function listActions( - serverAddress: string, - requestHeaders?: RequestHeaders -): Promise { - const models = await listLocalModels(serverAddress, requestHeaders); - return ( - models - // naively filter out embedders, unfortunately there's no better way. - ?.filter((m) => m.model && !m.model.includes('embed')) - .map((m) => - modelActionMetadata({ - name: `ollama/${m.model}`, - info: GENERIC_MODEL_INFO, - }) - ) || [] - ); -} - -function ollamaPlugin(params?: OllamaPluginParams): GenkitPlugin { - if (!params) { - params = {}; - } - if (!params.serverAddress) { - params.serverAddress = DEFAULT_OLLAMA_SERVER_ADDRESS; - } - const serverAddress = params.serverAddress; - return genkitPlugin( - 'ollama', - async (ai: Genkit) => { - await initializer(ai, serverAddress, params); - }, - async (ai, actionType, actionName) => { - resolveAction( - ai, - actionType, - actionName, - serverAddress, - params?.requestHeaders - ); - }, - async () => await listActions(serverAddress, params?.requestHeaders) - ); -} - -async function listLocalModels( - serverAddress: string, - requestHeaders?: RequestHeaders -): Promise { - // We call the ollama list local models api: https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models - let res; - try { - res = await fetch(serverAddress + '/api/tags', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...(await getHeaders(serverAddress, requestHeaders)), - }, - }); - } catch (e) { - throw new Error(`Make sure the Ollama server is running.`, { - cause: e, - }); - } - const modelResponse = JSON.parse(await res.text()) as ListLocalModelsResponse; - return modelResponse.models; -} - -/** - * Please refer to: https://github.com/ollama/ollama/blob/main/docs/modelfile.md - * for further information. - */ -export const OllamaConfigSchema = GenerationCommonConfigSchema.extend({ - temperature: z - .number() - .min(0.0) - .max(1.0) - .describe( - GenerationCommonConfigDescriptions.temperature + - ' The default value is 0.8.' - ) - .optional(), - topK: z - .number() - .describe( - GenerationCommonConfigDescriptions.topK + ' The default value is 40.' - ) - .optional(), - topP: z - .number() - .min(0) - .max(1.0) - .describe( - GenerationCommonConfigDescriptions.topP + ' The default value is 0.9.' - ) - .optional(), -}); - -function defineOllamaModel( - ai: Genkit, - model: ModelDefinition, +async function createOllamaModel( + modelDef: ModelDefinition, serverAddress: string, requestHeaders?: RequestHeaders ) { - return ai.defineModel( + return model( { - name: `ollama/${model.name}`, - label: `Ollama - ${model.name}`, + name: modelDef.name, + label: `Ollama - ${modelDef.name}`, configSchema: OllamaConfigSchema, supports: { - multiturn: !model.type || model.type === 'chat', + multiturn: !modelDef.type || modelDef.type === 'chat', systemRole: true, - tools: model.supports?.tools, + tools: modelDef.supports?.tools, }, }, - async (input, streamingCallback) => { + async (input, opts) => { const { topP, topK, stopSequences, maxOutputTokens, ...rest } = input.config as any; const options: Record = { ...rest }; @@ -250,20 +118,20 @@ function defineOllamaModel( if (maxOutputTokens !== undefined) { options.num_predict = maxOutputTokens; } - const type = model.type ?? 'chat'; + const type = modelDef.type ?? 'chat'; const request = toOllamaRequest( - model.name, + modelDef.name, input, options, type, - !!streamingCallback + !!opts.sendChunk ); logger.debug(request, `ollama request (${type})`); const extraHeaders = await getHeaders( serverAddress, requestHeaders, - model, + modelDef, input ); let res; @@ -297,7 +165,7 @@ function defineOllamaModel( let message: MessageData; - if (streamingCallback) { + if (opts.sendChunk) { const reader = res.body.getReader(); const textDecoder = new TextDecoder(); let textResponse = ''; @@ -305,8 +173,7 @@ function defineOllamaModel( const chunkText = textDecoder.decode(chunk); const json = JSON.parse(chunkText); const message = parseMessage(json, type); - streamingCallback({ - index: 0, + opts.sendChunk({ content: message.content, }); textResponse += message.content[0].text; @@ -336,6 +203,223 @@ function defineOllamaModel( ); } +async function createOllamaEmbedder( + modelDef: EmbeddingModelDefinition, + serverAddress: string, + requestHeaders?: RequestHeaders +) { + return embedder( + { + name: modelDef.name, + info: { + label: 'Ollama Embedding - ' + modelDef.name, + dimensions: modelDef.dimensions, + supports: { + input: ['text'], + }, + }, + }, + async (input, config) => { + const { url, requestPayload, headers } = await toOllamaEmbedRequest( + modelDef.name, + modelDef.dimensions, + input.input, + serverAddress, + requestHeaders + ); + + const response: Response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(requestPayload), + }); + + if (!response.ok) { + const errMsg = (await response.json()).error?.message || ''; + throw new Error( + `Error fetching embedding from Ollama: ${response.statusText}. ${errMsg}` + ); + } + + const payload = (await response.json()) as any; + + const embeddings: { embedding: number[] }[] = []; + + for (const embedding of payload.embeddings) { + embeddings.push({ embedding }); + } + return { embeddings }; + } + ); +} + +async function listActions( + serverAddress: string, + requestHeaders?: RequestHeaders +): Promise { + const models = await listLocalModels(serverAddress, requestHeaders); + return ( + models + // naively filter out embedders, unfortunately there's no better way. + ?.filter((m) => m.model && !m.model.includes('embed')) + .map((m) => + modelActionMetadata({ + name: m.model, + info: GENERIC_MODEL_INFO, + }) + ) || [] + ); +} + +function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { + if (!params) { + params = {}; + } + if (!params.serverAddress) { + params.serverAddress = DEFAULT_OLLAMA_SERVER_ADDRESS; + } + const serverAddress = params.serverAddress; + + return genkitPluginV2({ + name: 'ollama', + async init() { + const actions: ResolvableAction[] = []; + + if (params?.models) { + for (const model of params.models) { + actions.push( + await createOllamaModel(model, serverAddress, params.requestHeaders) + ); + } + } + + if (params?.embedders) { + for (const embedder of params.embedders) { + actions.push( + await createOllamaEmbedder( + embedder, + serverAddress, + params.requestHeaders + ) + ); + } + } + + return actions; + }, + async resolve(actionType, actionName) { + // dynamically resolve models, for embedders user must provide dimensions. + if (actionType === 'model') { + return await createOllamaModel( + { + name: actionName, + }, + serverAddress, + params?.requestHeaders + ); + } + return undefined; + }, + async list() { + return await listActions(serverAddress, params?.requestHeaders); + }, + }); +} + +async function listLocalModels( + serverAddress: string, + requestHeaders?: RequestHeaders +): Promise { + // We call the ollama list local models api: https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models + let res; + try { + res = await fetch(serverAddress + '/api/tags', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(await getHeaders(serverAddress, requestHeaders)), + }, + }); + } catch (e) { + throw new Error(`Make sure the Ollama server is running.`, { + cause: e, + }); + } + const modelResponse = JSON.parse(await res.text()) as ListLocalModelsResponse; + return modelResponse.models; +} + +/** + * Please refer to: https://github.com/ollama/ollama/blob/main/docs/modelfile.md + * for further information. + */ +export const OllamaConfigSchema = GenerationCommonConfigSchema.extend({ + temperature: z + .number() + .min(0.0) + .max(1.0) + .describe( + GenerationCommonConfigDescriptions.temperature + + ' The default value is 0.8.' + ) + .optional(), + topK: z + .number() + .describe( + GenerationCommonConfigDescriptions.topK + ' The default value is 40.' + ) + .optional(), + topP: z + .number() + .min(0) + .max(1.0) + .describe( + GenerationCommonConfigDescriptions.topP + ' The default value is 0.9.' + ) + .optional(), +}); + +async function toOllamaEmbedRequest( + modelName: string, + dimensions: number, + documents: any[], + serverAddress: string, + requestHeaders?: RequestHeaders +): Promise<{ + url: string; + requestPayload: any; + headers: Record; +}> { + const requestPayload = { + model: modelName, + input: documents.map((doc) => doc.text), + }; + + const extraHeaders = requestHeaders + ? typeof requestHeaders === 'function' + ? await requestHeaders({ + serverAddress, + model: { + name: modelName, + dimensions, + }, + embedRequest: requestPayload, + }) + : requestHeaders + : {}; + + const headers = { + 'Content-Type': 'application/json', + ...extraHeaders, + }; + + return { + url: `${serverAddress}/api/embed`, + requestPayload, + headers, + }; +} + function parseMessage(response: any, type: ApiType): MessageData { if (response.error) { throw new Error(response.error); diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index 3c81eb24b3..3099f4f0c3 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -58,6 +58,12 @@ const MAGIC_WORD = 'sunnnnnnny'; global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/api/chat')) { + // For basic calls without tools, return the end response + const body = JSON.parse((options?.body as string) || '{}'); + if (!body.tools || body.tools.length === 0) { + return new Response(JSON.stringify(MOCK_END_RESPONSE)); + } + // For tool calls, check if magic word is present if (options?.body && JSON.stringify(options.body).includes(MAGIC_WORD)) { return new Response(JSON.stringify(MOCK_END_RESPONSE)); } @@ -79,24 +85,12 @@ describe('ollama models', () => { }); }); - it('should successfully return tool call response', async () => { - const get_current_weather = ai.defineTool( - { - name: 'get_current_weather', - description: 'gets weather', - inputSchema: z.object({ format: z.string(), location: z.string() }), - }, - async () => { - return MAGIC_WORD; - } - ); - + it('should successfully return basic response', async () => { const result = await ai.generate({ model: 'ollama/test-model', prompt: 'Hello', - tools: [get_current_weather], }); - assert.ok(result.text === 'The weather is sunny'); + assert.ok(result.message.content[0].text === 'The weather is sunny'); }); it('should throw for primitive tools', async () => { From 2487bffff9603be660d5ee945e5afb550c71a708 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Thu, 18 Sep 2025 14:44:28 +0300 Subject: [PATCH 02/23] chore(js/plugins/ollama): migrate embeddings --- js/plugins/ollama/src/embeddings.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/js/plugins/ollama/src/embeddings.ts b/js/plugins/ollama/src/embeddings.ts index 96a58b6164..2f36a200f7 100644 --- a/js/plugins/ollama/src/embeddings.ts +++ b/js/plugins/ollama/src/embeddings.ts @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { Document, EmbedderAction, Genkit } from 'genkit'; +import { embedder } from 'genkit/plugin'; +import type { Document, EmbedderAction } from 'genkit'; import type { EmbedRequest, EmbedResponse } from 'ollama'; import type { DefineOllamaEmbeddingParams, RequestHeaders } from './types.js'; -async function toOllamaEmbedRequest( +export async function toOllamaEmbedRequest( modelName: string, dimensions: number, documents: Document[], @@ -60,10 +61,9 @@ async function toOllamaEmbedRequest( } export function defineOllamaEmbedder( - ai: Genkit, { name, modelName, dimensions, options }: DefineOllamaEmbeddingParams ): EmbedderAction { - return ai.defineEmbedder( + return embedder( { name: `ollama/${name}`, info: { @@ -76,12 +76,12 @@ export function defineOllamaEmbedder( }, }, async (input, config) => { - const serverAddress = config?.serverAddress || options.serverAddress; + const serverAddress = options.serverAddress || 'http://localhost:11434'; const { url, requestPayload, headers } = await toOllamaEmbedRequest( modelName, dimensions, - input, + input.input, serverAddress, options.requestHeaders ); From 6220e034397c5ff2be9b82864151c27fd2008122 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Thu, 18 Sep 2025 14:44:50 +0300 Subject: [PATCH 03/23] chore(js/plugins/ollama): update types --- js/plugins/ollama/src/types.ts | 3 +- .../ollama/tests/embedding_live_test.ts | 18 +++------- js/plugins/ollama/tests/embeddings_test.ts | 33 ++++++------------- js/plugins/ollama/tests/model_test.ts | 33 +++++++++++++++---- 4 files changed, 43 insertions(+), 44 deletions(-) diff --git a/js/plugins/ollama/src/types.ts b/js/plugins/ollama/src/types.ts index b166b3ca3e..f1e7e10ecc 100644 --- a/js/plugins/ollama/src/types.ts +++ b/js/plugins/ollama/src/types.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { z, type GenerateRequest } from 'genkit'; +import { z } from 'genkit'; +import type { GenerateRequest } from 'genkit/model'; import type { EmbedRequest } from 'ollama'; // Define possible API types export type ApiType = 'chat' | 'generate'; diff --git a/js/plugins/ollama/tests/embedding_live_test.ts b/js/plugins/ollama/tests/embedding_live_test.ts index 0f19150377..45b1e19c6a 100644 --- a/js/plugins/ollama/tests/embedding_live_test.ts +++ b/js/plugins/ollama/tests/embedding_live_test.ts @@ -14,10 +14,8 @@ * limitations under the License. */ import * as assert from 'assert'; -import { genkit } from 'genkit'; import { describe, it } from 'node:test'; import { defineOllamaEmbedder } from '../src/embeddings.js'; // Adjust the import path as necessary -import { ollama } from '../src/index.js'; import type { OllamaPluginParams } from '../src/types.js'; // Adjust the import path as necessary // Utility function to parse command-line arguments function parseArgs() { @@ -37,21 +35,15 @@ describe('defineOllamaEmbedder - Live Tests', () => { serverAddress, }; it('should successfully return embeddings', async () => { - const ai = genkit({ - plugins: [ollama(options)], - }); - const embedder = defineOllamaEmbedder(ai, { + const embedder = defineOllamaEmbedder({ name: 'live-test-embedder', modelName: 'nomic-embed-text', dimensions: 768, options, }); - const result = ( - await ai.embed({ - embedder, - content: 'Hello, world!', - }) - )[0].embedding; - assert.strictEqual(result.length, 768); + const result = await embedder({ + input: [{ content: [{ text: 'Hello, world!' }] }], + }); + assert.strictEqual(result.embeddings[0].embedding.length, 768); }); }); diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index a2a5e7adca..2d3b416174 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -14,11 +14,10 @@ * limitations under the License. */ import * as assert from 'assert'; -import { genkit, type Genkit } from 'genkit'; -import { beforeEach, describe, it } from 'node:test'; +import { describe, it } from 'node:test'; import { defineOllamaEmbedder } from '../src/embeddings.js'; -import { ollama } from '../src/index.js'; import type { OllamaPluginParams } from '../src/types.js'; +import 'genkit'; // Mock fetch to simulate API responses global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { @@ -42,38 +41,27 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { }; describe('defineOllamaEmbedder', () => { + const options: OllamaPluginParams = { models: [{ name: 'test-model' }], serverAddress: 'http://localhost:3000', }; - let ai: Genkit; - beforeEach(() => { - ai = genkit({ - plugins: [ - ollama({ - serverAddress: 'http://localhost:3000', - }), - ], - }); - }); - it('should successfully return embeddings', async () => { - const embedder = defineOllamaEmbedder(ai, { + const embedder = defineOllamaEmbedder({ name: 'test-embedder', modelName: 'test-model', dimensions: 123, options, }); - const result = await ai.embed({ - embedder, - content: 'Hello, world!', + const result = await embedder({ + input: [{ content: [{ text: 'Hello, world!' }] }], }); - assert.deepStrictEqual(result, [{ embedding: [0.1, 0.2, 0.3] }]); + assert.deepStrictEqual(result, { embeddings: [{ embedding: [0.1, 0.2, 0.3] }] }); }); it('should handle API errors correctly', async () => { - const embedder = defineOllamaEmbedder(ai, { + const embedder = defineOllamaEmbedder({ name: 'test-embedder', modelName: 'test-model', dimensions: 123, @@ -81,9 +69,8 @@ describe('defineOllamaEmbedder', () => { }); await assert.rejects( async () => { - await ai.embed({ - embedder, - content: 'fail', + await embedder({ + input: [{ content: [{ text: 'fail' }] }], }); }, (error) => { diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index 3099f4f0c3..268b74d4ce 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -58,16 +58,15 @@ const MAGIC_WORD = 'sunnnnnnny'; global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/api/chat')) { - // For basic calls without tools, return the end response const body = JSON.parse((options?.body as string) || '{}'); + + // For basic calls without tools, return the end response if (!body.tools || body.tools.length === 0) { return new Response(JSON.stringify(MOCK_END_RESPONSE)); } - // For tool calls, check if magic word is present - if (options?.body && JSON.stringify(options.body).includes(MAGIC_WORD)) { - return new Response(JSON.stringify(MOCK_END_RESPONSE)); - } - return new Response(JSON.stringify(MOCK_TOOL_CALL_RESPONSE)); + + // For tool calls, return the end response directly (simplified for v2) + return new Response(JSON.stringify(MOCK_END_RESPONSE)); } throw new Error('Unknown API endpoint'); }; @@ -90,7 +89,27 @@ describe('ollama models', () => { model: 'ollama/test-model', prompt: 'Hello', }); - assert.ok(result.message.content[0].text === 'The weather is sunny'); + assert.ok(result.message?.content[0]?.text === 'The weather is sunny'); + }); + + it('should successfully return tool call response', async () => { + const get_current_weather = ai.defineTool( + { + name: 'get_current_weather', + description: 'gets weather', + inputSchema: z.object({ format: z.string(), location: z.string() }), + }, + async () => { + return MAGIC_WORD; + } + ); + + const result = await ai.generate({ + model: 'ollama/test-model', + prompt: 'Hello', + tools: [get_current_weather], + }); + assert.ok(result.message?.content[0]?.text === 'The weather is sunny'); }); it('should throw for primitive tools', async () => { From 1dd55cedf32ac1bc99932d97bf58f0e2f49dd4f0 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Thu, 18 Sep 2025 14:45:56 +0300 Subject: [PATCH 04/23] feat(js/plugins/ollama): migrate ollama plugin to v2 plugins API --- js/plugins/ollama/src/embeddings.ts | 11 +- js/plugins/ollama/src/index.ts | 125 +++------------------ js/plugins/ollama/tests/embeddings_test.ts | 7 +- js/plugins/ollama/tests/model_test.ts | 26 +---- 4 files changed, 31 insertions(+), 138 deletions(-) diff --git a/js/plugins/ollama/src/embeddings.ts b/js/plugins/ollama/src/embeddings.ts index 2f36a200f7..462df9b438 100644 --- a/js/plugins/ollama/src/embeddings.ts +++ b/js/plugins/ollama/src/embeddings.ts @@ -13,8 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { embedder } from 'genkit/plugin'; import type { Document, EmbedderAction } from 'genkit'; +import { embedder } from 'genkit/plugin'; import type { EmbedRequest, EmbedResponse } from 'ollama'; import type { DefineOllamaEmbeddingParams, RequestHeaders } from './types.js'; @@ -60,9 +60,12 @@ export async function toOllamaEmbedRequest( }; } -export function defineOllamaEmbedder( - { name, modelName, dimensions, options }: DefineOllamaEmbeddingParams -): EmbedderAction { +export function defineOllamaEmbedder({ + name, + modelName, + dimensions, + options, +}: DefineOllamaEmbeddingParams): EmbedderAction { return embedder( { name: `ollama/${name}`, diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index a453d4525b..c57470d693 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -38,15 +38,14 @@ import { type ToolDefinition, } from 'genkit/model'; import { - embedder, genkitPluginV2, model, type GenkitPluginV2, type ResolvableAction, } from 'genkit/plugin'; +import { defineOllamaEmbedder } from './embeddings.js'; import type { ApiType, - EmbeddingModelDefinition, ListLocalModelsResponse, LocalModel, Message, @@ -86,7 +85,7 @@ const GENERIC_MODEL_INFO = { const DEFAULT_OLLAMA_SERVER_ADDRESS = 'http://localhost:11434'; -async function createOllamaModel( +function createOllamaModel( modelDef: ModelDefinition, serverAddress: string, requestHeaders?: RequestHeaders @@ -102,9 +101,9 @@ async function createOllamaModel( tools: modelDef.supports?.tools, }, }, - async (input, opts) => { + async (request, opts) => { const { topP, topK, stopSequences, maxOutputTokens, ...rest } = - input.config as any; + request.config as any; const options: Record = { ...rest }; if (topP !== undefined) { options.top_p = topP; @@ -119,20 +118,20 @@ async function createOllamaModel( options.num_predict = maxOutputTokens; } const type = modelDef.type ?? 'chat'; - const request = toOllamaRequest( + const ollamaRequest = toOllamaRequest( modelDef.name, - input, + request, options, type, - !!opts.sendChunk + !!opts ); - logger.debug(request, `ollama request (${type})`); + logger.debug(ollamaRequest, `ollama request (${type})`); const extraHeaders = await getHeaders( serverAddress, requestHeaders, modelDef, - input + request ); let res; try { @@ -140,7 +139,7 @@ async function createOllamaModel( serverAddress + (type === 'chat' ? '/api/chat' : '/api/generate'), { method: 'POST', - body: JSON.stringify(request), + body: JSON.stringify(ollamaRequest), headers: { 'Content-Type': 'application/json', ...extraHeaders, @@ -196,63 +195,13 @@ async function createOllamaModel( return { message, - usage: getBasicUsageStats(input.messages, message), + usage: getBasicUsageStats(request.messages, message), finishReason: 'stop', } as GenerateResponseData; } ); } -async function createOllamaEmbedder( - modelDef: EmbeddingModelDefinition, - serverAddress: string, - requestHeaders?: RequestHeaders -) { - return embedder( - { - name: modelDef.name, - info: { - label: 'Ollama Embedding - ' + modelDef.name, - dimensions: modelDef.dimensions, - supports: { - input: ['text'], - }, - }, - }, - async (input, config) => { - const { url, requestPayload, headers } = await toOllamaEmbedRequest( - modelDef.name, - modelDef.dimensions, - input.input, - serverAddress, - requestHeaders - ); - - const response: Response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(requestPayload), - }); - - if (!response.ok) { - const errMsg = (await response.json()).error?.message || ''; - throw new Error( - `Error fetching embedding from Ollama: ${response.statusText}. ${errMsg}` - ); - } - - const payload = (await response.json()) as any; - - const embeddings: { embedding: number[] }[] = []; - - for (const embedding of payload.embeddings) { - embeddings.push({ embedding }); - } - return { embeddings }; - } - ); -} - async function listActions( serverAddress: string, requestHeaders?: RequestHeaders @@ -296,11 +245,12 @@ function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { if (params?.embedders) { for (const embedder of params.embedders) { actions.push( - await createOllamaEmbedder( - embedder, - serverAddress, - params.requestHeaders - ) + defineOllamaEmbedder({ + name: embedder.name, + modelName: embedder.name, + dimensions: embedder.dimensions, + options: params, + }) ); } } @@ -379,46 +329,7 @@ export const OllamaConfigSchema = GenerationCommonConfigSchema.extend({ .optional(), }); -async function toOllamaEmbedRequest( - modelName: string, - dimensions: number, - documents: any[], - serverAddress: string, - requestHeaders?: RequestHeaders -): Promise<{ - url: string; - requestPayload: any; - headers: Record; -}> { - const requestPayload = { - model: modelName, - input: documents.map((doc) => doc.text), - }; - - const extraHeaders = requestHeaders - ? typeof requestHeaders === 'function' - ? await requestHeaders({ - serverAddress, - model: { - name: modelName, - dimensions, - }, - embedRequest: requestPayload, - }) - : requestHeaders - : {}; - - const headers = { - 'Content-Type': 'application/json', - ...extraHeaders, - }; - - return { - url: `${serverAddress}/api/embed`, - requestPayload, - headers, - }; -} +// toOllamaEmbedRequest is now in embeddings.ts function parseMessage(response: any, type: ApiType): MessageData { if (response.error) { diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index 2d3b416174..d6b3ff847c 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -14,10 +14,10 @@ * limitations under the License. */ import * as assert from 'assert'; +import 'genkit'; import { describe, it } from 'node:test'; import { defineOllamaEmbedder } from '../src/embeddings.js'; import type { OllamaPluginParams } from '../src/types.js'; -import 'genkit'; // Mock fetch to simulate API responses global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { @@ -41,7 +41,6 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { }; describe('defineOllamaEmbedder', () => { - const options: OllamaPluginParams = { models: [{ name: 'test-model' }], serverAddress: 'http://localhost:3000', @@ -57,7 +56,9 @@ describe('defineOllamaEmbedder', () => { const result = await embedder({ input: [{ content: [{ text: 'Hello, world!' }] }], }); - assert.deepStrictEqual(result, { embeddings: [{ embedding: [0.1, 0.2, 0.3] }] }); + assert.deepStrictEqual(result, { + embeddings: [{ embedding: [0.1, 0.2, 0.3] }], + }); }); it('should handle API errors correctly', async () => { diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index 268b74d4ce..e4e0c93f5b 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -19,28 +19,6 @@ import { beforeEach, describe, it } from 'node:test'; import { ollama } from '../src/index.js'; import type { OllamaPluginParams } from '../src/types.js'; -const MOCK_TOOL_CALL_RESPONSE = { - model: 'llama3.2', - created_at: '2024-07-22T20:33:28.123648Z', - message: { - role: 'assistant', - content: '', - tool_calls: [ - { - function: { - name: 'get_current_weather', - arguments: { - format: 'celsius', - location: 'Paris, FR', - }, - }, - }, - ], - }, - done_reason: 'stop', - done: true, -}; - const MOCK_END_RESPONSE = { model: 'llama3.2', created_at: '2024-07-22T20:33:28.123648Z', @@ -59,12 +37,12 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/api/chat')) { const body = JSON.parse((options?.body as string) || '{}'); - + // For basic calls without tools, return the end response if (!body.tools || body.tools.length === 0) { return new Response(JSON.stringify(MOCK_END_RESPONSE)); } - + // For tool calls, return the end response directly (simplified for v2) return new Response(JSON.stringify(MOCK_END_RESPONSE)); } From 16f53ce49b026154208687a09ea6697510ef899c Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Thu, 18 Sep 2025 15:10:57 +0300 Subject: [PATCH 05/23] chore(js/plugins/ollama): add back mock tool call response --- js/plugins/ollama/src/types.ts | 3 +-- js/plugins/ollama/tests/embeddings_test.ts | 1 - js/plugins/ollama/tests/model_test.ts | 29 +++++++++++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/js/plugins/ollama/src/types.ts b/js/plugins/ollama/src/types.ts index f1e7e10ecc..b166b3ca3e 100644 --- a/js/plugins/ollama/src/types.ts +++ b/js/plugins/ollama/src/types.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { z } from 'genkit'; -import type { GenerateRequest } from 'genkit/model'; +import { z, type GenerateRequest } from 'genkit'; import type { EmbedRequest } from 'ollama'; // Define possible API types export type ApiType = 'chat' | 'generate'; diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index d6b3ff847c..47be5c7b73 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ import * as assert from 'assert'; -import 'genkit'; import { describe, it } from 'node:test'; import { defineOllamaEmbedder } from '../src/embeddings.js'; import type { OllamaPluginParams } from '../src/types.js'; diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index e4e0c93f5b..bb918e1951 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -19,6 +19,28 @@ import { beforeEach, describe, it } from 'node:test'; import { ollama } from '../src/index.js'; import type { OllamaPluginParams } from '../src/types.js'; +const MOCK_TOOL_CALL_RESPONSE = { + model: 'llama3.2', + created_at: '2024-07-22T20:33:28.123648Z', + message: { + role: 'assistant', + content: '', + tool_calls: [ + { + function: { + name: 'get_current_weather', + arguments: { + format: 'celsius', + location: 'Paris, FR', + }, + }, + }, + ], + }, + done_reason: 'stop', + done: true, +}; + const MOCK_END_RESPONSE = { model: 'llama3.2', created_at: '2024-07-22T20:33:28.123648Z', @@ -43,7 +65,7 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { return new Response(JSON.stringify(MOCK_END_RESPONSE)); } - // For tool calls, return the end response directly (simplified for v2) + // For tool calls return new Response(JSON.stringify(MOCK_END_RESPONSE)); } throw new Error('Unknown API endpoint'); @@ -67,7 +89,7 @@ describe('ollama models', () => { model: 'ollama/test-model', prompt: 'Hello', }); - assert.ok(result.message?.content[0]?.text === 'The weather is sunny'); + assert.ok(result.text === 'The weather is sunny'); }); it('should successfully return tool call response', async () => { @@ -87,7 +109,8 @@ describe('ollama models', () => { prompt: 'Hello', tools: [get_current_weather], }); - assert.ok(result.message?.content[0]?.text === 'The weather is sunny'); + + assert.ok(result.text === 'The weather is sunny'); }); it('should throw for primitive tools', async () => { From 755baceeb6e6e5f2e387b3eaabb14e464dee1abb Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 30 Sep 2025 16:52:40 +0300 Subject: [PATCH 06/23] chore(js/plugins/ollama): input => request --- js/plugins/ollama/src/embeddings.ts | 4 +- js/plugins/ollama/src/index.ts | 142 ++++++++++++++-------------- 2 files changed, 73 insertions(+), 73 deletions(-) diff --git a/js/plugins/ollama/src/embeddings.ts b/js/plugins/ollama/src/embeddings.ts index 462df9b438..4c0e212216 100644 --- a/js/plugins/ollama/src/embeddings.ts +++ b/js/plugins/ollama/src/embeddings.ts @@ -78,13 +78,13 @@ export function defineOllamaEmbedder({ }, }, }, - async (input, config) => { + async (request, config) => { const serverAddress = options.serverAddress || 'http://localhost:11434'; const { url, requestPayload, headers } = await toOllamaEmbedRequest( modelName, dimensions, - input.input, + request.input, serverAddress, options.requestHeaders ); diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index c57470d693..28bf0569b4 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -85,6 +85,77 @@ const GENERIC_MODEL_INFO = { const DEFAULT_OLLAMA_SERVER_ADDRESS = 'http://localhost:11434'; +async function listActions( + serverAddress: string, + requestHeaders?: RequestHeaders +): Promise { + const models = await listLocalModels(serverAddress, requestHeaders); + return ( + models + // naively filter out embedders, unfortunately there's no better way. + ?.filter((m) => m.model && !m.model.includes('embed')) + .map((m) => + modelActionMetadata({ + name: m.model, + info: GENERIC_MODEL_INFO, + }) + ) || [] + ); +} + +async function listLocalModels( + serverAddress: string, + requestHeaders?: RequestHeaders +): Promise { + // We call the ollama list local models api: https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models + let res; + try { + res = await fetch(serverAddress + '/api/tags', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...(await getHeaders(serverAddress, requestHeaders)), + }, + }); + } catch (e) { + throw new Error(`Make sure the Ollama server is running.`, { + cause: e, + }); + } + const modelResponse = JSON.parse(await res.text()) as ListLocalModelsResponse; + return modelResponse.models; +} + +/** + * Please refer to: https://github.com/ollama/ollama/blob/main/docs/modelfile.md + * for further information. + */ +export const OllamaConfigSchema = GenerationCommonConfigSchema.extend({ + temperature: z + .number() + .min(0.0) + .max(1.0) + .describe( + GenerationCommonConfigDescriptions.temperature + + ' The default value is 0.8.' + ) + .optional(), + topK: z + .number() + .describe( + GenerationCommonConfigDescriptions.topK + ' The default value is 40.' + ) + .optional(), + topP: z + .number() + .min(0) + .max(1.0) + .describe( + GenerationCommonConfigDescriptions.topP + ' The default value is 0.9.' + ) + .optional(), +}); + function createOllamaModel( modelDef: ModelDefinition, serverAddress: string, @@ -202,24 +273,6 @@ function createOllamaModel( ); } -async function listActions( - serverAddress: string, - requestHeaders?: RequestHeaders -): Promise { - const models = await listLocalModels(serverAddress, requestHeaders); - return ( - models - // naively filter out embedders, unfortunately there's no better way. - ?.filter((m) => m.model && !m.model.includes('embed')) - .map((m) => - modelActionMetadata({ - name: m.model, - info: GENERIC_MODEL_INFO, - }) - ) || [] - ); -} - function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { if (!params) { params = {}; @@ -276,59 +329,6 @@ function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { }); } -async function listLocalModels( - serverAddress: string, - requestHeaders?: RequestHeaders -): Promise { - // We call the ollama list local models api: https://github.com/ollama/ollama/blob/main/docs/api.md#list-local-models - let res; - try { - res = await fetch(serverAddress + '/api/tags', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - ...(await getHeaders(serverAddress, requestHeaders)), - }, - }); - } catch (e) { - throw new Error(`Make sure the Ollama server is running.`, { - cause: e, - }); - } - const modelResponse = JSON.parse(await res.text()) as ListLocalModelsResponse; - return modelResponse.models; -} - -/** - * Please refer to: https://github.com/ollama/ollama/blob/main/docs/modelfile.md - * for further information. - */ -export const OllamaConfigSchema = GenerationCommonConfigSchema.extend({ - temperature: z - .number() - .min(0.0) - .max(1.0) - .describe( - GenerationCommonConfigDescriptions.temperature + - ' The default value is 0.8.' - ) - .optional(), - topK: z - .number() - .describe( - GenerationCommonConfigDescriptions.topK + ' The default value is 40.' - ) - .optional(), - topP: z - .number() - .min(0) - .max(1.0) - .describe( - GenerationCommonConfigDescriptions.topP + ' The default value is 0.9.' - ) - .optional(), -}); - // toOllamaEmbedRequest is now in embeddings.ts function parseMessage(response: any, type: ApiType): MessageData { From c32af5871a425b2606357d6928eba9c864ac7964 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 30 Sep 2025 16:45:18 +0100 Subject: [PATCH 07/23] refactor(js/plugins/ollama): extract constants to own module, fix some tests --- js/plugins/ollama/src/constants.ts | 34 +++++++++++++++ js/plugins/ollama/src/embeddings.ts | 7 ++- js/plugins/ollama/src/index.ts | 33 ++++---------- .../ollama/tests/embedding_live_test.ts | 7 +++ js/plugins/ollama/tests/embeddings_test.ts | 43 +++++++++++++------ 5 files changed, 87 insertions(+), 37 deletions(-) create mode 100644 js/plugins/ollama/src/constants.ts diff --git a/js/plugins/ollama/src/constants.ts b/js/plugins/ollama/src/constants.ts new file mode 100644 index 0000000000..8efd0d9da8 --- /dev/null +++ b/js/plugins/ollama/src/constants.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ModelInfo } from 'genkit/model'; + +export const ANY_JSON_SCHEMA: Record = { + $schema: 'http://json-schema.org/draft-07/schema#', +}; + +export const GENERIC_MODEL_INFO = { + supports: { + multiturn: true, + media: true, + tools: true, + toolChoice: true, + systemRole: true, + constrained: 'all', + }, +} as ModelInfo; + +export const DEFAULT_OLLAMA_SERVER_ADDRESS = 'http://localhost:11434'; diff --git a/js/plugins/ollama/src/embeddings.ts b/js/plugins/ollama/src/embeddings.ts index 4c0e212216..3459a1d63d 100644 --- a/js/plugins/ollama/src/embeddings.ts +++ b/js/plugins/ollama/src/embeddings.ts @@ -16,6 +16,7 @@ import type { Document, EmbedderAction } from 'genkit'; import { embedder } from 'genkit/plugin'; import type { EmbedRequest, EmbedResponse } from 'ollama'; +import { DEFAULT_OLLAMA_SERVER_ADDRESS } from './constants.js'; import type { DefineOllamaEmbeddingParams, RequestHeaders } from './types.js'; export async function toOllamaEmbedRequest( @@ -79,7 +80,11 @@ export function defineOllamaEmbedder({ }, }, async (request, config) => { - const serverAddress = options.serverAddress || 'http://localhost:11434'; + console.log('request.options', request.options); + // request.options; might be the equivalent of config now + + const serverAddress = + options.serverAddress || DEFAULT_OLLAMA_SERVER_ADDRESS; const { url, requestPayload, headers } = await toOllamaEmbedRequest( modelName, diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 28bf0569b4..6950572b59 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -34,7 +34,6 @@ import { type GenerateRequest, type GenerateResponseData, type MessageData, - type ModelInfo, type ToolDefinition, } from 'genkit/model'; import { @@ -43,6 +42,11 @@ import { type GenkitPluginV2, type ResolvableAction, } from 'genkit/plugin'; +import { + ANY_JSON_SCHEMA, + DEFAULT_OLLAMA_SERVER_ADDRESS, + GENERIC_MODEL_INFO, +} from './constants.js'; import { defineOllamaEmbedder } from './embeddings.js'; import type { ApiType, @@ -68,23 +72,6 @@ export type OllamaPlugin = { embedder(name: string, config?: Record): EmbedderReference; }; -const ANY_JSON_SCHEMA: Record = { - $schema: 'http://json-schema.org/draft-07/schema#', -}; - -const GENERIC_MODEL_INFO = { - supports: { - multiturn: true, - media: true, - tools: true, - toolChoice: true, - systemRole: true, - constrained: 'all', - }, -} as ModelInfo; - -const DEFAULT_OLLAMA_SERVER_ADDRESS = 'http://localhost:11434'; - async function listActions( serverAddress: string, requestHeaders?: RequestHeaders @@ -284,25 +271,25 @@ function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { return genkitPluginV2({ name: 'ollama', - async init() { + init() { const actions: ResolvableAction[] = []; if (params?.models) { for (const model of params.models) { actions.push( - await createOllamaModel(model, serverAddress, params.requestHeaders) + createOllamaModel(model, serverAddress, params.requestHeaders) ); } } - if (params?.embedders) { + if (params?.embedders && params.serverAddress) { for (const embedder of params.embedders) { actions.push( defineOllamaEmbedder({ name: embedder.name, modelName: embedder.name, dimensions: embedder.dimensions, - options: params, + options: { ...params, serverAddress }, }) ); } @@ -329,8 +316,6 @@ function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { }); } -// toOllamaEmbedRequest is now in embeddings.ts - function parseMessage(response: any, type: ApiType): MessageData { if (response.error) { throw new Error(response.error); diff --git a/js/plugins/ollama/tests/embedding_live_test.ts b/js/plugins/ollama/tests/embedding_live_test.ts index 45b1e19c6a..e6a5b516fb 100644 --- a/js/plugins/ollama/tests/embedding_live_test.ts +++ b/js/plugins/ollama/tests/embedding_live_test.ts @@ -17,6 +17,13 @@ import * as assert from 'assert'; import { describe, it } from 'node:test'; import { defineOllamaEmbedder } from '../src/embeddings.js'; // Adjust the import path as necessary import type { OllamaPluginParams } from '../src/types.js'; // Adjust the import path as necessary + +// TODO: see if this can be removed? +import { z } from 'genkit'; + +// literally just to stop linting from removing the import +const mySchemaExample = z.string(); + // Utility function to parse command-line arguments function parseArgs() { const args = process.argv.slice(2); diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index 47be5c7b73..6cded6f129 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as assert from 'assert'; -import { describe, it } from 'node:test'; +import { Genkit, genkit } from 'genkit'; +import assert from 'node:assert'; +import { beforeEach, describe, it } from 'node:test'; +import { ollama } from '../src'; import { defineOllamaEmbedder } from '../src/embeddings.js'; import type { OllamaPluginParams } from '../src/types.js'; @@ -39,25 +41,36 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { throw new Error('Unknown API endpoint'); }; +const options: OllamaPluginParams = { + models: [{ name: 'test-model' }], + serverAddress: 'http://localhost:3000', +}; + +// TODO: also have tests that do not need initializing genkit + describe('defineOllamaEmbedder', () => { - const options: OllamaPluginParams = { - models: [{ name: 'test-model' }], - serverAddress: 'http://localhost:3000', - }; + let ai: Genkit; + + beforeEach(() => { + ai = genkit({ + plugins: [ollama(options)], + }); + }); - it('should successfully return embeddings', async () => { + it.only('should successfully return embeddings', async () => { const embedder = defineOllamaEmbedder({ name: 'test-embedder', modelName: 'test-model', dimensions: 123, options, }); - const result = await embedder({ - input: [{ content: [{ text: 'Hello, world!' }] }], - }); - assert.deepStrictEqual(result, { - embeddings: [{ embedding: [0.1, 0.2, 0.3] }], + + const result = await ai.embed({ + embedder, + content: 'Hello, world!', }); + + assert.deepStrictEqual(result, [{ embedding: [0.1, 0.2, 0.3] }]); }); it('should handle API errors correctly', async () => { @@ -67,8 +80,14 @@ describe('defineOllamaEmbedder', () => { dimensions: 123, options, }); + await assert.rejects( async () => { + await ai.embed({ + embedder, + content: 'fail', + }); + await embedder({ input: [{ content: [{ text: 'fail' }] }], }); From 9ad041640265cc11651ec064b461ee55ad8e4dfc Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Mon, 6 Oct 2025 12:29:45 +0300 Subject: [PATCH 08/23] (js/plugins/ollama): ensure all embeddings tests are executed --- js/plugins/ollama/tests/embeddings_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index 6cded6f129..fbc0427a16 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -57,7 +57,7 @@ describe('defineOllamaEmbedder', () => { }); }); - it.only('should successfully return embeddings', async () => { + it('should successfully return embeddings', async () => { const embedder = defineOllamaEmbedder({ name: 'test-embedder', modelName: 'test-model', From ebe61434d2f1dea934d2405d60ea9a9628c54dc5 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Mon, 6 Oct 2025 13:01:02 +0300 Subject: [PATCH 09/23] tests(js/plugins/ollama): add tests to cover cases when genkit isnt initialized --- js/plugins/ollama/tests/embeddings_test.ts | 73 +++++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index fbc0427a16..9cf1a86625 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -31,10 +31,14 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { json: async () => ({}), } as Response; } + + const body = options?.body ? JSON.parse(options.body as string) : {}; + const inputCount = body.input ? body.input.length : 1; + return { ok: true, json: async () => ({ - embeddings: [[0.1, 0.2, 0.3]], // Example embedding values + embeddings: Array(inputCount).fill([0.1, 0.2, 0.3]), // Return embedding for each input }), } as Response; } @@ -46,9 +50,72 @@ const options: OllamaPluginParams = { serverAddress: 'http://localhost:3000', }; -// TODO: also have tests that do not need initializing genkit +describe('defineOllamaEmbedder (without genkit initialization)', () => { + it('should successfully return embeddings when called directly', async () => { + const embedder = defineOllamaEmbedder({ + name: 'test-embedder', + modelName: 'test-model', + dimensions: 123, + options, + }); + + const result = await embedder({ + input: [{ content: [{ text: 'Hello, world!' }] }], + }); + + assert.deepStrictEqual(result, { embeddings: [{ embedding: [0.1, 0.2, 0.3] }] }); + }); + + it('should handle API errors correctly when called directly', async () => { + const embedder = defineOllamaEmbedder({ + name: 'test-embedder', + modelName: 'test-model', + dimensions: 123, + options, + }); + + await assert.rejects( + async () => { + await embedder({ + input: [{ content: [{ text: 'fail' }] }], + }); + }, + (error) => { + assert.ok(error instanceof Error); + assert.strictEqual( + error.message, + 'Error fetching embedding from Ollama: Internal Server Error. ' + ); + return true; + } + ); + }); + + it('should handle multiple documents', async () => { + const embedder = defineOllamaEmbedder({ + name: 'test-embedder', + modelName: 'test-model', + dimensions: 123, + options, + }); + + const result = await embedder({ + input: [ + { content: [{ text: 'First document' }] }, + { content: [{ text: 'Second document' }] }, + ], + }); + + assert.deepStrictEqual(result, { + embeddings: [ + { embedding: [0.1, 0.2, 0.3] }, + { embedding: [0.1, 0.2, 0.3] } + ] + }); + }); +}); -describe('defineOllamaEmbedder', () => { +describe('defineOllamaEmbedder (with genkit initialization)', () => { let ai: Genkit; beforeEach(() => { From 9bfbee9dda9369c3880572238368ef402d848c25 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Mon, 6 Oct 2025 13:14:12 +0300 Subject: [PATCH 10/23] tests(js/plugins/ollama): add live tests --- .../ollama/tests/embedding_live_test.ts | 282 +++++++++++++++++- js/plugins/ollama/tests/embeddings_test.ts | 14 +- 2 files changed, 280 insertions(+), 16 deletions(-) diff --git a/js/plugins/ollama/tests/embedding_live_test.ts b/js/plugins/ollama/tests/embedding_live_test.ts index e6a5b516fb..c155c73020 100644 --- a/js/plugins/ollama/tests/embedding_live_test.ts +++ b/js/plugins/ollama/tests/embedding_live_test.ts @@ -14,16 +14,12 @@ * limitations under the License. */ import * as assert from 'assert'; -import { describe, it } from 'node:test'; +import { Genkit, genkit } from 'genkit'; +import { beforeEach, describe, it } from 'node:test'; import { defineOllamaEmbedder } from '../src/embeddings.js'; // Adjust the import path as necessary +import { ollama } from '../src/index.js'; import type { OllamaPluginParams } from '../src/types.js'; // Adjust the import path as necessary -// TODO: see if this can be removed? -import { z } from 'genkit'; - -// literally just to stop linting from removing the import -const mySchemaExample = z.string(); - // Utility function to parse command-line arguments function parseArgs() { const args = process.argv.slice(2); @@ -36,21 +32,287 @@ function parseArgs() { return { serverAddress, modelName }; } const { serverAddress, modelName } = parseArgs(); -describe('defineOllamaEmbedder - Live Tests', () => { +describe('defineOllamaEmbedder - Live Tests (without genkit)', () => { const options: OllamaPluginParams = { models: [{ name: modelName }], serverAddress, }; - it('should successfully return embeddings', async () => { + + it('should successfully return embeddings for single document', async () => { const embedder = defineOllamaEmbedder({ name: 'live-test-embedder', - modelName: 'nomic-embed-text', + modelName: modelName, dimensions: 768, options, }); + const result = await embedder({ input: [{ content: [{ text: 'Hello, world!' }] }], }); + + assert.strictEqual(result.embeddings.length, 1); assert.strictEqual(result.embeddings[0].embedding.length, 768); + assert.ok(Array.isArray(result.embeddings[0].embedding)); + assert.ok( + result.embeddings[0].embedding.every((val) => typeof val === 'number') + ); + }); + + it('should successfully return embeddings for multiple documents', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-multi', + modelName: modelName, + dimensions: 768, + options, + }); + + const result = await embedder({ + input: [ + { content: [{ text: 'First document about machine learning' }] }, + { + content: [{ text: 'Second document about artificial intelligence' }], + }, + { content: [{ text: 'Third document about neural networks' }] }, + ], + }); + + assert.strictEqual(result.embeddings.length, 3); + result.embeddings.forEach((embedding, index) => { + assert.strictEqual( + embedding.embedding.length, + 768, + `Embedding ${index} should have 768 dimensions` + ); + assert.ok( + Array.isArray(embedding.embedding), + `Embedding ${index} should be an array` + ); + assert.ok( + embedding.embedding.every((val) => typeof val === 'number'), + `Embedding ${index} should contain only numbers` + ); + }); + }); + + it('should return different embeddings for different texts', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-different', + modelName: modelName, + dimensions: 768, + options, + }); + + const result1 = await embedder({ + input: [ + { content: [{ text: 'The quick brown fox jumps over the lazy dog' }] }, + ], + }); + + const result2 = await embedder({ + input: [ + { + content: [ + { text: 'Machine learning is a subset of artificial intelligence' }, + ], + }, + ], + }); + + assert.strictEqual(result1.embeddings.length, 1); + assert.strictEqual(result2.embeddings.length, 1); + + const embedding1 = result1.embeddings[0].embedding; + const embedding2 = result2.embeddings[0].embedding; + + assert.notDeepStrictEqual( + embedding1, + embedding2, + 'Different texts should produce different embeddings' + ); + + assert.strictEqual(embedding1.length, 768); + assert.strictEqual(embedding2.length, 768); + }); + + it('should handle empty text gracefully', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-empty', + modelName: modelName, + dimensions: 768, + options, + }); + + const result = await embedder({ + input: [{ content: [{ text: '' }] }], + }); + + assert.strictEqual(result.embeddings.length, 1); + assert.strictEqual(result.embeddings[0].embedding.length, 768); + assert.ok(Array.isArray(result.embeddings[0].embedding)); + }); + + it('should handle long text', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-long', + modelName: modelName, + dimensions: 768, + options, + }); + + const longText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(100); + + const result = await embedder({ + input: [{ content: [{ text: longText }] }], + }); + + assert.strictEqual(result.embeddings.length, 1); + assert.strictEqual(result.embeddings[0].embedding.length, 768); + assert.ok(Array.isArray(result.embeddings[0].embedding)); + assert.ok( + result.embeddings[0].embedding.every((val) => typeof val === 'number') + ); + }); +}); + +describe('defineOllamaEmbedder - Live Tests (with genkit)', () => { + let ai: Genkit; + const options: OllamaPluginParams = { + models: [{ name: modelName }], + serverAddress, + }; + + beforeEach(() => { + ai = genkit({ + plugins: [ollama(options)], + }); + }); + + it('should successfully return embeddings through genkit', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-genkit', + modelName: modelName, + dimensions: 768, + options, + }); + + const result = await ai.embed({ + embedder, + content: 'Hello, world!', + }); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].embedding.length, 768); + assert.ok(Array.isArray(result[0].embedding)); + assert.ok(result[0].embedding.every((val) => typeof val === 'number')); + }); + + it('should handle multiple documents through genkit', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-genkit-multi', + modelName: modelName, + dimensions: 768, + options, + }); + + const result = await ai.embedMany({ + embedder, + content: [ + 'First document about machine learning', + 'Second document about artificial intelligence', + 'Third document about neural networks', + ], + }); + + assert.strictEqual(result.length, 3); + result.forEach((embedding, index) => { + assert.strictEqual( + embedding.embedding.length, + 768, + `Embedding ${index} should have 768 dimensions` + ); + assert.ok( + Array.isArray(embedding.embedding), + `Embedding ${index} should be an array` + ); + assert.ok( + embedding.embedding.every((val) => typeof val === 'number'), + `Embedding ${index} should contain only numbers` + ); + }); + }); + + it('should return different embeddings for different texts through genkit', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-genkit-different', + modelName: modelName, + dimensions: 768, + options, + }); + + const result1 = await ai.embed({ + embedder, + content: 'The quick brown fox jumps over the lazy dog', + }); + + const result2 = await ai.embed({ + embedder, + content: 'Machine learning is a subset of artificial intelligence', + }); + + assert.strictEqual(result1.length, 1); + assert.strictEqual(result2.length, 1); + + const embedding1 = result1[0].embedding; + const embedding2 = result2[0].embedding; + + assert.notDeepStrictEqual( + embedding1, + embedding2, + 'Different texts should produce different embeddings' + ); + + assert.strictEqual(embedding1.length, 768); + assert.strictEqual(embedding2.length, 768); + }); + + it('should handle empty text gracefully through genkit', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-genkit-empty', + modelName: modelName, + dimensions: 768, + options, + }); + + const result = await ai.embed({ + embedder, + content: '', + }); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].embedding.length, 768); + assert.ok(Array.isArray(result[0].embedding)); + }); + + it('should handle long text through genkit', async () => { + const embedder = defineOllamaEmbedder({ + name: 'live-test-embedder-genkit-long', + modelName: modelName, + dimensions: 768, + options, + }); + + const longText = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(100); + + const result = await ai.embed({ + embedder, + content: longText, + }); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].embedding.length, 768); + assert.ok(Array.isArray(result[0].embedding)); + assert.ok(result[0].embedding.every((val) => typeof val === 'number')); }); }); diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index 9cf1a86625..905ca2edb7 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -31,10 +31,10 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { json: async () => ({}), } as Response; } - + const body = options?.body ? JSON.parse(options.body as string) : {}; const inputCount = body.input ? body.input.length : 1; - + return { ok: true, json: async () => ({ @@ -63,7 +63,9 @@ describe('defineOllamaEmbedder (without genkit initialization)', () => { input: [{ content: [{ text: 'Hello, world!' }] }], }); - assert.deepStrictEqual(result, { embeddings: [{ embedding: [0.1, 0.2, 0.3] }] }); + assert.deepStrictEqual(result, { + embeddings: [{ embedding: [0.1, 0.2, 0.3] }], + }); }); it('should handle API errors correctly when called directly', async () => { @@ -106,11 +108,11 @@ describe('defineOllamaEmbedder (without genkit initialization)', () => { ], }); - assert.deepStrictEqual(result, { + assert.deepStrictEqual(result, { embeddings: [ { embedding: [0.1, 0.2, 0.3] }, - { embedding: [0.1, 0.2, 0.3] } - ] + { embedding: [0.1, 0.2, 0.3] }, + ], }); }); }); From 84a81f8cb5a21dfa50a22ea93fc6b9a93c3ddf32 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 6 Oct 2025 11:54:58 +0100 Subject: [PATCH 11/23] test(js/plugins/ollama): update model tests --- js/plugins/ollama/tests/model_test.ts | 38 ++++++++++++--------------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index bb918e1951..44ccfb056f 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -66,7 +66,7 @@ global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { } // For tool calls - return new Response(JSON.stringify(MOCK_END_RESPONSE)); + return new Response(JSON.stringify(MOCK_TOOL_CALL_RESPONSE)); } throw new Error('Unknown API endpoint'); }; @@ -92,7 +92,7 @@ describe('ollama models', () => { assert.ok(result.text === 'The weather is sunny'); }); - it('should successfully return tool call response', async () => { + it.only('should successfully return tool call response', async () => { const get_current_weather = ai.defineTool( { name: 'get_current_weather', @@ -109,38 +109,34 @@ describe('ollama models', () => { prompt: 'Hello', tools: [get_current_weather], }); - assert.ok(result.text === 'The weather is sunny'); }); - it('should throw for primitive tools', async () => { - const get_current_weather = ai.defineTool( - { - name: 'get_current_weather', - description: 'gets weather', - inputSchema: z.object({ format: z.string(), location: z.string() }), - }, - async () => { - return MAGIC_WORD; - } - ); - const fooz = ai.defineTool( + it('should throw for tools with primitive (non-object) input schema.', async () => { + // This tool will throw an error because it has a primitive (non-object) input schema. + const toolWithNonObjectInput = ai.defineTool( { - name: 'fooz', - description: 'gets fooz', + name: 'toolWithNonObjectInput', + description: 'tool with non-object input schema', inputSchema: z.string(), }, async () => { - return 1; + return 'anything'; } ); - await assert.rejects(async () => { + try { await ai.generate({ model: 'ollama/test-model', prompt: 'Hello', - tools: [get_current_weather, fooz], + tools: [toolWithNonObjectInput], }); - }); + } catch (error) { + assert.ok(error instanceof Error); + + assert.ok( + error.message.includes('Ollama only supports tools with object inputs') + ); + } }); }); From d7c8115b61cb028f35a13123a276974fd1a76eeb Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 6 Oct 2025 13:03:12 +0100 Subject: [PATCH 12/23] fix(js/plguings/ollama): change to opts.streamingRequested and improve testing --- js/plugins/ollama/src/embeddings.ts | 3 --- js/plugins/ollama/src/index.ts | 4 ++-- js/plugins/ollama/tests/model_test.ts | 24 +++++++++++++++--------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/js/plugins/ollama/src/embeddings.ts b/js/plugins/ollama/src/embeddings.ts index 3459a1d63d..ca5f2dda68 100644 --- a/js/plugins/ollama/src/embeddings.ts +++ b/js/plugins/ollama/src/embeddings.ts @@ -80,9 +80,6 @@ export function defineOllamaEmbedder({ }, }, async (request, config) => { - console.log('request.options', request.options); - // request.options; might be the equivalent of config now - const serverAddress = options.serverAddress || DEFAULT_OLLAMA_SERVER_ADDRESS; diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 6950572b59..ed40666dd6 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -181,7 +181,7 @@ function createOllamaModel( request, options, type, - !!opts + opts?.streamingRequested ); logger.debug(ollamaRequest, `ollama request (${type})`); @@ -222,7 +222,7 @@ function createOllamaModel( let message: MessageData; - if (opts.sendChunk) { + if (opts?.streamingRequested) { const reader = res.body.getReader(); const textDecoder = new TextDecoder(); let textResponse = ''; diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index 44ccfb056f..4a6598bff1 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -52,21 +52,27 @@ const MOCK_END_RESPONSE = { done: true, }; -const MAGIC_WORD = 'sunnnnnnny'; - -// Mock fetch to simulate API responses +// Mock fetch to simulate the multi-turn tool calling flow: +// 1. Initial request with tools → returns tool_calls response +// 2. Follow-up request with tool results (role='tool') → returns final answer global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/api/chat')) { const body = JSON.parse((options?.body as string) || '{}'); - // For basic calls without tools, return the end response - if (!body.tools || body.tools.length === 0) { + // Check if this request contains tool responses (second call in tool flow) + const hasToolResponses = body.messages?.some((m) => m.role === 'tool'); + if (hasToolResponses) { return new Response(JSON.stringify(MOCK_END_RESPONSE)); } - // For tool calls - return new Response(JSON.stringify(MOCK_TOOL_CALL_RESPONSE)); + // Initial request with tools → return tool call + if (body.tools && body.tools.length > 0) { + return new Response(JSON.stringify(MOCK_TOOL_CALL_RESPONSE)); + } + + // Basic request without tools → return final response + return new Response(JSON.stringify(MOCK_END_RESPONSE)); } throw new Error('Unknown API endpoint'); }; @@ -92,7 +98,7 @@ describe('ollama models', () => { assert.ok(result.text === 'The weather is sunny'); }); - it.only('should successfully return tool call response', async () => { + it('should successfully return tool call response', async () => { const get_current_weather = ai.defineTool( { name: 'get_current_weather', @@ -100,7 +106,7 @@ describe('ollama models', () => { inputSchema: z.object({ format: z.string(), location: z.string() }), }, async () => { - return MAGIC_WORD; + return 'sunny'; } ); From 959213742aef6cc175bd44ca3b6f3f2e778315d2 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 7 Oct 2025 10:25:49 +0100 Subject: [PATCH 13/23] refactor(js/plugins/ollama): improve model tests --- js/plugins/ollama/package.json | 2 +- js/plugins/ollama/src/index.ts | 2 +- js/plugins/ollama/tests/model_test.ts | 146 +++++++++++++++++++++++--- 3 files changed, 131 insertions(+), 19 deletions(-) diff --git a/js/plugins/ollama/package.json b/js/plugins/ollama/package.json index 715fee5a2c..43adb9dfca 100644 --- a/js/plugins/ollama/package.json +++ b/js/plugins/ollama/package.json @@ -18,7 +18,7 @@ "build:clean": "rimraf ./lib", "build": "npm-run-all build:clean check compile", "build:watch": "tsup-node --watch", - "test": "find tests -name '*_test.ts' ! -name '*_live_test.ts' -exec node --import tsx --test {} +", + "test": "find tests -name 'model_test.ts' ! -name '*_live_test.ts' -exec node --import tsx --test {} +", "test:live": "node --import tsx --test tests/*_test.ts" }, "repository": { diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index ed40666dd6..7e443aef48 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -222,7 +222,7 @@ function createOllamaModel( let message: MessageData; - if (opts?.streamingRequested) { + if (opts.streamingRequested) { const reader = res.body.getReader(); const textDecoder = new TextDecoder(); let textResponse = ''; diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index 4a6598bff1..8fd3b89f2a 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -19,9 +19,11 @@ import { beforeEach, describe, it } from 'node:test'; import { ollama } from '../src/index.js'; import type { OllamaPluginParams } from '../src/types.js'; +const BASE_TIME = new Date('2024-07-22T20:33:28.123648Z').getTime(); + const MOCK_TOOL_CALL_RESPONSE = { model: 'llama3.2', - created_at: '2024-07-22T20:33:28.123648Z', + created_at: new Date(BASE_TIME).toISOString(), message: { role: 'assistant', content: '', @@ -43,7 +45,7 @@ const MOCK_TOOL_CALL_RESPONSE = { const MOCK_END_RESPONSE = { model: 'llama3.2', - created_at: '2024-07-22T20:33:28.123648Z', + created_at: new Date(BASE_TIME).toISOString(), message: { role: 'assistant', content: 'The weather is sunny', @@ -52,27 +54,116 @@ const MOCK_END_RESPONSE = { done: true, }; -// Mock fetch to simulate the multi-turn tool calling flow: -// 1. Initial request with tools → returns tool_calls response -// 2. Follow-up request with tool results (role='tool') → returns final answer +const MOCK_NO_TOOLS_END_RESPONSE = { + model: 'llama3.2', + created_at: new Date(BASE_TIME).toISOString(), + message: { + role: 'assistant', + content: 'I have no way of knowing that', + }, + done_reason: 'stop', + done: true, +}; + +// MockModel class to simulate the tool calling flow more clearly +class MockModel { + private callCount = 0; + private hasTools = false; + + // for non-streaming requests + async chat(request: any): Promise { + this.callCount++; + + // First call: initial request with tools → return tool call + if (this.callCount === 1 && request.tools && request.tools.length > 0) { + this.hasTools = true; + return MOCK_TOOL_CALL_RESPONSE; + } + + // Second call: follow-up with tool results → return final answer + if ( + this.callCount === 2 && + this.hasTools && + request.messages?.some((m: any) => m.role === 'tool') + ) { + return MOCK_END_RESPONSE; + } + + // Basic request without tools → return end response + return MOCK_NO_TOOLS_END_RESPONSE; + } + + // Create a streaming response for testing using a ReadableStream + createStreamingResponse(): ReadableStream { + const words = ['this', 'is', 'a', 'streaming', 'response']; + + return new ReadableStream({ + start(controller) { + let wordIndex = 0; + + const sendNextChunk = () => { + if (wordIndex >= words.length) { + controller.close(); + return; + } + + // Stream individual words (not cumulative) + const currentWord = words[wordIndex]; + const isLastChunk = wordIndex === words.length - 1; + + // Increment timestamp for each chunk + const chunkTime = new Date(BASE_TIME + wordIndex * 100).toISOString(); + + const response = { + model: 'llama3.2', + created_at: chunkTime, + message: { + role: 'assistant', + content: currentWord + (isLastChunk ? '' : ' '), // Add space except for last word + }, + done_reason: isLastChunk ? 'stop' : undefined, + done: isLastChunk, + }; + + controller.enqueue( + new TextEncoder().encode(JSON.stringify(response) + '\n') + ); + + wordIndex++; + setTimeout(sendNextChunk, 10); // Small delay to simulate streaming + }; + + sendNextChunk(); + }, + }); + } + + reset(): void { + this.callCount = 0; + this.hasTools = false; + } +} + +// Create a mock model instance to simulate the tool calling flow +const mockModel = new MockModel(); + +// Mock fetch to simulate the multi-turn tool calling flow using MockModel global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/api/chat')) { const body = JSON.parse((options?.body as string) || '{}'); - // Check if this request contains tool responses (second call in tool flow) - const hasToolResponses = body.messages?.some((m) => m.role === 'tool'); - if (hasToolResponses) { - return new Response(JSON.stringify(MOCK_END_RESPONSE)); - } - - // Initial request with tools → return tool call - if (body.tools && body.tools.length > 0) { - return new Response(JSON.stringify(MOCK_TOOL_CALL_RESPONSE)); + // Check if this is a streaming request + if (body.stream) { + const stream = mockModel.createStreamingResponse(); + return new Response(stream, { + headers: { 'Content-Type': 'application/json' }, + }); } - // Basic request without tools → return final response - return new Response(JSON.stringify(MOCK_END_RESPONSE)); + // Non-streaming request + const response = await mockModel.chat(body); + return new Response(JSON.stringify(response)); } throw new Error('Unknown API endpoint'); }; @@ -85,6 +176,7 @@ describe('ollama models', () => { let ai: Genkit; beforeEach(() => { + mockModel.reset(); // Reset mock state between tests ai = genkit({ plugins: [ollama(options)], }); @@ -95,7 +187,7 @@ describe('ollama models', () => { model: 'ollama/test-model', prompt: 'Hello', }); - assert.ok(result.text === 'The weather is sunny'); + assert.ok(result.text === 'I have no way of knowing that'); }); it('should successfully return tool call response', async () => { @@ -145,4 +237,24 @@ describe('ollama models', () => { ); } }); + + it('should successfully return streaming response', async () => { + const streamingResult = ai.generateStream({ + model: 'ollama/test-model', + prompt: 'Hello', + }); + + let fullText = ''; + let chunkCount = 0; + for await (const chunk of streamingResult.stream) { + console.log(JSON.stringify(chunk, null, 2)); + fullText += chunk.text; // Each chunk contains individual words + chunkCount++; + } + + // Should have received multiple chunks (one per word) + assert.ok(chunkCount > 1); + // Final text should be complete + assert.ok(fullText === 'this is a streaming response'); + }); }); From 9468b02d87f5300fd4f6e50094841122acaff92a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 7 Oct 2025 14:08:16 +0100 Subject: [PATCH 14/23] refactor(js/plguings/ollama): use namespace and keep plugin function in same place --- js/plugins/ollama/src/index.ts | 120 +++++++++++++++++---------------- 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 7e443aef48..6d2238e9b7 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -72,6 +72,62 @@ export type OllamaPlugin = { embedder(name: string, config?: Record): EmbedderReference; }; +function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { + if (!params) { + params = {}; + } + if (!params.serverAddress) { + params.serverAddress = DEFAULT_OLLAMA_SERVER_ADDRESS; + } + const serverAddress = params.serverAddress; + + return genkitPluginV2({ + name: 'ollama', + init() { + const actions: ResolvableAction[] = []; + + if (params?.models) { + for (const model of params.models) { + actions.push( + createOllamaModel(model, serverAddress, params.requestHeaders) + ); + } + } + + if (params?.embedders && params.serverAddress) { + for (const embedder of params.embedders) { + actions.push( + defineOllamaEmbedder({ + name: embedder.name, + modelName: embedder.name, + dimensions: embedder.dimensions, + options: { ...params, serverAddress }, + }) + ); + } + } + + return actions; + }, + async resolve(actionType, actionName) { + // dynamically resolve models, for embedders user must provide dimensions. + if (actionType === 'model') { + return await createOllamaModel( + { + name: actionName, + }, + serverAddress, + params?.requestHeaders + ); + } + return undefined; + }, + async list() { + return await listActions(serverAddress, params?.requestHeaders); + }, + }); +} + async function listActions( serverAddress: string, requestHeaders?: RequestHeaders @@ -260,62 +316,6 @@ function createOllamaModel( ); } -function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { - if (!params) { - params = {}; - } - if (!params.serverAddress) { - params.serverAddress = DEFAULT_OLLAMA_SERVER_ADDRESS; - } - const serverAddress = params.serverAddress; - - return genkitPluginV2({ - name: 'ollama', - init() { - const actions: ResolvableAction[] = []; - - if (params?.models) { - for (const model of params.models) { - actions.push( - createOllamaModel(model, serverAddress, params.requestHeaders) - ); - } - } - - if (params?.embedders && params.serverAddress) { - for (const embedder of params.embedders) { - actions.push( - defineOllamaEmbedder({ - name: embedder.name, - modelName: embedder.name, - dimensions: embedder.dimensions, - options: { ...params, serverAddress }, - }) - ); - } - } - - return actions; - }, - async resolve(actionType, actionName) { - // dynamically resolve models, for embedders user must provide dimensions. - if (actionType === 'model') { - return await createOllamaModel( - { - name: actionName, - }, - serverAddress, - params?.requestHeaders - ); - } - return undefined; - }, - async list() { - return await listActions(serverAddress, params?.requestHeaders); - }, - }); -} - function parseMessage(response: any, type: ApiType): MessageData { if (response.error) { throw new Error(response.error); @@ -480,7 +480,7 @@ function toGenkitToolRequest(tool_calls: OllamaToolCall[]): ToolRequestPart[] { })); } -function readChunks(reader) { +function readChunks(reader: ReadableStreamDefaultReader) { return { async *[Symbol.asyncIterator]() { let readResult = await reader.read(); @@ -521,7 +521,8 @@ ollama.model = ( config?: z.infer ): ModelReference => { return modelRef({ - name: `ollama/${name}`, + name, + namespace: 'ollama', config, configSchema: OllamaConfigSchema, }); @@ -531,7 +532,8 @@ ollama.embedder = ( config?: Record ): EmbedderReference => { return embedderRef({ - name: `ollama/${name}`, + name, + namespace: 'ollama', config, }); }; From ff348b3b771b97f2b07f5e9a2155750c6768f88a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 7 Oct 2025 15:24:41 +0100 Subject: [PATCH 15/23] fix(js/plguings/ollama): revert to using prefixes --- js/plugins/ollama/package.json | 2 +- js/plugins/ollama/src/index.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/js/plugins/ollama/package.json b/js/plugins/ollama/package.json index 43adb9dfca..715fee5a2c 100644 --- a/js/plugins/ollama/package.json +++ b/js/plugins/ollama/package.json @@ -18,7 +18,7 @@ "build:clean": "rimraf ./lib", "build": "npm-run-all build:clean check compile", "build:watch": "tsup-node --watch", - "test": "find tests -name 'model_test.ts' ! -name '*_live_test.ts' -exec node --import tsx --test {} +", + "test": "find tests -name '*_test.ts' ! -name '*_live_test.ts' -exec node --import tsx --test {} +", "test:live": "node --import tsx --test tests/*_test.ts" }, "repository": { diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 6d2238e9b7..499a6b2076 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -521,8 +521,7 @@ ollama.model = ( config?: z.infer ): ModelReference => { return modelRef({ - name, - namespace: 'ollama', + name: `ollama/${name}`, config, configSchema: OllamaConfigSchema, }); @@ -532,8 +531,7 @@ ollama.embedder = ( config?: Record ): EmbedderReference => { return embedderRef({ - name, - namespace: 'ollama', + name: `ollama/${name}`, config, }); }; From 9215cab66558b0a222dab4149d081ca6bc2bb654 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 13 Oct 2025 11:27:34 +0100 Subject: [PATCH 16/23] refactor(js/plugins/ollama): clean up default args in ollamaPlugin --- js/plugins/ollama/src/index.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 499a6b2076..402058ea22 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -72,14 +72,8 @@ export type OllamaPlugin = { embedder(name: string, config?: Record): EmbedderReference; }; -function ollamaPlugin(params?: OllamaPluginParams): GenkitPluginV2 { - if (!params) { - params = {}; - } - if (!params.serverAddress) { - params.serverAddress = DEFAULT_OLLAMA_SERVER_ADDRESS; - } - const serverAddress = params.serverAddress; +function ollamaPlugin(params: OllamaPluginParams = {}): GenkitPluginV2 { + const serverAddress = params.serverAddress || DEFAULT_OLLAMA_SERVER_ADDRESS; return genkitPluginV2({ name: 'ollama', From 68dd595cfeeae8eb5eb6e5b52698f9dd8fff22fb Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Mon, 13 Oct 2025 15:42:07 +0300 Subject: [PATCH 17/23] refactor(js/plugins/ollama): extract initializer as with the original plugins implementation --- js/plugins/ollama/src/index.ts | 109 ++++++++++++++++++++------------- 1 file changed, 65 insertions(+), 44 deletions(-) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 402058ea22..526d7b16b9 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -72,54 +72,57 @@ export type OllamaPlugin = { embedder(name: string, config?: Record): EmbedderReference; }; -function ollamaPlugin(params: OllamaPluginParams = {}): GenkitPluginV2 { - const serverAddress = params.serverAddress || DEFAULT_OLLAMA_SERVER_ADDRESS; +function initializer(serverAddress: string, params: OllamaPluginParams = {}) { + const actions: ResolvableAction[] = []; - return genkitPluginV2({ - name: 'ollama', - init() { - const actions: ResolvableAction[] = []; + if (params?.models) { + for (const model of params.models) { + actions.push( + createOllamaModel(model, serverAddress, params.requestHeaders) + ); + } + } - if (params?.models) { - for (const model of params.models) { - actions.push( - createOllamaModel(model, serverAddress, params.requestHeaders) - ); - } - } + if (params?.embedders && params.serverAddress) { + for (const embedder of params.embedders) { + actions.push( + defineOllamaEmbedder({ + name: embedder.name, + modelName: embedder.name, + dimensions: embedder.dimensions, + options: { ...params, serverAddress }, + }) + ); + } + } - if (params?.embedders && params.serverAddress) { - for (const embedder of params.embedders) { - actions.push( - defineOllamaEmbedder({ - name: embedder.name, - modelName: embedder.name, - dimensions: embedder.dimensions, - options: { ...params, serverAddress }, - }) - ); - } - } + return actions; +} - return actions; - }, - async resolve(actionType, actionName) { - // dynamically resolve models, for embedders user must provide dimensions. - if (actionType === 'model') { - return await createOllamaModel( - { - name: actionName, - }, - serverAddress, - params?.requestHeaders - ); - } - return undefined; - }, - async list() { - return await listActions(serverAddress, params?.requestHeaders); - }, - }); +interface ResolveActionOptions { + params: OllamaPluginParams; + actionType: string; + actionName: string; + serverAddress: string; +} + +function resolveAction({ + params, + actionType, + actionName, + serverAddress, +}: ResolveActionOptions) { + switch (actionType) { + case 'model': + return createOllamaModel( + { + name: actionName, + }, + serverAddress, + params?.requestHeaders + ); + } + return undefined; } async function listActions( @@ -140,6 +143,23 @@ async function listActions( ); } +function ollamaPlugin(params: OllamaPluginParams = {}): GenkitPluginV2 { + const serverAddress = params.serverAddress || DEFAULT_OLLAMA_SERVER_ADDRESS; + + return genkitPluginV2({ + name: 'ollama', + init() { + return initializer(serverAddress, params); + }, + resolve(actionType, actionName) { + return resolveAction({ params, actionType, actionName, serverAddress }); + }, + async list() { + return await listActions(serverAddress, params?.requestHeaders); + }, + }); +} + async function listLocalModels( serverAddress: string, requestHeaders?: RequestHeaders @@ -510,6 +530,7 @@ function isValidOllamaTool(tool: ToolDefinition): boolean { } export const ollama = ollamaPlugin as OllamaPlugin; + ollama.model = ( name: string, config?: z.infer From 2703c9a07cee0b85725a933f0e9391c455cb9e52 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 13 Oct 2025 15:46:48 +0100 Subject: [PATCH 18/23] fix(js/plugins/ollama): fix prefix in list method, and add tests --- js/plugins/ollama/src/index.ts | 2 +- js/plugins/ollama/tests/list_test.ts | 142 ++++++++++++++++++++++++++ js/plugins/ollama/tests/model_test.ts | 1 - 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 js/plugins/ollama/tests/list_test.ts diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 526d7b16b9..73d3505b5e 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -136,7 +136,7 @@ async function listActions( ?.filter((m) => m.model && !m.model.includes('embed')) .map((m) => modelActionMetadata({ - name: m.model, + name: `ollama/${m.model}`, info: GENERIC_MODEL_INFO, }) ) || [] diff --git a/js/plugins/ollama/tests/list_test.ts b/js/plugins/ollama/tests/list_test.ts new file mode 100644 index 0000000000..cdd96b28de --- /dev/null +++ b/js/plugins/ollama/tests/list_test.ts @@ -0,0 +1,142 @@ +/** + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as assert from 'assert'; +import { Genkit, genkit } from 'genkit'; +import { beforeEach, describe, it } from 'node:test'; +import { ollama } from '../src/index.js'; +import type { + ListLocalModelsResponse, + OllamaPluginParams, +} from '../src/types.js'; + +const MOCK_MODELS_RESPONSE: ListLocalModelsResponse = { + models: [ + { + name: 'llama3.2:latest', + model: 'llama3.2:latest', + modified_at: '2024-07-22T20:33:28.123648Z', + size: 1234567890, + digest: 'sha256:abcdef123456', + details: { + parent_model: '', + format: 'gguf', + family: 'llama', + families: ['llama'], + parameter_size: '8B', + quantization_level: 'Q4_0', + }, + }, + { + name: 'gemma2:latest', + model: 'gemma2:latest', + modified_at: '2024-07-22T20:33:28.123648Z', + size: 987654321, + digest: 'sha256:fedcba654321', + details: { + parent_model: '', + format: 'gguf', + family: 'gemma', + families: ['gemma'], + parameter_size: '2B', + quantization_level: 'Q4_0', + }, + }, + { + name: 'nomic-embed-text:latest', + model: 'nomic-embed-text:latest', + modified_at: '2024-07-22T20:33:28.123648Z', + size: 456789123, + digest: 'sha256:123456789abc', + details: { + parent_model: '', + format: 'gguf', + family: 'nomic', + families: ['nomic'], + parameter_size: '137M', + quantization_level: 'Q4_0', + }, + }, + ], +}; + +// Mock fetch to simulate the Ollama API response +global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/api/tags')) { + return new Response(JSON.stringify(MOCK_MODELS_RESPONSE), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + throw new Error(`Unknown API endpoint: ${url}`); +}; + +describe('ollama list', () => { + const options: OllamaPluginParams = { + serverAddress: 'http://localhost:3000', + }; + + let ai: Genkit; + beforeEach(() => { + ai = genkit({ + plugins: [ollama(options)], + }); + }); + + it('should return models with ollama/ prefix to maintain v1 compatibility', async () => { + const result = await ollama().list!(); + + // Should return 2 models (embedding models are filtered out) + assert.strictEqual(result.length, 2); + + // Check that model names have the ollama/ prefix (maintaining v1 compatibility) + const modelNames = result.map((m) => m.name); + assert.ok(modelNames.includes('ollama/llama3.2:latest')); + assert.ok(modelNames.includes('ollama/gemma2:latest')); + assert.ok(!modelNames.includes('ollama/nomic-embed-text:latest')); // embedding model filtered out + + // Check that each model has the correct structure + for (const model of result) { + assert.ok(model.name); + assert.ok(model.metadata); + assert.ok(model.metadata.model); + const modelInfo = model.metadata.model; + assert.strictEqual(modelInfo.supports?.multiturn, true); + assert.strictEqual(modelInfo.supports?.media, true); + assert.strictEqual(modelInfo.supports?.tools, true); + assert.strictEqual(modelInfo.supports?.toolChoice, true); + assert.strictEqual(modelInfo.supports?.systemRole, true); + assert.strictEqual(modelInfo.supports?.constrained, 'all'); + } + }); + + it('should list models through Genkit instance', async () => { + const result = await ai.registry.listResolvableActions(); + + // Should return 2 models (embedding models are filtered out) + const modelActions = Object.values(result).filter( + (action) => action.actionType === 'model' + ); + assert.strictEqual(modelActions.length, 2); + + // Check that model names have the ollama/ prefix + const modelNames = modelActions.map((m) => m.name); + assert.ok(modelNames.includes('ollama/llama3.2:latest')); + assert.ok(modelNames.includes('ollama/gemma2:latest')); + assert.ok(!modelNames.includes('ollama/nomic-embed-text:latest')); // embedding model filtered out + }); +}); diff --git a/js/plugins/ollama/tests/model_test.ts b/js/plugins/ollama/tests/model_test.ts index 8fd3b89f2a..d2b99ac2ce 100644 --- a/js/plugins/ollama/tests/model_test.ts +++ b/js/plugins/ollama/tests/model_test.ts @@ -247,7 +247,6 @@ describe('ollama models', () => { let fullText = ''; let chunkCount = 0; for await (const chunk of streamingResult.stream) { - console.log(JSON.stringify(chunk, null, 2)); fullText += chunk.text; // Each chunk contains individual words chunkCount++; } From c0b1913dceadd11031d912f24971f399a8ea862b Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 13 Oct 2025 16:44:45 +0100 Subject: [PATCH 19/23] fix(js/plugins/ollama): restore specifying serverAddress at request time --- js/plugins/ollama/src/embeddings.ts | 15 +++++--- js/plugins/ollama/src/types.ts | 14 ++++---- js/plugins/ollama/tests/embeddings_test.ts | 41 ++++++++++++++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/js/plugins/ollama/src/embeddings.ts b/js/plugins/ollama/src/embeddings.ts index ca5f2dda68..5a9c7ae9bb 100644 --- a/js/plugins/ollama/src/embeddings.ts +++ b/js/plugins/ollama/src/embeddings.ts @@ -18,6 +18,7 @@ import { embedder } from 'genkit/plugin'; import type { EmbedRequest, EmbedResponse } from 'ollama'; import { DEFAULT_OLLAMA_SERVER_ADDRESS } from './constants.js'; import type { DefineOllamaEmbeddingParams, RequestHeaders } from './types.js'; +import { OllamaEmbedderConfigSchema } from './types.js'; export async function toOllamaEmbedRequest( modelName: string, @@ -66,10 +67,13 @@ export function defineOllamaEmbedder({ modelName, dimensions, options, -}: DefineOllamaEmbeddingParams): EmbedderAction { +}: DefineOllamaEmbeddingParams): EmbedderAction< + typeof OllamaEmbedderConfigSchema +> { return embedder( { name: `ollama/${name}`, + configSchema: OllamaEmbedderConfigSchema, info: { label: 'Ollama Embedding - ' + name, dimensions, @@ -79,14 +83,15 @@ export function defineOllamaEmbedder({ }, }, }, - async (request, config) => { + async ({ input, options: requestOptions }, config) => { const serverAddress = - options.serverAddress || DEFAULT_OLLAMA_SERVER_ADDRESS; - + requestOptions?.serverAddress || + options.serverAddress || + DEFAULT_OLLAMA_SERVER_ADDRESS; const { url, requestPayload, headers } = await toOllamaEmbedRequest( modelName, dimensions, - request.input, + input, serverAddress, options.requestHeaders ); diff --git a/js/plugins/ollama/src/types.ts b/js/plugins/ollama/src/types.ts index b166b3ca3e..220d7a8680 100644 --- a/js/plugins/ollama/src/types.ts +++ b/js/plugins/ollama/src/types.ts @@ -34,14 +34,6 @@ export interface EmbeddingModelDefinition { dimensions: number; } -export const OllamaEmbeddingPredictionSchema = z.object({ - embedding: z.array(z.number()), -}); - -export type OllamaEmbeddingPrediction = z.infer< - typeof OllamaEmbeddingPredictionSchema ->; - export interface DefineOllamaEmbeddingParams { name: string; modelName: string; @@ -49,6 +41,12 @@ export interface DefineOllamaEmbeddingParams { options: OllamaPluginParams; } +export const OllamaEmbedderConfigSchema = z.object({ + serverAddress: z.string().optional(), +}); + +export type OllamaEmbedderConfig = z.infer; + /** * Parameters for the Ollama plugin configuration. */ diff --git a/js/plugins/ollama/tests/embeddings_test.ts b/js/plugins/ollama/tests/embeddings_test.ts index 905ca2edb7..375569dfdd 100644 --- a/js/plugins/ollama/tests/embeddings_test.ts +++ b/js/plugins/ollama/tests/embeddings_test.ts @@ -171,4 +171,45 @@ describe('defineOllamaEmbedder (with genkit initialization)', () => { } ); }); + + it('should support per-call embedder serverAddress configuration', async () => { + const aiWithEmbedder = genkit({ + plugins: [ + ollama({ + serverAddress: 'http://localhost:3000', + embedders: [{ name: 'test-embedder', dimensions: 768 }], + }), + ], + }); + + // Mock fetch to verify custom serverAddress is used + global.fetch = async (input: RequestInfo | URL, options?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/api/embed')) { + // Verify the custom serverAddress was used + assert.ok(url.includes('http://custom-server:11434')); + return new Response( + JSON.stringify({ + embeddings: [[0.1, 0.2, 0.3]], + }), + { + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + throw new Error(`Unknown API endpoint: ${url}`); + }; + + const result = await aiWithEmbedder.embed({ + embedder: 'ollama/test-embedder', + content: 'test document', + options: { serverAddress: 'http://custom-server:11434' }, + }); + + assert.ok(result); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].embedding.length, 3); + }); }); From 13423cc3d5eb2c737bd7676407620dfd35de2fb3 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 13 Oct 2025 17:07:57 +0100 Subject: [PATCH 20/23] fix(js/plugins/ollama): restore old method name and add a config schema --- js/plugins/ollama/src/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 73d3505b5e..64562926da 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -78,7 +78,7 @@ function initializer(serverAddress: string, params: OllamaPluginParams = {}) { if (params?.models) { for (const model of params.models) { actions.push( - createOllamaModel(model, serverAddress, params.requestHeaders) + defineOllamaModel(model, serverAddress, params.requestHeaders) ); } } @@ -114,7 +114,7 @@ function resolveAction({ }: ResolveActionOptions) { switch (actionType) { case 'model': - return createOllamaModel( + return defineOllamaModel( { name: actionName, }, @@ -213,12 +213,12 @@ export const OllamaConfigSchema = GenerationCommonConfigSchema.extend({ .optional(), }); -function createOllamaModel( +function defineOllamaModel( modelDef: ModelDefinition, serverAddress: string, requestHeaders?: RequestHeaders ) { - return model( + return model( { name: modelDef.name, label: `Ollama - ${modelDef.name}`, @@ -231,8 +231,8 @@ function createOllamaModel( }, async (request, opts) => { const { topP, topK, stopSequences, maxOutputTokens, ...rest } = - request.config as any; - const options: Record = { ...rest }; + request.config || {}; + const options = { ...rest }; if (topP !== undefined) { options.top_p = topP; } From 5a8ebf9bb80ca42f7eaf0fef5ae0c3b1a85c1ce6 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 14 Oct 2025 11:33:34 +0100 Subject: [PATCH 21/23] fix(js/plugins/ollama): address PR review comments --- js/plugins/ollama/src/index.ts | 10 ++-------- js/plugins/ollama/src/types.ts | 7 +++++++ js/plugins/ollama/tests/list_test.ts | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 64562926da..1f137b2248 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -58,6 +58,7 @@ import type { OllamaTool, OllamaToolCall, RequestHeaders, + ResolveActionOptions, } from './types.js'; export type { OllamaPluginParams }; @@ -99,13 +100,6 @@ function initializer(serverAddress: string, params: OllamaPluginParams = {}) { return actions; } -interface ResolveActionOptions { - params: OllamaPluginParams; - actionType: string; - actionName: string; - serverAddress: string; -} - function resolveAction({ params, actionType, @@ -220,7 +214,7 @@ function defineOllamaModel( ) { return model( { - name: modelDef.name, + name: `ollama/${modelDef.name}`, label: `Ollama - ${modelDef.name}`, configSchema: OllamaConfigSchema, supports: { diff --git a/js/plugins/ollama/src/types.ts b/js/plugins/ollama/src/types.ts index 220d7a8680..bcb688f103 100644 --- a/js/plugins/ollama/src/types.ts +++ b/js/plugins/ollama/src/types.ts @@ -176,3 +176,10 @@ export interface LocalModel { export interface ListLocalModelsResponse { models: LocalModel[]; } + +export interface ResolveActionOptions { + params: OllamaPluginParams; + actionType: string; + actionName: string; + serverAddress: string; +} diff --git a/js/plugins/ollama/tests/list_test.ts b/js/plugins/ollama/tests/list_test.ts index cdd96b28de..6ae1f371a2 100644 --- a/js/plugins/ollama/tests/list_test.ts +++ b/js/plugins/ollama/tests/list_test.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Google LLC + * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 99ec01ce499b6e4a95cfc583a2f90c86cb5580bb Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 14 Oct 2025 14:30:24 +0100 Subject: [PATCH 22/23] fix(js/plugins/ollama): dont pass in serverAddress if we dont need to --- js/plugins/ollama/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index 1f137b2248..ce1538c1a4 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -91,7 +91,7 @@ function initializer(serverAddress: string, params: OllamaPluginParams = {}) { name: embedder.name, modelName: embedder.name, dimensions: embedder.dimensions, - options: { ...params, serverAddress }, + options: params, }) ); } From 98d718d2bdc14a32fb65c67abe1f895c4e6bbfec Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 14 Oct 2025 14:33:34 +0100 Subject: [PATCH 23/23] fix(js/plugins/ollama): keep index:0 in streaming callback call --- js/plugins/ollama/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/js/plugins/ollama/src/index.ts b/js/plugins/ollama/src/index.ts index ce1538c1a4..c9f64f1bbb 100644 --- a/js/plugins/ollama/src/index.ts +++ b/js/plugins/ollama/src/index.ts @@ -295,6 +295,7 @@ function defineOllamaModel( const json = JSON.parse(chunkText); const message = parseMessage(json, type); opts.sendChunk({ + index: 0, content: message.content, }); textResponse += message.content[0].text;