From 5a384a9315db0af88332cf21d1ee9a65e5150144 Mon Sep 17 00:00:00 2001 From: Masteromanlol <138344556+Masteromanlol@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:37:26 -0600 Subject: [PATCH] Add Ollama LLM adapter and support Introduce a new OllamaAdapter (src/llm/ollama.ts) implementing LLMAdapter with chat and streaming support, converting between the framework's ContentBlock types and Ollama (OpenAI-compatible) chat completions. Wire the adapter into the factory (src/llm/adapter.ts) and extend provider types (src/types.ts) to include 'ollama'. Update example (examples/04-multi-model-team.ts) to allow selecting Ollama as a model/provider option. Ollama adapter defaults its base URL from OLLAMA_BASE_URL or http://localhost:11434 and handles tool calls, tool results, images, and finish reason normalization. --- examples/04-multi-model-team.ts | 3 +- package-lock.json | 6 + src/llm/adapter.ts | 9 +- src/llm/ollama.ts | 414 ++++++++++++++++++++++++++++++++ src/types.ts | 4 +- 5 files changed, 431 insertions(+), 5 deletions(-) create mode 100644 src/llm/ollama.ts diff --git a/examples/04-multi-model-team.ts b/examples/04-multi-model-team.ts index f3b8492..8642b80 100644 --- a/examples/04-multi-model-team.ts +++ b/examples/04-multi-model-team.ts @@ -151,8 +151,7 @@ Return the raw rates as a JSON object keyed by pair, e.g. { "USD/EUR": 0.91, "US const analystConfig: AgentConfig = { name: 'analyst', - model: useOpenAI ? 'gpt-5.4' : 'claude-sonnet-4-6', - provider: useOpenAI ? 'openai' : 'anthropic', + model: useOllama ? 'llama3.1' : useOpenAI ? 'gpt-4o-mini' : 'claude-3-5-sonnet-20240620',\n provider: useOllama ? 'ollama' : useOpenAI ? 'openai' : 'anthropic', systemPrompt: `You are a foreign exchange analyst. You receive exchange rate data and produce a short briefing. Use format_currency to show example conversions. diff --git a/package-lock.json b/package-lock.json index 96f1dec..06af6f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1459,6 +1459,12 @@ } } }, + "node_modules/ollama": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.3.0.tgz", + "integrity": "sha512-+eaNmtirwiAcfLW84RUZA2rhEwl7pOtLJ39zTVkBWRbRQ1Q1JubTf1k/wmxvICs1APVA+iYgYeYif0n5GUflhw==", + "license": "MIT" + }, "node_modules/openai": { "version": "4.104.0", "resolved": "https://registry.npmmirror.com/openai/-/openai-4.104.0.tgz", diff --git a/src/llm/adapter.ts b/src/llm/adapter.ts index 979f37c..1ed605f 100644 --- a/src/llm/adapter.ts +++ b/src/llm/adapter.ts @@ -11,6 +11,7 @@ * * const anthropic = createAdapter('anthropic') * const openai = createAdapter('openai', process.env.OPENAI_API_KEY) + * const ollama = createAdapter('ollama') * ``` */ @@ -37,13 +38,14 @@ import type { LLMAdapter } from '../types.js' * Additional providers can be integrated by implementing {@link LLMAdapter} * directly and bypassing this factory. */ -export type SupportedProvider = 'anthropic' | 'openai' +export type SupportedProvider = 'anthropic' | 'openai' | 'ollama' /** * Instantiate the appropriate {@link LLMAdapter} for the given provider. * * API keys fall back to the standard environment variables * (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) when not supplied explicitly. + * Ollama uses `OLLAMA_BASE_URL` (defaults to http://localhost:11434). * * Adapters are imported lazily so that projects using only one provider * are not forced to install the SDK for the other. @@ -65,6 +67,10 @@ export async function createAdapter( const { OpenAIAdapter } = await import('./openai.js') return new OpenAIAdapter(apiKey) } + case 'ollama': { + const { OllamaAdapter } = await import('./ollama.js') + return new OllamaAdapter() + } default: { // The `never` cast here makes TypeScript enforce exhaustiveness. const _exhaustive: never = provider @@ -72,3 +78,4 @@ export async function createAdapter( } } } + diff --git a/src/llm/ollama.ts b/src/llm/ollama.ts new file mode 100644 index 0000000..876a8ea --- /dev/null +++ b/src/llm/ollama.ts @@ -0,0 +1,414 @@ +/** + * @fileoverview Ollama adapter implementing {@link LLMAdapter}. + * + * Converts between the framework's internal {@link ContentBlock} types and the + * Ollama Chat Completions wire format (OpenAI-compatible). + * Key mapping decisions mirror {@link OpenAIAdapter}: + * + * - Framework `tool_use` blocks in assistant messages → Ollama `tool_calls` + * - Framework `tool_result` blocks in user messages → Ollama `tool` role messages + * - Framework `image` blocks in user messages → Ollama image content parts + * - System prompt in {@link LLMChatOptions} → prepended `system` message + * + * Ollama runs locally (ollama serve). No API key needed. + * Resolution order for base URL: + * 1. `OLLAMA_BASE_URL` environment variable + * 2. `http://localhost:11434` + * + * @example + * ```ts + * import { createAdapter } from './adapter.js' + * + * const adapter = await createAdapter('ollama') + * const response = await adapter.chat(messages, { + * model: 'llama3.1', + * maxTokens: 1024, + * }) + * ``` + */ + +import OpenAI from 'openai' +import type { + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +} from 'openai/resources/chat/completions/index.js' + +import type { + ContentBlock, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + LLMStreamOptions, + LLMToolDef, + StreamEvent, + TextBlock, + ToolUseBlock, +} from '../types.js' + +// --------------------------------------------------------------------------- +// Internal helpers — framework → Ollama (same as OpenAI) +// --------------------------------------------------------------------------- + +/** + * Convert a framework {@link LLMToolDef} to an Ollama {@link ChatCompletionTool}. + */ +function toOpenAITool(tool: LLMToolDef): ChatCompletionTool { + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema as Record, + }, + } +} + +function hasToolResults(msg: LLMMessage): boolean { + return msg.content.some((b) => b.type === 'tool_result') +} + +function toOpenAIMessages(messages: LLMMessage[]): ChatCompletionMessageParam[] { + const result: ChatCompletionMessageParam[] = [] + + for (const msg of messages) { + if (msg.role === 'assistant') { + result.push(toOpenAIAssistantMessage(msg)) + } else { + if (!hasToolResults(msg)) { + result.push(toOpenAIUserMessage(msg)) + } else { + const nonToolBlocks = msg.content.filter((b) => b.type !== 'tool_result') + if (nonToolBlocks.length > 0) { + result.push(toOpenAIUserMessage({ role: 'user', content: nonToolBlocks })) + } + + for (const block of msg.content) { + if (block.type === 'tool_result') { + const toolMsg: ChatCompletionToolMessageParam = { + role: 'tool', + tool_call_id: block.tool_use_id, + content: block.content, + } + result.push(toolMsg) + } + } + } + } + } + + return result +} + +function toOpenAIUserMessage(msg: LLMMessage): ChatCompletionUserMessageParam { + if (msg.content.length === 1 && msg.content[0]?.type === 'text') { + return { role: 'user', content: msg.content[0].text } + } + + type ContentPart = OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage + const parts: ContentPart[] = [] + + for (const block of msg.content) { + if (block.type === 'text') { + parts.push({ type: 'text', text: block.text }) + } else if (block.type === 'image') { + parts.push({ + type: 'image_url', + image_url: { + url: `data:${block.source.media_type};base64,${block.source.data}`, + }, + }) + } + } + + return { role: 'user', content: parts } +} + +function toOpenAIAssistantMessage(msg: LLMMessage): ChatCompletionAssistantMessageParam { + const toolCalls: ChatCompletionMessageToolCall[] = [] + const textParts: string[] = [] + + for (const block of msg.content) { + if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input), + }, + }) + } else if (block.type === 'text') { + textParts.push(block.text) + } + } + + const assistantMsg: ChatCompletionAssistantMessageParam = { + role: 'assistant', + content: textParts.length > 0 ? textParts.join('') : null, + } + + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls + } + + return assistantMsg +} + +// --------------------------------------------------------------------------- +// Internal helpers — Ollama → framework (same as OpenAI) +// --------------------------------------------------------------------------- + +function fromOpenAICompletion(completion: ChatCompletion): LLMResponse { + const choice = completion.choices[0] + if (choice === undefined) { + throw new Error('Ollama returned a completion with no choices') + } + + const content: ContentBlock[] = [] + const message = choice.message + + if (message.content !== null && message.content !== undefined) { + const textBlock: TextBlock = { type: 'text', text: message.content } + content.push(textBlock) + } + + for (const toolCall of message.tool_calls ?? []) { + let parsedInput: Record = {} + try { + const parsed: unknown = JSON.parse(toolCall.function.arguments) + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedInput = parsed as Record + } + } catch { + // Malformed arguments from the model — surface as empty object. + } + + const toolUseBlock: ToolUseBlock = { + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parsedInput, + } + content.push(toolUseBlock) + } + + const stopReason = normalizeFinishReason(choice.finish_reason ?? 'stop') + + return { + id: completion.id, + content, + model: completion.model, + stop_reason: stopReason, + usage: { + input_tokens: completion.usage?.prompt_tokens ?? 0, + output_tokens: completion.usage?.completion_tokens ?? 0, + }, + } +} + +function normalizeFinishReason(reason: string): string { + switch (reason) { + case 'stop': return 'end_turn' + case 'tool_calls': return 'tool_use' + case 'length': return 'max_tokens' + case 'content_filter': return 'content_filter' + default: return reason + } +} + +// --------------------------------------------------------------------------- +// Adapter implementation +// --------------------------------------------------------------------------- + +/** + * LLM adapter backed by Ollama (OpenAI-compatible Chat Completions API). + * + * Local-first — run `ollama serve` and `ollama pull `. + * Thread-safe — share across concurrent runs. + */ +export class OllamaAdapter implements LLMAdapter { + readonly name = 'ollama' + + readonly #client: OpenAI + + constructor() { + this.#client = new OpenAI({ + baseURL: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434', + }) + } + + async chat(messages: LLMMessage[], options: LLMChatOptions): Promise { + const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt) + + const completion = await this.#client.chat.completions.create( + { + model: options.model, + messages: openAIMessages, + max_tokens: options.maxTokens, + temperature: options.temperature, + tools: options.tools ? options.tools.map(toOpenAITool) : undefined, + stream: false, + }, + { + signal: options.abortSignal, + }, + ) + + return fromOpenAICompletion(completion) + } + + async *stream( + messages: LLMMessage[], + options: LLMStreamOptions, + ): AsyncIterable { + const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt) + + const streamResponse = await this.#client.chat.completions.create( + { + model: options.model, + messages: openAIMessages, + max_tokens: options.maxTokens, + temperature: options.temperature, + tools: options.tools ? options.tools.map(toOpenAITool) : undefined, + stream: true, + stream_options: { include_usage: true }, + }, + { + signal: options.abortSignal, + }, + ) + + let completionId = '' + let completionModel = '' + let finalFinishReason: string = 'stop' + let inputTokens = 0 + let outputTokens = 0 + + const toolCallBuffers = new Map() + + let fullText = '' + + try { + for await (const chunk of streamResponse) { + completionId = chunk.id + completionModel = chunk.model + + if (chunk.usage !== null && chunk.usage !== undefined) { + inputTokens = chunk.usage.prompt_tokens + outputTokens = chunk.usage.completion_tokens + } + + const choice: ChatCompletionChunk.Choice | undefined = chunk.choices[0] + if (choice === undefined) continue + + const delta = choice.delta + + if (delta.content !== null && delta.content !== undefined) { + fullText += delta.content + const textEvent: StreamEvent = { type: 'text', data: delta.content } + yield textEvent + } + + for (const toolCallDelta of delta.tool_calls ?? []) { + const idx = toolCallDelta.index + + if (!toolCallBuffers.has(idx)) { + toolCallBuffers.set(idx, { + id: toolCallDelta.id ?? '', + name: toolCallDelta.function?.name ?? '', + argsJson: '', + }) + } + + const buf = toolCallBuffers.get(idx)! + if (toolCallDelta.id) buf.id = toolCallDelta.id + if (toolCallDelta.function?.name) buf.name = toolCallDelta.function.name + if (toolCallDelta.function?.arguments) { + buf.argsJson += toolCallDelta.function.arguments + } + } + + if (choice.finish_reason !== null && choice.finish_reason !== undefined) { + finalFinishReason = choice.finish_reason + } + } + + const finalToolUseBlocks: ToolUseBlock[] = [] + for (const buf of toolCallBuffers.values()) { + let parsedInput: Record = {} + try { + const parsed: unknown = JSON.parse(buf.argsJson) + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedInput = parsed as Record + } + } catch { + } + + const toolUseBlock: ToolUseBlock = { + type: 'tool_use', + id: buf.id, + name: buf.name, + input: parsedInput, + } + finalToolUseBlocks.push(toolUseBlock) + const toolUseEvent: StreamEvent = { type: 'tool_use', data: toolUseBlock } + yield toolUseEvent + } + + const doneContent: ContentBlock[] = [] + if (fullText.length > 0) { + const textBlock: TextBlock = { type: 'text', text: fullText } + doneContent.push(textBlock) + } + doneContent.push(...finalToolUseBlocks) + + const finalResponse: LLMResponse = { + id: completionId, + content: doneContent, + model: completionModel, + stop_reason: normalizeFinishReason(finalFinishReason), + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + } + + const doneEvent: StreamEvent = { type: 'done', data: finalResponse } + yield doneEvent + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + const errorEvent: StreamEvent = { type: 'error', data: error } + yield errorEvent + } + } +} + +function buildOpenAIMessageList( + messages: LLMMessage[], + systemPrompt: string | undefined, +): ChatCompletionMessageParam[] { + const result: ChatCompletionMessageParam[] = [] + + if (systemPrompt !== undefined && systemPrompt.length > 0) { + result.push({ role: 'system', content: systemPrompt }) + } + + result.push(...toOpenAIMessages(messages)) + return result +} + +// Re-export types that consumers of this module commonly need alongside the adapter. +export type { + ContentBlock, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + LLMStreamOptions, + LLMToolDef, + StreamEvent, +} diff --git a/src/types.ts b/src/types.ts index 2875a35..8d6fdbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -186,7 +186,7 @@ export interface ToolDefinition> { export interface AgentConfig { readonly name: string readonly model: string - readonly provider?: 'anthropic' | 'openai' +provider?: 'anthropic' | 'openai' | 'ollama' readonly systemPrompt?: string /** Names of tools (from the tool registry) available to this agent. */ readonly tools?: readonly string[] @@ -285,7 +285,7 @@ export interface OrchestratorEvent { export interface OrchestratorConfig { readonly maxConcurrency?: number readonly defaultModel?: string - readonly defaultProvider?: 'anthropic' | 'openai' +defaultProvider?: 'anthropic' | 'openai' | 'ollama' onProgress?: (event: OrchestratorEvent) => void }