import type { Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import OpenAI from 'openai' import type { ChatCompletionChunk, ChatCompletionCreateParamsStreaming, } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' import { ProviderError } from '@/providers/types' import { calculateCost, generateSchemaInstructions, prepareToolExecution, sumToolCosts, } from '@/providers/utils' import { executeTool } from '@/tools' /** * Ollama enforces JSON mode (`json_object`) but ignores `json_schema`, so * structured outputs use JSON mode with the schema described in-prompt. Mutates * `payload.response_format` and returns the messages with instructions appended. */ function applyJsonResponseFormat( payload: { response_format?: unknown }, messages: Message[], responseFormat: NonNullable ): Message[] { payload.response_format = { type: 'json_object' } const schema = responseFormat.schema || responseFormat return [ ...messages, { role: 'user', content: generateSchemaInstructions(schema, responseFormat.name) }, ] } /** * Per-provider hooks for the shared Ollama execution logic. The self-hosted * `ollama` and hosted `ollama-cloud` providers differ only in client * construction and labels; both pass those in here. */ export interface OllamaCoreConfig { /** Provider id used for trace enrichment (`ollama`, `ollama-cloud`). */ providerId: string /** Human-readable label used in log messages. */ providerLabel: string /** Builds the OpenAI-compatible client (base URL + credentials per provider). */ createClient: () => OpenAI createStream: ( stream: AsyncIterable, onComplete?: (content: string, usage: CompletionUsage) => void ) => ReadableStream logger: Logger } /** * Shared execution logic for the Ollama-family providers, which speak the same * OpenAI-compatible Ollama API. Ollama ignores `tool_choice`, so tools are sent * as `tool_choice: 'auto'` (forced tools degrade to auto) and the final post-tool * call drops tools entirely rather than relying on `tool_choice: 'none'`. */ export async function executeOllamaProviderRequest( request: ProviderRequest, config: OllamaCoreConfig ): Promise { const { providerId, providerLabel, logger } = config logger.info(`Preparing ${providerLabel} request`, { model: request.model, hasSystemPrompt: !!request.systemPrompt, hasMessages: !!request.messages?.length, hasTools: !!request.tools?.length, toolCount: request.tools?.length || 0, hasResponseFormat: !!request.responseFormat, stream: !!request.stream, }) const ollama = config.createClient() const allMessages: Message[] = [] if (request.systemPrompt) { allMessages.push({ role: 'system', content: request.systemPrompt, }) } if (request.context) { allMessages.push({ role: 'user', content: request.context, }) } if (request.messages) { allMessages.push(...request.messages) } const formattedMessages = formatMessagesForProvider(allMessages, providerId) as Message[] const tools = request.tools?.length ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool)) : undefined const payload: any = { model: request.model, messages: formattedMessages, } if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_tokens = request.maxTokens let hasActiveTools = false if (tools?.length) { const filteredTools = tools.filter((tool) => { const toolId = tool.function?.name const toolConfig = request.tools?.find((t) => t.id === toolId) return toolConfig?.usageControl !== 'none' }) const hasForcedTools = tools.some((tool) => { const toolId = tool.function?.name const toolConfig = request.tools?.find((t) => t.id === toolId) return toolConfig?.usageControl === 'force' }) if (hasForcedTools) { logger.warn( `${providerLabel} does not support forced tool selection (tool_choice parameter is ignored). ` + 'Tools marked with usageControl="force" will behave as "auto" instead.' ) } if (filteredTools?.length) { payload.tools = filteredTools payload.tool_choice = 'auto' hasActiveTools = true logger.info(`${providerLabel} request configuration:`, { toolCount: filteredTools.length, toolChoice: 'auto', forcedToolsIgnored: hasForcedTools, model: request.model, }) } } // With tools, defer structured output to the final call so JSON mode doesn't preempt tool use. if (request.responseFormat && !hasActiveTools) { payload.messages = applyJsonResponseFormat(payload, payload.messages, request.responseFormat) logger.info(`Added JSON response format to ${providerLabel} request`) } const providerStartTime = Date.now() const providerStartTimeISO = new Date(providerStartTime).toISOString() try { if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) { logger.info(`Using streaming response for ${providerLabel} request`) const streamingParams: ChatCompletionCreateParamsStreaming = { ...payload, stream: true, stream_options: { include_usage: true }, } const streamResponse = await ollama.chat.completions.create( streamingParams, request.abortSignal ? { signal: request.abortSignal } : undefined ) const streamingResult = createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, createStream: ({ output, finalizeTiming }) => config.createStream(streamResponse, (content, usage) => { output.content = content if (content && request.responseFormat) { output.content = content.replace(/```json\n?|\n?```/g, '').trim() } output.tokens = { input: usage.prompt_tokens, output: usage.completion_tokens, total: usage.total_tokens, } const costResult = calculateCost( request.model, usage.prompt_tokens, usage.completion_tokens ) output.cost = { input: costResult.input, output: costResult.output, total: costResult.total, } finalizeTiming() }), }) return streamingResult } const initialCallTime = Date.now() let currentResponse = await ollama.chat.completions.create( payload, request.abortSignal ? { signal: request.abortSignal } : undefined ) const firstResponseTime = Date.now() - initialCallTime let content = currentResponse.choices[0]?.message?.content || '' if (content && request.responseFormat) { content = content.replace(/```json\n?|\n?```/g, '') content = content.trim() } const tokens = { input: currentResponse.usage?.prompt_tokens || 0, output: currentResponse.usage?.completion_tokens || 0, total: currentResponse.usage?.total_tokens || 0, } const toolCalls = [] const toolResults: Record[] = [] const currentMessages = [...formattedMessages] let iterationCount = 0 let modelTime = firstResponseTime let toolsTime = 0 const timeSegments: TimeSegment[] = [ { type: 'model', name: request.model, startTime: initialCallTime, endTime: initialCallTime + firstResponseTime, duration: firstResponseTime, }, ] while (iterationCount < MAX_TOOL_ITERATIONS) { if (currentResponse.choices[0]?.message?.content) { content = currentResponse.choices[0].message.content if (request.responseFormat) { content = content.replace(/```json\n?|\n?```/g, '').trim() } } const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, toolCallsInResponse, { model: request.model, provider: providerId, } ) if (!toolCallsInResponse || toolCallsInResponse.length === 0) { break } logger.info( `Processing ${toolCallsInResponse.length} tool calls (iteration ${iterationCount + 1}/${MAX_TOOL_ITERATIONS})` ) const toolsStartTime = Date.now() const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => { const toolCallStartTime = Date.now() const toolName = toolCall.function.name try { const toolArgs = JSON.parse(toolCall.function.arguments) const tool = request.tools?.find((t) => t.id === toolName) if (!tool) return null const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request) const result = await executeTool(toolName, executionParams, { signal: request.abortSignal, }) const toolCallEndTime = Date.now() return { toolCall, toolName, toolParams, result, startTime: toolCallStartTime, endTime: toolCallEndTime, duration: toolCallEndTime - toolCallStartTime, } } catch (error) { const toolCallEndTime = Date.now() logger.error('Error processing tool call:', { error, toolName }) return { toolCall, toolName, toolParams: {}, result: { success: false, output: undefined, error: getErrorMessage(error, 'Tool execution failed'), }, startTime: toolCallStartTime, endTime: toolCallEndTime, duration: toolCallEndTime - toolCallStartTime, } } }) const executionResults = await Promise.allSettled(toolExecutionPromises) currentMessages.push({ role: 'assistant', content: null, tool_calls: toolCallsInResponse.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.function.name, arguments: tc.function.arguments, }, })), }) for (const settledResult of executionResults) { if (settledResult.status === 'rejected' || !settledResult.value) continue const { toolCall, toolName, toolParams, result, startTime, endTime, duration } = settledResult.value timeSegments.push({ type: 'tool', name: toolName, startTime: startTime, endTime: endTime, duration: duration, toolCallId: toolCall.id, }) let resultContent: any if (result.success && result.output) { toolResults.push(result.output) resultContent = result.output } else { resultContent = { error: true, message: result.error || 'Tool execution failed', tool: toolName, } } toolCalls.push({ name: toolName, arguments: toolParams, startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), duration: duration, result: resultContent, success: result.success, }) currentMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(resultContent), }) } const thisToolsTime = Date.now() - toolsStartTime toolsTime += thisToolsTime const nextPayload = { ...payload, messages: currentMessages, } const nextModelStartTime = Date.now() currentResponse = await ollama.chat.completions.create( nextPayload, request.abortSignal ? { signal: request.abortSignal } : undefined ) const nextModelEndTime = Date.now() const thisModelTime = nextModelEndTime - nextModelStartTime timeSegments.push({ type: 'model', name: request.model, startTime: nextModelStartTime, endTime: nextModelEndTime, duration: thisModelTime, }) modelTime += thisModelTime if (currentResponse.choices[0]?.message?.content) { content = currentResponse.choices[0].message.content if (request.responseFormat) { content = content.replace(/```json\n?|\n?```/g, '').trim() } } if (currentResponse.usage) { tokens.input += currentResponse.usage.prompt_tokens || 0 tokens.output += currentResponse.usage.completion_tokens || 0 tokens.total += currentResponse.usage.total_tokens || 0 } iterationCount++ } if (iterationCount === MAX_TOOL_ITERATIONS) { enrichLastModelSegmentFromChatCompletions( timeSegments, currentResponse, currentResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: providerId } ) } if (request.stream) { logger.info(`Using streaming for final ${providerLabel} response after tool processing`) const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output) const { tools: _tools, tool_choice: _toolChoice, ...streamPayload } = payload const finalMessages = request.responseFormat ? applyJsonResponseFormat(streamPayload, currentMessages, request.responseFormat) : currentMessages const streamingParams: ChatCompletionCreateParamsStreaming = { ...streamPayload, messages: finalMessages, stream: true, stream_options: { include_usage: true }, } const streamResponse = await ollama.chat.completions.create( streamingParams, request.abortSignal ? { signal: request.abortSignal } : undefined ) const streamingResult = createStreamingExecution({ model: request.model, providerStartTime, providerStartTimeISO, timing: { kind: 'accumulated', modelTime, toolsTime, firstResponseTime, iterations: iterationCount + 1, timeSegments, }, initialTokens: { input: tokens.input, output: tokens.output, total: tokens.total, }, initialCost: { input: accumulatedCost.input, output: accumulatedCost.output, total: accumulatedCost.total, }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length, } : undefined, createStream: ({ output }) => config.createStream(streamResponse, (content, usage) => { output.content = content if (content && request.responseFormat) { output.content = content.replace(/```json\n?|\n?```/g, '').trim() } output.tokens = { input: tokens.input + usage.prompt_tokens, output: tokens.output + usage.completion_tokens, total: tokens.total + usage.total_tokens, } const streamCost = calculateCost( request.model, usage.prompt_tokens, usage.completion_tokens ) const tc = sumToolCosts(toolResults) output.cost = { input: accumulatedCost.input + streamCost.input, output: accumulatedCost.output + streamCost.output, toolCost: tc || undefined, total: accumulatedCost.total + streamCost.total + tc, } }), }) return streamingResult } // Deferred structured output: one final JSON-mode call now that tools have run. if (request.responseFormat && hasActiveTools) { const finalPayload: any = { model: payload.model } if (payload.temperature !== undefined) finalPayload.temperature = payload.temperature if (payload.max_tokens !== undefined) finalPayload.max_tokens = payload.max_tokens finalPayload.messages = applyJsonResponseFormat( finalPayload, currentMessages, request.responseFormat ) const finalStartTime = Date.now() const finalResponse = await ollama.chat.completions.create( finalPayload, request.abortSignal ? { signal: request.abortSignal } : undefined ) const finalEndTime = Date.now() timeSegments.push({ type: 'model', name: 'Final structured response', startTime: finalStartTime, endTime: finalEndTime, duration: finalEndTime - finalStartTime, }) modelTime += finalEndTime - finalStartTime if (finalResponse.choices[0]?.message?.content) { content = finalResponse.choices[0].message.content.replace(/```json\n?|\n?```/g, '').trim() } if (finalResponse.usage) { tokens.input += finalResponse.usage.prompt_tokens || 0 tokens.output += finalResponse.usage.completion_tokens || 0 tokens.total += finalResponse.usage.total_tokens || 0 } enrichLastModelSegmentFromChatCompletions( timeSegments, finalResponse, finalResponse.choices[0]?.message?.tool_calls, { model: request.model, provider: providerId } ) } const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime return { content, model: request.model, tokens, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, toolResults: toolResults.length > 0 ? toolResults : undefined, timing: { startTime: providerStartTimeISO, endTime: providerEndTimeISO, duration: totalDuration, modelTime: modelTime, toolsTime: toolsTime, firstResponseTime: firstResponseTime, iterations: iterationCount + 1, timeSegments: timeSegments, }, } } catch (error) { const providerEndTime = Date.now() const providerEndTimeISO = new Date(providerEndTime).toISOString() const totalDuration = providerEndTime - providerStartTime let errorMessage = getErrorMessage(error, 'Unknown error') let errorType: string | undefined let errorCode: string | undefined let status: number | undefined if (error instanceof OpenAI.APIError) { errorMessage = error.message errorType = error.type errorCode = error.code ?? undefined status = error.status } logger.error(`Error in ${providerLabel} request:`, { error: errorMessage, errorType, errorCode, status, duration: totalDuration, }) throw new ProviderError(errorMessage, { startTime: providerStartTimeISO, endTime: providerEndTimeISO, duration: totalDuration, }) } }