ollamaClient.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { config } from "../config/index.js";
  2. import { createTtlCache } from "./simpleCache.js";
  3. function timeoutSignal(externalSignal, timeoutMs) {
  4. const controller = new AbortController();
  5. const timer = setTimeout(() => controller.abort(new Error(`ollama_timeout:${timeoutMs}ms`)), timeoutMs);
  6. const onExternalAbort = () => controller.abort(externalSignal.reason);
  7. if (externalSignal) {
  8. if (externalSignal.aborted) controller.abort(externalSignal.reason);
  9. else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
  10. }
  11. return {
  12. signal: controller.signal,
  13. cleanup() {
  14. clearTimeout(timer);
  15. externalSignal?.removeEventListener("abort", onExternalAbort);
  16. }
  17. };
  18. }
  19. async function ollamaFetch(path, body, { signal } = {}) {
  20. const { signal: combinedSignal, cleanup } = timeoutSignal(signal, config.ollama.timeoutMs);
  21. let res;
  22. try {
  23. res = await fetch(`${config.ollama.url}${path}`, {
  24. method: "POST",
  25. headers: { "content-type": "application/json" },
  26. body: JSON.stringify(body),
  27. signal: combinedSignal
  28. });
  29. } finally {
  30. cleanup();
  31. }
  32. if (!res.ok) {
  33. const text = await res.text().catch(() => "");
  34. let msg = `ollama_error:${res.status}:${text || res.statusText}`;
  35. if (
  36. res.status === 404 &&
  37. typeof body?.model === "string" &&
  38. typeof text === "string" &&
  39. text.toLowerCase().includes("model") &&
  40. text.toLowerCase().includes("not found")
  41. ) {
  42. let installed = [];
  43. try {
  44. const tagsRes = await fetch(`${config.ollama.url}/api/tags`);
  45. const tags = await tagsRes.json().catch(() => ({}));
  46. installed = Array.isArray(tags?.models) ? tags.models.map((m) => m?.name).filter(Boolean) : [];
  47. } catch {}
  48. const installedList = installed.length ? installed.slice(0, 10).join(",") : "none";
  49. msg = `ollama_model_not_found:${body.model}:installed=${installedList}:hint=ollama pull ${body.model}`;
  50. }
  51. const err = new Error(msg);
  52. err.statusCode = 502;
  53. throw err;
  54. }
  55. return res.json();
  56. }
  57. function embedPrefixFor(role) {
  58. if (role === "query") return config.ollama.embedQueryPrefix;
  59. if (role === "passage") return config.ollama.embedPassagePrefix;
  60. return "";
  61. }
  62. const EMBED_CACHE_TTL_MS = 15 * 60 * 1000;
  63. const embedCache = createTtlCache(EMBED_CACHE_TTL_MS);
  64. async function embedSingle(text, { role, model } = {}) {
  65. const effectiveModel = model ?? config.ollama.embeddingsModel;
  66. const cacheKey = `${effectiveModel}:${role ?? ""}:${text}`;
  67. const cached = embedCache.get(cacheKey);
  68. if (cached) return cached;
  69. const prefix = embedPrefixFor(role);
  70. const data = await ollamaFetch("/api/embeddings", {
  71. model: effectiveModel,
  72. prompt: `${prefix}${text}`
  73. });
  74. embedCache.set(cacheKey, data.embedding);
  75. return data.embedding;
  76. }
  77. export async function embedTexts(texts, { role, model } = {}) {
  78. const BATCH = 10;
  79. const results = [];
  80. for (let i = 0; i < texts.length; i += BATCH) {
  81. const batch = texts.slice(i, i + BATCH);
  82. const embeddings = await Promise.all(batch.map((text) => embedSingle(text, { role, model })));
  83. results.push(...embeddings);
  84. }
  85. return results;
  86. }
  87. export async function chatCompletion({ messages, options, format, model, signal }) {
  88. const data = await ollamaFetch(
  89. "/api/chat",
  90. {
  91. model: model || config.ollama.chatModel,
  92. messages,
  93. stream: false,
  94. ...(format && { format }),
  95. ...(options && Object.keys(options).length > 0 && { options })
  96. },
  97. { signal }
  98. );
  99. return {
  100. content: data?.message?.content ?? ""
  101. };
  102. }
  103. export async function chatCompletionStream({ messages, onChunk, signal, options }) {
  104. const idleTimeoutMs = config.ollama.streamIdleTimeoutMs;
  105. const idleController = new AbortController();
  106. let idleTimer = setTimeout(
  107. () => idleController.abort(new Error(`ollama_stream_idle_timeout:${idleTimeoutMs}ms`)),
  108. idleTimeoutMs
  109. );
  110. const resetIdleTimer = () => {
  111. clearTimeout(idleTimer);
  112. idleTimer = setTimeout(
  113. () => idleController.abort(new Error(`ollama_stream_idle_timeout:${idleTimeoutMs}ms`)),
  114. idleTimeoutMs
  115. );
  116. };
  117. const onExternalAbort = () => idleController.abort(signal.reason);
  118. if (signal) {
  119. if (signal.aborted) idleController.abort(signal.reason);
  120. else signal.addEventListener("abort", onExternalAbort, { once: true });
  121. }
  122. try {
  123. const res = await fetch(`${config.ollama.url}/api/chat`, {
  124. method: "POST",
  125. headers: { "content-type": "application/json" },
  126. body: JSON.stringify({
  127. model: config.ollama.chatModel,
  128. messages,
  129. stream: true,
  130. ...(options && Object.keys(options).length > 0 && { options })
  131. }),
  132. signal: idleController.signal
  133. });
  134. if (!res.ok) {
  135. const text = await res.text().catch(() => "");
  136. const err = new Error(`ollama_error:${res.status}:${text || res.statusText}`);
  137. err.statusCode = 502;
  138. throw err;
  139. }
  140. const reader = res.body.getReader();
  141. const decoder = new TextDecoder();
  142. let fullContent = "";
  143. let buffer = "";
  144. const processLine = (line) => {
  145. if (!line.trim()) return;
  146. try {
  147. const parsed = JSON.parse(line);
  148. const delta = parsed?.message?.content ?? "";
  149. if (delta) {
  150. fullContent += delta;
  151. onChunk(delta);
  152. }
  153. } catch {}
  154. };
  155. while (true) {
  156. const { done, value } = await reader.read();
  157. if (done) break;
  158. resetIdleTimer();
  159. buffer += decoder.decode(value, { stream: true });
  160. const lines = buffer.split("\n");
  161. buffer = lines.pop() ?? "";
  162. for (const line of lines) processLine(line);
  163. }
  164. buffer += decoder.decode();
  165. processLine(buffer);
  166. return { content: fullContent };
  167. } finally {
  168. clearTimeout(idleTimer);
  169. signal?.removeEventListener("abort", onExternalAbort);
  170. }
  171. }
  172. export async function visionExtractFromImage({ imageBase64, prompt }) {
  173. const data = await ollamaFetch("/api/chat", {
  174. model: config.ollama.visionModel,
  175. messages: [
  176. {
  177. role: "user",
  178. content: prompt,
  179. images: [imageBase64]
  180. }
  181. ],
  182. stream: false
  183. });
  184. return {
  185. content: data?.message?.content ?? ""
  186. };
  187. }