/** * Ollama Provider Adapter * * 本地推理引擎,支持 Tool Calling、Thinking 模式、NDJSON 流式。 * 无需 API Key,连接本地 http://localhost:11434。 * * 完整实现 Ollama API 文档中所有端点和参数: * - POST /api/chat (对话) * - POST /api/generate (补全) * - POST /api/embed (嵌入) * - GET /api/tags (列出模型) * - POST /api/show (模型详情) * - POST /api/pull (下载模型) * - GET /api/ps (运行中模型) * - GET /api/version (版本) * - think 参数(Thinking 模式) * - options 参数(temperature/top_k/top_p/stop/num_ctx/num_predict) * - format 参数(structured output) * - images 参数(多模态) * - tool_calls 流式处理 * * @see apis/ollama-api-docs-20260518.html */ import { BaseAdapter } from './base-adapter'; import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types'; import { MetonaFinishReason, MetonaStreamEventType } from '../types'; export class OllamaAdapter extends BaseAdapter { override readonly provider: string = 'ollama'; readonly supportedModels = ['qwen3:latest', 'gemma3:latest', 'deepseek-r1:latest']; readonly supportsToolCalling = true; readonly supportsThinking = true; private baseURL: string; constructor(config: ConstructorParameters[0]) { super(config); this.baseURL = config.baseURL || 'http://localhost:11434'; } // ===== POST /api/chat ===== async chat(request: MetonaRequest): Promise { const nativeRequest = this.toNativeRequest(request); const response = await fetch(`${this.baseURL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...nativeRequest, stream: false }), signal: AbortSignal.timeout(this.config.timeoutMs ?? 300_000), }); if (!response.ok) { throw new Error(`Ollama API error: ${response.status}`); } const data = await response.json(); return this.toMetonaResponse(data, request.meta.requestId); } async *chatStream(request: MetonaRequest): AsyncIterable { const nativeRequest = this.toNativeRequest(request); const response = await fetch(`${this.baseURL}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...nativeRequest, stream: true }), }); if (!response.ok || !response.body) { throw new Error(`Ollama stream error: ${response.status}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let seq = 0; let buffer = ''; // 工具调用缓冲区 const toolCallsBuffer = new Map(); while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; try { const chunk = JSON.parse(trimmed); // 思考内容 if (chunk.message?.thinking) { yield { type: MetonaStreamEventType.REASONING_DELTA, requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: seq++, timestamp: Date.now(), delta: chunk.message.thinking, }; } // 文本内容 if (chunk.message?.content) { yield { type: MetonaStreamEventType.TEXT_DELTA, requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: seq++, timestamp: Date.now(), delta: chunk.message.content, }; } // 工具调用(Ollama 在最后一个 chunk 中整块返回) if (chunk.message?.tool_calls) { for (const tc of chunk.message.tool_calls) { yield { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: seq++, timestamp: Date.now(), toolCall: { id: `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, name: tc.function?.name ?? '', args: tc.function?.arguments ?? {}, iteration: request.meta.iteration, timestamp: Date.now(), }, }; } } // 流结束 if (chunk.done) { // 发送 usage 信息 yield { type: MetonaStreamEventType.USAGE, requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: seq++, timestamp: Date.now(), usage: { inputTokens: chunk.prompt_eval_count ?? 0, outputTokens: chunk.eval_count ?? 0, totalTokens: (chunk.prompt_eval_count ?? 0) + (chunk.eval_count ?? 0), }, }; yield { type: MetonaStreamEventType.DONE, requestId: request.meta.requestId, sessionId: request.meta.sessionId, iteration: request.meta.iteration, seq: seq++, timestamp: Date.now(), }; return; } } catch { // 跳过解析失败的行 } } } } // ===== POST /api/generate ===== async generate(params: { model: string; prompt: string; suffix?: string; system?: string; stream?: boolean; think?: boolean | string; format?: string | object; images?: string[]; options?: Record; }): Promise<{ response: string; thinking?: string; done: boolean; totalDuration: number; evalCount: number }> { const response = await fetch(`${this.baseURL}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...params, stream: false }), signal: AbortSignal.timeout(300_000), }); if (!response.ok) throw new Error(`Ollama generate error: ${response.status}`); const data = await response.json() as { response?: string; thinking?: string; done?: boolean; total_duration?: number; eval_count?: number; }; return { response: data.response ?? '', thinking: data.thinking, done: data.done ?? true, totalDuration: data.total_duration ?? 0, evalCount: data.eval_count ?? 0, }; } // ===== POST /api/embed ===== async embed(params: { model: string; input: string | string[]; dimensions?: number; }): Promise<{ embeddings: number[][]; totalDuration: number }> { const response = await fetch(`${this.baseURL}/api/embed`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), signal: AbortSignal.timeout(60_000), }); if (!response.ok) throw new Error(`Ollama embed error: ${response.status}`); const data = await response.json() as { embeddings?: number[][]; total_duration?: number }; return { embeddings: data.embeddings ?? [], totalDuration: data.total_duration ?? 0, }; } // ===== GET /api/tags ===== async listModels(): Promise { try { const response = await fetch(`${this.baseURL}/api/tags`, { signal: AbortSignal.timeout(10_000), }); if (!response.ok) return this.supportedModels; const data = await response.json() as { models?: Array<{ name: string }> }; return data.models?.map((m) => m.name) ?? this.supportedModels; } catch { return this.supportedModels; } } // ===== POST /api/show ===== async showModel(model: string): Promise<{ parameters: string; template: string; capabilities: string[] } | null> { try { const response = await fetch(`${this.baseURL}/api/show`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model }), signal: AbortSignal.timeout(10_000), }); if (!response.ok) return null; const data = await response.json() as { parameters?: string; template?: string; capabilities?: string[] }; return { parameters: data.parameters ?? '', template: data.template ?? '', capabilities: data.capabilities ?? [], }; } catch { return null; } } // ===== POST /api/pull ===== async pullModel(model: string, onProgress?: (progress: { status: string; completed?: number; total?: number }) => void): Promise { const response = await fetch(`${this.baseURL}/api/pull`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, stream: true }), }); if (!response.ok || !response.body) throw new Error(`Ollama pull error: ${response.status}`); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; for (const line of lines) { if (!line.trim()) continue; try { const chunk = JSON.parse(line); onProgress?.({ status: chunk.status, completed: chunk.completed, total: chunk.total }); } catch {} } } } // ===== GET /api/ps ===== async listRunning(): Promise> { try { const response = await fetch(`${this.baseURL}/api/ps`, { signal: AbortSignal.timeout(10_000), }); if (!response.ok) return []; const data = await response.json() as { models?: Array<{ name: string; size?: number; size_vram?: number; context_length?: number }> }; return (data.models ?? []).map((m) => ({ name: m.name ?? '', size: m.size ?? 0, sizeVram: m.size_vram ?? 0, contextLength: m.context_length ?? 0, })); } catch { return []; } } // ===== GET /api/version ===== async getVersion(): Promise { try { const response = await fetch(`${this.baseURL}/api/version`, { signal: AbortSignal.timeout(5_000), }); if (!response.ok) return 'unknown'; const data = await response.json() as { version?: string }; return data.version ?? 'unknown'; } catch { return 'unknown'; } } // ========== 私有转换方法 ========== private toNativeRequest(request: MetonaRequest): Record { const messages = [ { role: 'system', content: [ request.systemPrompt.roleDefinition, request.systemPrompt.outputConstraints, request.systemPrompt.safetyGuidelines, request.systemPrompt.dynamicReminders, ].filter(Boolean).join('\n\n'), }, ...request.messages.filter((m) => m.role !== 'system').map((m) => { const msg: Record = { role: m.role, content: m.content }; // Ollama 图片使用 images 字段(纯 base64 数组,不含 data: 前缀) if (m.images?.length) { msg.images = m.images.map((img) => { const url = img.url; // data:image/png;base64,iVBOR... → iVBOR... if (url.startsWith('data:')) { const base64Part = url.split(',')[1]; return base64Part ?? url; } return url; }); } // 工具结果 if (m.role === 'tool' && m.toolResult) { msg.tool_call_id = m.toolResult.toolCallId; msg.content = typeof m.toolResult.result === 'string' ? m.toolResult.result : JSON.stringify(m.toolResult.result); } // assistant 工具调用(Ollama REST API 要求 arguments 为 JSON 字符串) if (m.role === 'assistant' && m.toolCalls?.length) { msg.tool_calls = m.toolCalls.map((tc) => ({ function: { name: tc.name, arguments: JSON.stringify(tc.args) }, })); } // 推理内容回传(保持多轮推理链完整) if (m.role === 'assistant' && m.reasoningContent) { (msg as Record).reasoning_content = m.reasoningContent; } return msg; }), ]; const body: Record = { model: this.config.defaultModel, messages, options: { temperature: request.params.temperature, num_predict: request.params.maxTokens, top_p: request.params.topP, stop: request.params.stopSequences, num_ctx: request.params.contextLength, }, }; // Tool Calling if (request.tools?.length) { body.tools = request.tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters, }, })); } // Thinking 模式 if (request.params.thinkingEnabled) { const effortMap: Record = { low: 'low', medium: 'medium', high: 'high', max: true }; body.think = effortMap[request.params.thinkingEffort ?? 'high'] ?? true; } return body; } private toMetonaResponse(data: Record, requestId: string): MetonaResponse { const message = data.message as Record | undefined; const toolCalls = message?.tool_calls as Array> | undefined; return { meta: { requestId, provider: this.provider, model: (data.model as string) ?? this.config.defaultModel, latencyMs: 0, timestamp: Date.now(), perfStats: { loadDurationMs: data.load_duration ? (data.load_duration as number) / 1e6 : undefined, promptEvalDurationMs: data.prompt_eval_duration ? (data.prompt_eval_duration as number) / 1e6 : undefined, evalDurationMs: data.eval_duration ? (data.eval_duration as number) / 1e6 : undefined, tokensPerSecond: data.eval_count && data.eval_duration ? ((data.eval_count as number) / ((data.eval_duration as number) / 1e9)) : undefined, }, }, content: (message?.content as string) ?? '', reasoningContent: message?.thinking as string | undefined, toolCalls: toolCalls?.map((tc, i) => { const fn = tc.function as Record; return { id: `tc_${Date.now()}_${i}`, name: (fn?.name as string) ?? '', args: (fn?.arguments as Record) ?? {}, iteration: 0, timestamp: Date.now(), }; }), usage: { inputTokens: (data.prompt_eval_count as number) ?? 0, outputTokens: (data.eval_count as number) ?? 0, totalTokens: ((data.prompt_eval_count as number) ?? 0) + ((data.eval_count as number) ?? 0), }, finishReason: mapOllamaDoneReason(data.done_reason as string | undefined, !!message?.tool_calls), }; } } /** * 映射 Ollama done_reason → MetonaFinishReason * * @see apis/ollama-api-docs-20260518.html — /api/chat 响应字段 */ function mapOllamaDoneReason( reason: string | undefined, hasToolCalls: boolean, ): MetonaFinishReason { if (hasToolCalls) return MetonaFinishReason.TOOL_CALLS; switch (reason) { case 'stop': return MetonaFinishReason.STOP; case 'length': return MetonaFinishReason.LENGTH; case 'load': return MetonaFinishReason.STOP; // 冷启动加载完成,非错误 case 'unload': return MetonaFinishReason.STOP; default: return MetonaFinishReason.STOP; } }