adding ollama adapter
This commit is contained in:
parent
a6244cfe64
commit
53e2d11c4e
27
README.md
27
README.md
|
|
@ -187,6 +187,33 @@ const team = orchestrator.createTeam('mixed-team', {
|
|||
const result = await orchestrator.runTeam(team, 'Build a CLI tool that converts JSON to CSV.')
|
||||
```
|
||||
|
||||
### Local Ollama Support
|
||||
|
||||
```typescript
|
||||
const orchestrator = new OpenMultiAgent({
|
||||
defaultProvider: 'ollama',
|
||||
defaultModel: 'llama2',
|
||||
})
|
||||
|
||||
const localAgent: AgentConfig = {
|
||||
name: 'assistant',
|
||||
model: 'llama2',
|
||||
provider: 'ollama',
|
||||
systemPrompt: 'You are a local assistant running on Ollama.',
|
||||
tools: ['bash', 'file_read', 'file_write'],
|
||||
}
|
||||
|
||||
const team = orchestrator.createTeam('local-team', {
|
||||
name: 'local-team',
|
||||
agents: [localAgent],
|
||||
sharedMemory: true,
|
||||
})
|
||||
|
||||
const result = await orchestrator.runTeam(team, 'Create a small script that lists files in the current directory.')
|
||||
```
|
||||
|
||||
Set `OLLAMA_API_KEY` when your local Ollama instance requires authentication. The adapter defaults to `http://localhost:11434`.
|
||||
|
||||
### Streaming Output
|
||||
|
||||
```typescript
|
||||
|
|
|
|||
|
|
@ -8,18 +8,22 @@
|
|||
* npx tsx examples/01-single-agent.ts
|
||||
*
|
||||
* Prerequisites:
|
||||
* ANTHROPIC_API_KEY env var must be set.
|
||||
* Ollama server running at http://localhost:11434
|
||||
* OLLAMA_API_KEY env var may be set if your server requires auth.
|
||||
*/
|
||||
|
||||
import { OpenMultiAgent, Agent, ToolRegistry, ToolExecutor, registerBuiltInTools } from '../src/index.js'
|
||||
import type { OrchestratorEvent } from '../src/types.js'
|
||||
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? 'llama2'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Part 1: Single agent via OpenMultiAgent (simplest path)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const orchestrator = new OpenMultiAgent({
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
defaultProvider: 'ollama',
|
||||
defaultModel: OLLAMA_MODEL,
|
||||
onProgress: (event: OrchestratorEvent) => {
|
||||
if (event.type === 'agent_start') {
|
||||
console.log(`[start] agent=${event.agent}`)
|
||||
|
|
@ -34,10 +38,11 @@ console.log('Part 1: runAgent() — single one-shot task\n')
|
|||
const result = await orchestrator.runAgent(
|
||||
{
|
||||
name: 'coder',
|
||||
model: 'claude-sonnet-4-6',
|
||||
systemPrompt: `You are a focused TypeScript developer.
|
||||
When asked to implement something, write clean, minimal code with no extra commentary.
|
||||
Use the bash tool to run commands and the file tools to read/write files.`,
|
||||
provider: 'ollama',
|
||||
model: OLLAMA_MODEL,
|
||||
systemPrompt: `You are a digital marketing agency.
|
||||
When asked to getting clients for our cybersecurity solution company, find some startup with funding, webscarp thier niche and collect there contact info.
|
||||
Use that info and send a cold eamil`,
|
||||
tools: ['bash', 'file_read', 'file_write'],
|
||||
maxTurns: 8,
|
||||
},
|
||||
|
|
@ -80,7 +85,8 @@ const executor = new ToolExecutor(registry)
|
|||
const streamingAgent = new Agent(
|
||||
{
|
||||
name: 'explainer',
|
||||
model: 'claude-sonnet-4-6',
|
||||
provider: 'ollama',
|
||||
model: OLLAMA_MODEL,
|
||||
systemPrompt: 'You are a concise technical writer. Keep explanations brief.',
|
||||
maxTurns: 3,
|
||||
},
|
||||
|
|
@ -111,7 +117,8 @@ console.log('\nPart 3: Agent.prompt() — multi-turn conversation\n')
|
|||
const conversationAgent = new Agent(
|
||||
{
|
||||
name: 'tutor',
|
||||
model: 'claude-sonnet-4-6',
|
||||
provider: 'ollama',
|
||||
model: OLLAMA_MODEL,
|
||||
systemPrompt: 'You are a TypeScript tutor. Give short, direct answers.',
|
||||
maxTurns: 2,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "maestro-agents",
|
||||
"name": "open-multi-agent",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "maestro-agents",
|
||||
"name": "open-multi-agent",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { createAdapter } from './llm/adapter.js'
|
||||
export { OllamaAdapter } from './llm/ollama.js'
|
||||
export type { SupportedProvider } from './llm/adapter.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -37,13 +37,13 @@ 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.
|
||||
* (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `OLLAMA_API_KEY`) when not supplied explicitly.
|
||||
*
|
||||
* Adapters are imported lazily so that projects using only one provider
|
||||
* are not forced to install the SDK for the other.
|
||||
|
|
@ -65,6 +65,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(apiKey)
|
||||
}
|
||||
default: {
|
||||
// The `never` cast here makes TypeScript enforce exhaustiveness.
|
||||
const _exhaustive: never = provider
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { OllamaAdapter } from './ollama.js'
|
||||
import { createAdapter } from './adapter.js'
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
function createFetchMock(response: unknown): ReturnType<typeof vi.fn> {
|
||||
return vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => response,
|
||||
text: async () => JSON.stringify(response),
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
describe('OllamaAdapter', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('creates an Ollama adapter through createAdapter()', async () => {
|
||||
const adapter = await createAdapter('ollama')
|
||||
expect(adapter.name).toBe('ollama')
|
||||
})
|
||||
|
||||
it('sends chat requests to the local Ollama endpoint', async () => {
|
||||
const adapter = new OllamaAdapter(undefined, 'http://localhost:11434')
|
||||
const mockResponse = {
|
||||
id: 'abc123',
|
||||
model: 'qwen',
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 2,
|
||||
},
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => mockResponse,
|
||||
text: async () => JSON.stringify(mockResponse),
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await adapter.chat(
|
||||
[{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
|
||||
{ model: 'qwen' },
|
||||
)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init?.headers).toEqual({ 'Content-Type': 'application/json' })
|
||||
expect(typeof init?.body).toBe('string')
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
model: 'qwen',
|
||||
})
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(result.model).toBe('qwen')
|
||||
})
|
||||
|
||||
it('parses streaming-style chat responses and ignores final empty chunks', async () => {
|
||||
const adapter = new OllamaAdapter(undefined, 'http://localhost:11434')
|
||||
const chunk = '{"model":"llama2","message":{"role":"assistant","content":"Hello"}}\n'
|
||||
+ '{"model":"llama2","message":{"role":"assistant","content":""}}\n'
|
||||
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
text: async () => chunk,
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const result = await adapter.chat(
|
||||
[{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
|
||||
{ model: 'llama2' },
|
||||
)
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Hello' }])
|
||||
expect(result.model).toBe('llama2')
|
||||
})
|
||||
|
||||
it('streams SSE events from Ollama and emits done', async () => {
|
||||
const adapter = new OllamaAdapter(undefined, 'http://localhost:11434')
|
||||
const chunk = `data: {"choices":[{"delta":{"content":"hi"}}]}\n\n` +
|
||||
`data: [DONE]\n\n`
|
||||
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => chunk,
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const events = [] as Array<unknown>
|
||||
for await (const event of adapter.stream(
|
||||
[{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
|
||||
{ model: 'qwen' },
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events[0]).toEqual({ type: 'text', data: 'hi' })
|
||||
expect((events[events.length - 1] as any).type).toBe('done')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,534 @@
|
|||
/**
|
||||
* @fileoverview Ollama adapter implementing {@link LLMAdapter}.
|
||||
*
|
||||
* Supports local Ollama servers via `/api/chat` and handles function calling
|
||||
* with Ollama's OpenAI-compatible tool definition format.
|
||||
*
|
||||
* The adapter is intentionally lightweight: it uses the native Fetch API and
|
||||
* parses both regular JSON responses and SSE streams from Ollama.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ContentBlock,
|
||||
LLMAdapter,
|
||||
LLMChatOptions,
|
||||
LLMMessage,
|
||||
LLMResponse,
|
||||
LLMStreamOptions,
|
||||
LLMToolDef,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ToolUseBlock,
|
||||
ToolResultBlock,
|
||||
} from '../types.js'
|
||||
|
||||
const DEFAULT_BASE_URL = 'http://localhost:11434'
|
||||
|
||||
function hasToolResults(msg: LLMMessage): boolean {
|
||||
return msg.content.some((block) => block.type === 'tool_result')
|
||||
}
|
||||
|
||||
function toOllamaTextContent(msg: LLMMessage): string {
|
||||
return msg.content
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => block.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function toOllamaUserMessage(msg: LLMMessage): Record<string, unknown> {
|
||||
const text = toOllamaTextContent(msg)
|
||||
return {
|
||||
role: 'user',
|
||||
content: text || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function toOllamaToolMessages(msg: LLMMessage): Array<Record<string, unknown>> {
|
||||
const messages: Array<Record<string, unknown>> = []
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type !== 'tool_result') continue
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: block.tool_use_id,
|
||||
content: block.content,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function toOllamaAssistantMessage(msg: LLMMessage): Record<string, unknown> {
|
||||
const text = toOllamaTextContent(msg)
|
||||
const assistantMessage: Record<string, unknown> = {
|
||||
role: 'assistant',
|
||||
content: text || undefined,
|
||||
}
|
||||
|
||||
const toolCalls = msg.content
|
||||
.filter((block) => block.type === 'tool_use')
|
||||
.map((block) => ({
|
||||
id: block.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input),
|
||||
},
|
||||
}))
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
assistantMessage.tool_calls = toolCalls
|
||||
}
|
||||
|
||||
return assistantMessage
|
||||
}
|
||||
|
||||
function toOllamaMessages(messages: LLMMessage[], systemPrompt?: string): Record<string, unknown>[] {
|
||||
const result: Record<string, unknown>[] = []
|
||||
|
||||
if (systemPrompt) {
|
||||
result.push({ role: 'system', content: systemPrompt })
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'assistant') {
|
||||
result.push(toOllamaAssistantMessage(msg))
|
||||
continue
|
||||
}
|
||||
|
||||
if (!hasToolResults(msg)) {
|
||||
result.push(toOllamaUserMessage(msg))
|
||||
continue
|
||||
}
|
||||
|
||||
const text = toOllamaTextContent(msg)
|
||||
if (text.length > 0) {
|
||||
result.push({ role: 'user', content: text })
|
||||
}
|
||||
|
||||
result.push(...toOllamaToolMessages(msg))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function toOllamaFunction(tool: LLMToolDef): Record<string, unknown> {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinishReason(reason: unknown): string {
|
||||
if (typeof reason !== 'string') {
|
||||
return 'end_turn'
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case 'stop':
|
||||
case 'end_turn':
|
||||
return 'end_turn'
|
||||
case 'length':
|
||||
return 'max_tokens'
|
||||
case 'tool_call':
|
||||
case 'function_call':
|
||||
return 'tool_use'
|
||||
default:
|
||||
return reason
|
||||
}
|
||||
}
|
||||
|
||||
function isMeaningfulOllamaMessage(message: any): boolean {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const content = message.content
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content.length > 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function chooseBestResponseObject(objects: any[]): any {
|
||||
let best: any = null
|
||||
|
||||
for (const obj of objects) {
|
||||
const message = obj.message ?? obj.choices?.[0]?.message
|
||||
if (isMeaningfulOllamaMessage(message)) {
|
||||
best = obj
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? objects[objects.length - 1]
|
||||
}
|
||||
|
||||
function parseTextAsJson(text: string): any {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
// Fall through to SSE / NDJSON parsing.
|
||||
}
|
||||
|
||||
const objects: any[] = []
|
||||
|
||||
// Try server-sent events blocks.
|
||||
for (const chunk of trimmed.split(/\r?\n\r?\n/)) {
|
||||
let payload = ''
|
||||
for (const line of chunk.split(/\r?\n/)) {
|
||||
if (line.startsWith('data:')) {
|
||||
const value = line.slice(5).trim()
|
||||
if (value === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
payload += value
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
objects.push(JSON.parse(payload))
|
||||
} catch {
|
||||
// ignore non-JSON payloads
|
||||
}
|
||||
}
|
||||
|
||||
if (objects.length > 0) {
|
||||
return chooseBestResponseObject(objects)
|
||||
}
|
||||
|
||||
// Try line-delimited JSON fallback.
|
||||
for (const line of trimmed.split(/\r?\n/)) {
|
||||
const candidate = line.trim()
|
||||
if (!candidate) continue
|
||||
try {
|
||||
objects.push(JSON.parse(candidate))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (objects.length > 0) {
|
||||
return chooseBestResponseObject(objects)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function parseToolCall(message: any): ToolUseBlock | null {
|
||||
const toolCall = message?.tool_call ?? message?.tool_call?.arguments ? message?.tool_call : undefined
|
||||
if (!toolCall || typeof toolCall.name !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
let input: Record<string, unknown> = {}
|
||||
if (typeof toolCall.arguments === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(toolCall.arguments)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
input = parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed JSON
|
||||
}
|
||||
} else if (toolCall.arguments && typeof toolCall.arguments === 'object') {
|
||||
input = toolCall.arguments as Record<string, unknown>
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tool_use',
|
||||
id: toolCall.id ?? `${toolCall.name}:${Math.random().toString(16).slice(2)}`,
|
||||
name: toolCall.name,
|
||||
input,
|
||||
}
|
||||
}
|
||||
|
||||
function parseOllamaContent(message: any): ContentBlock[] {
|
||||
if (typeof message === 'string') {
|
||||
return [{ type: 'text', text: message }]
|
||||
}
|
||||
|
||||
const content: ContentBlock[] = []
|
||||
const items = Array.isArray(message.content) ? message.content : []
|
||||
|
||||
for (const item of items) {
|
||||
if (item?.type === 'text' && typeof item.text === 'string') {
|
||||
content.push({ type: 'text', text: item.text })
|
||||
} else if (item?.type === 'tool_use' && typeof item.id === 'string' && typeof item.name === 'string') {
|
||||
content.push({
|
||||
type: 'tool_use',
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
input: item.input ?? {},
|
||||
})
|
||||
} else if (item?.type === 'tool_result' && typeof item.tool_use_id === 'string') {
|
||||
content.push({
|
||||
type: 'tool_result',
|
||||
tool_use_id: item.tool_use_id,
|
||||
content: typeof item.content === 'string' ? item.content : String(item.content ?? ''),
|
||||
is_error: Boolean(item.is_error),
|
||||
})
|
||||
} else if (item?.type === 'image' && item.source) {
|
||||
content.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: item.source.media_type ?? 'image/png',
|
||||
data: item.source.data ?? '',
|
||||
},
|
||||
})
|
||||
} else if (typeof item === 'string') {
|
||||
content.push({ type: 'text', text: item })
|
||||
}
|
||||
}
|
||||
|
||||
return content.length > 0
|
||||
? content
|
||||
: [{ type: 'text', text: String(message.content ?? '') }]
|
||||
}
|
||||
|
||||
function buildOllamaResponse(body: any): LLMResponse {
|
||||
const choice = Array.isArray(body.choices) && body.choices.length > 0
|
||||
? body.choices[0]
|
||||
: null
|
||||
|
||||
const message = choice?.message ?? body.message ?? body
|
||||
const content = parseOllamaContent(message)
|
||||
|
||||
const toolUse = parseToolCall(message)
|
||||
if (toolUse) {
|
||||
content.push(toolUse)
|
||||
}
|
||||
|
||||
return {
|
||||
id: body.id ?? choice?.id ?? '',
|
||||
content,
|
||||
model: body.model ?? choice?.model ?? 'ollama',
|
||||
stop_reason: normalizeFinishReason(choice?.finish_reason ?? body.finish_reason),
|
||||
usage: {
|
||||
input_tokens: body.usage?.prompt_tokens ?? body.usage?.input_tokens ?? 0,
|
||||
output_tokens: body.usage?.completion_tokens ?? body.usage?.output_tokens ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stripTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$|$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight adapter for Ollama's local `/api/chat` endpoint.
|
||||
*/
|
||||
export class OllamaAdapter implements LLMAdapter {
|
||||
readonly name = 'ollama'
|
||||
readonly #baseUrl: string
|
||||
readonly #apiKey?: string
|
||||
|
||||
constructor(apiKey?: string, baseUrl = DEFAULT_BASE_URL) {
|
||||
const envApiKey = (globalThis as any).process?.env?.OLLAMA_API_KEY
|
||||
this.#apiKey = apiKey ?? (typeof envApiKey === 'string' ? envApiKey : undefined)
|
||||
this.#baseUrl = stripTrailingSlash(baseUrl)
|
||||
}
|
||||
|
||||
private buildRequestBody(messages: LLMMessage[], options: LLMChatOptions): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
model: options.model,
|
||||
messages: toOllamaMessages(messages, options.systemPrompt),
|
||||
temperature: options.temperature ?? 1,
|
||||
max_tokens: options.maxTokens,
|
||||
}
|
||||
|
||||
if (options.tools) {
|
||||
body.functions = options.tools.map(toOllamaFunction)
|
||||
body.function_call = 'auto'
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (this.#apiKey) {
|
||||
headers.Authorization = `Bearer ${this.#apiKey}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// chat()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async chat(messages: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
||||
const url = `${this.#baseUrl}/api/chat`
|
||||
const requestBody = this.buildRequestBody(messages, options)
|
||||
requestBody.stream = false
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: options.abortSignal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Ollama API request failed (${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
const body = parseTextAsJson(text)
|
||||
if (body === null) {
|
||||
throw new Error(
|
||||
`Ollama API returned invalid JSON response: ${text.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return buildOllamaResponse(body)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// stream()
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async *stream(messages: LLMMessage[], options: LLMStreamOptions): AsyncIterable<StreamEvent> {
|
||||
const url = `${this.#baseUrl}/api/chat`
|
||||
const requestBody = this.buildRequestBody(messages, options)
|
||||
requestBody.stream = true
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: options.abortSignal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(`Ollama API request failed (${response.status}): ${text}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('Ollama stream response has no body')
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let lastMessage: any = null
|
||||
let accumulatedText = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
while (true) {
|
||||
const boundary = buffer.indexOf('\n\n')
|
||||
if (boundary === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
const packet = buffer.slice(0, boundary)
|
||||
buffer = buffer.slice(boundary + 2)
|
||||
const lines = packet.split(/\r?\n/)
|
||||
let data = ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
data += line.slice(5).trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (data === '[DONE]') {
|
||||
buffer = ''
|
||||
break
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(data)
|
||||
const choice = Array.isArray(payload.choices) && payload.choices.length > 0
|
||||
? payload.choices[0]
|
||||
: null
|
||||
const delta = choice?.delta ?? payload.delta
|
||||
|
||||
if (delta?.content) {
|
||||
const text = String(delta.content)
|
||||
accumulatedText += text
|
||||
yield { type: 'text', data: text }
|
||||
}
|
||||
|
||||
const toolDelta = delta?.tool_call ?? payload.tool_call
|
||||
if (toolDelta || choice?.message?.tool_call || payload.message?.tool_call) {
|
||||
const toolCallMessage = toolDelta ?? choice?.message?.tool_call ?? payload.message?.tool_call
|
||||
const toolUse = parseToolCall({ tool_call: toolCallMessage })
|
||||
if (toolUse) {
|
||||
yield { type: 'tool_use', data: toolUse }
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.message) {
|
||||
lastMessage = payload.message
|
||||
}
|
||||
if (choice?.message) {
|
||||
lastMessage = choice.message
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE payloads.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalMessage: any = lastMessage ?? {
|
||||
id: '',
|
||||
model: options.model,
|
||||
message: { content: [{ type: 'text', text: accumulatedText }] },
|
||||
usage: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
finish_reason: 'stop',
|
||||
}
|
||||
|
||||
const isEmptyMessage = finalMessage?.message &&
|
||||
((typeof finalMessage.message.content === 'string' && finalMessage.message.content.trim() === '') ||
|
||||
(Array.isArray(finalMessage.message.content) && finalMessage.message.content.length === 0))
|
||||
|
||||
if (isEmptyMessage && accumulatedText.length > 0) {
|
||||
finalMessage = {
|
||||
...finalMessage,
|
||||
message: { content: [{ type: 'text', text: accumulatedText }] },
|
||||
}
|
||||
}
|
||||
|
||||
const finalResponse: LLMResponse = buildOllamaResponse(finalMessage)
|
||||
|
||||
yield { type: 'done', data: finalResponse }
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
yield { type: 'error', data: error }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -186,7 +186,7 @@ export interface ToolDefinition<TInput = Record<string, unknown>> {
|
|||
export interface AgentConfig {
|
||||
readonly name: string
|
||||
readonly model: string
|
||||
readonly provider?: 'anthropic' | 'openai'
|
||||
readonly 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'
|
||||
readonly defaultProvider?: 'anthropic' | 'openai' | 'ollama'
|
||||
onProgress?: (event: OrchestratorEvent) => void
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue