Extract OpenAI helpers and add Ollama support
Introduce shared OpenAI/Ollama wire-format helpers (src/llm/openai-common.ts) and refactor the Ollama adapter to reuse them. Update Ollama client baseURL and API key handling, add example env detection for OLLAMA_BASE_URL and prefer Ollama for the analyst agent when present. Also update types to include 'ollama' in provider unions and adjust readonly annotations. Minor formatting/packaging updates and small cleanup in adapter and example files.
This commit is contained in:
parent
5a384a9315
commit
4e4918c4bb
|
|
@ -136,6 +136,7 @@ function buildCustomAgent(
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useOpenAI = Boolean(process.env.OPENAI_API_KEY)
|
||||
const useOllama = Boolean(process.env.OLLAMA_BASE_URL);
|
||||
|
||||
const researcherConfig: AgentConfig = {
|
||||
name: 'researcher',
|
||||
|
|
@ -151,7 +152,8 @@ Return the raw rates as a JSON object keyed by pair, e.g. { "USD/EUR": 0.91, "US
|
|||
|
||||
const analystConfig: AgentConfig = {
|
||||
name: 'analyst',
|
||||
model: useOllama ? 'llama3.1' : useOpenAI ? 'gpt-4o-mini' : 'claude-3-5-sonnet-20240620',\n provider: useOllama ? 'ollama' : useOpenAI ? 'openai' : 'anthropic',
|
||||
model: useOllama ? 'llama3.1' : useOpenAI ? 'gpt-4o-mini' : 'claude-3-5-sonnet-20240620',
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
{
|
||||
"name": "maestro-agents",
|
||||
"name": "@jackchen_me/open-multi-agent",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "maestro-agents",
|
||||
"name": "@jackchen_me/open-multi-agent",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"ollama": "^0.3.0",
|
||||
"openai": "^4.73.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
|
|
|
|||
50
package.json
50
package.json
|
|
@ -1,49 +1 @@
|
|||
{
|
||||
"name": "@jackchen_me/open-multi-agent",
|
||||
"version": "0.1.0",
|
||||
"description": "Production-grade multi-agent orchestration framework. Model-agnostic, supports team collaboration, task scheduling, and inter-agent communication.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "tsc --noEmit",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"agent",
|
||||
"multi-agent",
|
||||
"orchestration",
|
||||
"llm",
|
||||
"claude",
|
||||
"openai",
|
||||
"mcp",
|
||||
"tool-use",
|
||||
"agent-framework"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"openai": "^4.73.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
{"name":"@jackchen_me/open-multi-agent","version":"0.1.0","description":"Production-grade multi-agent orchestration framework. Model-agnostic, supports team collaboration, task scheduling, and inter-agent communication.","type":"module","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"scripts":{"build":"tsc","dev":"tsc --watch","test":"vitest run","test:watch":"vitest","lint":"tsc --noEmit","prepublishOnly":"npm run build"},"keywords":["ai","agent","multi-agent","orchestration","llm","claude","openai","ollama","mcp","tool-use","agent-framework"],"author":"","license":"MIT","engines":{"node":">=18.0.0"},"dependencies":{"@anthropic-ai/sdk":"^0.52.0","openai":"^4.73.0","zod":"^3.23.0"},"devDependencies":{"typescript":"^5.6.0","vitest":"^2.1.0","@types/node":"^22.0.0"}}
|
||||
|
|
|
|||
|
|
@ -78,4 +78,3 @@ export async function createAdapter(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,172 +56,15 @@ import type {
|
|||
// 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<string, unknown>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown> = {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolCall.function.arguments)
|
||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsedInput = parsed as Record<string, unknown>
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
import {
|
||||
toOpenAITool,
|
||||
hasToolResults,
|
||||
toOpenAIMessages,
|
||||
toOpenAIUserMessage,
|
||||
toOpenAIAssistantMessage,
|
||||
fromOpenAICompletion,
|
||||
normalizeFinishReason,
|
||||
} from './openai-common.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Adapter implementation
|
||||
|
|
@ -240,7 +83,8 @@ export class OllamaAdapter implements LLMAdapter {
|
|||
|
||||
constructor() {
|
||||
this.#client = new OpenAI({
|
||||
baseURL: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434',
|
||||
baseURL: process.env.OLLAMA_BASE_URL ?? 'http://localhost:11434/v1',
|
||||
apiKey: 'ollama', // dummy API key
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* @fileoverview Shared OpenAI wire-format helpers for Ollama and OpenAI adapters.
|
||||
*
|
||||
* These functions convert between the framework's internal types and the
|
||||
* OpenAI/Ollama Chat Completions wire format. Both adapters should import
|
||||
* from here rather than duplicating the conversion logic.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ChatCompletion,
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionTool,
|
||||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
} from 'openai/resources/chat/completions/index.js'
|
||||
|
||||
import type {
|
||||
ContentBlock,
|
||||
LLMMessage,
|
||||
LLMResponse,
|
||||
LLMToolDef,
|
||||
StreamEvent,
|
||||
TextBlock,
|
||||
ToolUseBlock,
|
||||
} from '../types.js'
|
||||
|
||||
/**
|
||||
* Convert a framework {@link LLMToolDef} to an OpenAI/Ollama {@link ChatCompletionTool}.
|
||||
*/
|
||||
export function toOpenAITool(tool: LLMToolDef): ChatCompletionTool {
|
||||
return {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema as Record<string, unknown>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a framework message contains any `tool_result` content
|
||||
* blocks, which must be serialised as separate OpenAI/Ollama `tool`-role messages.
|
||||
*/
|
||||
export function hasToolResults(msg: LLMMessage): boolean {
|
||||
return msg.content.some((b) => b.type === 'tool_result')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a single framework {@link LLMMessage} into one or more OpenAI/Ollama
|
||||
* {@link ChatCompletionMessageParam} entries.
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
|
||||
export function toOpenAIUserMessage(msg: LLMMessage): ChatCompletionUserMessageParam {
|
||||
if (msg.content.length === 1 && msg.content[0]?.type === 'text') {
|
||||
return { role: 'user', content: msg.content[0].text }
|
||||
}
|
||||
|
||||
const parts: Array<{ type: 'text', text: string } | { type: 'image_url', image_url: { url: string } }> = []
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an OpenAI/Ollama {@link ChatCompletion} into a framework {@link LLMResponse}.
|
||||
*/
|
||||
export function fromOpenAICompletion(completion: ChatCompletion): LLMResponse {
|
||||
const choice = completion.choices[0]
|
||||
if (choice === undefined) {
|
||||
throw new Error('Completion returned 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<string, unknown> = {}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(toolCall.function.arguments)
|
||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
parsedInput = parsed as Record<string, unknown>
|
||||
}
|
||||
} 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an OpenAI/Ollama `finish_reason` string to the framework's canonical
|
||||
* stop-reason vocabulary.
|
||||
*/
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a system message when `systemPrompt` is provided, then append the
|
||||
* converted conversation messages.
|
||||
*/
|
||||
export 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 helpers.
|
||||
export type {
|
||||
ContentBlock,
|
||||
LLMMessage,
|
||||
LLMToolDef,
|
||||
ToolUseBlock,
|
||||
}
|
||||
|
|
@ -186,7 +186,7 @@ export interface ToolDefinition<TInput = Record<string, unknown>> {
|
|||
export interface AgentConfig {
|
||||
readonly name: string
|
||||
readonly model: string
|
||||
provider?: 'anthropic' | 'openai' | 'ollama'
|
||||
readonly provider?: 'anthropic' | 'ollama' | 'openai'
|
||||
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
|
||||
defaultProvider?: 'anthropic' | 'openai' | 'ollama'
|
||||
readonly defaultProvider?: 'anthropic' | 'ollama' | 'openai'
|
||||
onProgress?: (event: OrchestratorEvent) => void
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue